Compare commits

..
Author SHA1 Message Date
fez5587andClaude Sonnet 5.5 7d92387bb8 coach: accept rules_context in build_user_prompt (every Coach request failed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 23:01:48 -05:00
fez5587andClaude Sonnet 5.5 a09eb5b21a farmhouse: sync Forge fixes (display, one deck folder, absolute result path) and phase model-seat safety (pre-validation, circuit-breaker, cost counters)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 22:32:14 -05:00
fez5587andClaude Sonnet 5.5 0efd4847a3 FarmHouse: Forge in the image, rules-accurate simulation with phase-rs, model seats
Forge restored as the default batch engine (and documented: farmhouse/forge). New: routes/farmhouse_phase.py and ui/phase-sim.html (four saved decks play real Commander games on the phase-rs engine; wins by seat, 95% interval, warnings), reference copies of the phase-sim service, Dockerfiles, deployment and the llm-seat crate (model-driven seats that choose among the engine's own legal options, with fallback to phase's AI and a call cap) under farmhouse/phase. Patches registered in lab_api.py, linked from the home page; Forge batches clamped to one thread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 20:20:10 -05:00
fez5587andClaude Sonnet 5.5 946a3706da docs: assessment of phase-rs and commander-playtester for the Magic component
Researched from public sources only; nothing run or vendored. phase: rules engine, headless ai-duel, phase-llm model seats speaking the System One protocol (Laya/Kev), 96% of the saved decks' cards supported. playtester: browser app on phase, no licence file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 19:50:51 -05:00
fez5587andClaude Sonnet 5.5 6569bb6f0d FarmHouse: Laya connections (Forge concrete path, extra questions, capture, fine-tuning data, card roles)
farmhouse_laya.py: Laya as a second opinion over Forge's concrete candidate ids (shadow by default; assist never overrides a pick Forge tagged stops_immediate_win/wins_now), two extra typed questions in the same forward pass, a laya-shadow-v1 capture log, batched cached card-role classification. scripts/farmhouse_laya_dataset.py turns the capture log into train/valid files with the baselines to beat; farmhouse_laya_roles_eval.py measures role accuracy. Hooks into farmhouse_decide.py. Reference copies of the Laya image, manifests and CI in farmhouse/laya/. docs/LAYA-CONNECTIONS.md. 39 offline tests. All off by default.

The matching hook in routes/policy.py is in the working tree only: that route belongs to the pending strategic-evaluator work.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 19:39:01 -05:00
fez5587andClaude Sonnet 5.5 32ab268f88 FarmHouse: card search returns each card once (index has one entry per printing)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:48:20 -05:00
fez5587andClaude Sonnet 5.5 abb26a679c FARMHOUSE-CHANGES: decision model section, settings, ollama package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:23:07 -05:00
fez5587andClaude Sonnet 5.5 635890deb6 Fix 34e0fc6: keep deepseek_brain.py to the decision-model wiring only
That commit also picked up uncommitted work from another editor of the same working tree (strategic_context.py and candidate_actions), importing a module that is not in the repository. This restores the file to the decision wiring alone; the other work is untouched in the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:05:47 -05:00
fez5587andClaude Sonnet 5.5 34e0fc6b2f FarmHouse: decision-model client (Laya/Kev) with shadow and assist modes
farmhouse_decide.py asks a /v1/systemone server which action to take; DECISION_MODE=shadow records agreement with the LLM, assist uses it when DECISION_MIN_CONF sure; off by default. scripts/farmhouse_decision_eval.py replays recorded decisions through it. 17 offline tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:05:07 -05:00
fez5587andClaude Sonnet 5.5 b779987802 FARMHOUSE-CHANGES: notes on making LLM-opponent turns faster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 16:53:23 -05:00
fez5587andClaude Sonnet 5.5 fee8ded52d FarmHouse: run Python-only on a local LLM; simulator plays real games
Starts from upstream 54cc5e9. Cards reach the simulator with type and text (filled from the Scryfall bulk DB) and the right quantities; the LLM engine asks several questions per turn and with reasoning off; Moxfield and Archidekt deck links work in every import and card names resolve locally; deck builds are fitted to the commander's colour identity; Scryfall format/User-Agent/429 handling fixed; coach init fixed. See FARMHOUSE-CHANGES.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 16:42:58 -05:00
rgriffithandClaude Opus 4.6 54cc5e9b2e fix(lan-client): swap P3/P4 grid positions for clockwise turn order
Swapped CSS grid-area, zone-row anchoring, commander-row anchoring,
label corners, and life-panel positions for .p3/.p4 so the turn
sequence P1→P2→P3→P4 reads clockwise around the battlefield.
Also added PLAYER_INDEX_TO_QUADRANT lookup in player-panels.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:22:32 -04:00
rgriffithandClaude Opus 4.6 9b79d18201 feat(lan-client): triggers panel + CMD DMG score badges + fix encoding project-wide
Left action panel revised to show triggers list + Create Token/Auto-Arrange.
CMD DMG badges added to center life-panel score areas (click opens matrix).
Removed renderEnhancedPanels calls that split the battlefield on damage adjust.
Added encoding='utf-8' to all bare open() calls across 17 Python files to
prevent cp1252 crashes on MTG card text with smart quotes/em-dashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:14:56 -04:00
rgriffithandClaude Opus 4.6 45ca961064 fix(lan-client): wire exit button to location.reload()
executeReset() references #game-board which doesn't exist in this HTML,
so the function threw a TypeError and silently did nothing. Swap the
exit button's onclick to location.reload() — the browser-refresh path
the user confirmed works to return to the setup screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:36:25 -04:00
rgriffithandClaude Opus 4.6 69bdd94ef2 feat(lan-client): hide AI hands + persist Scryfall image cache
AI hand privacy (rendering layer only):
- renderHandZone(): when player.isAI, render face-down violet card backs
  (dark purple diagonal hatch + ornate ring) instead of face-up thumbnails.
  Count stays public (standard MTG). No image, name, preview, drag, or
  click-to-open-modal on face-down thumbs.
- renderZoneModal(): filter the "Hand" tab out of the zone modal when
  inspecting an AI opponent; reset activeZoneTab to 'graveyard' if it was
  'hand' when an AI modal opens.
- New CSS: .hand-thumb.face-down (+ .overflow variant) with violet hatch
  pattern, radial glow, and centered purple ring pseudo-element.

Scryfall image cache persistence (eliminates per-game refetching):
- _scryfallCache now saves to localStorage under commanderLab.scryfallCache.v1
- 30-day TTL, 20,000-entry cap, graceful quota-exceeded handling
- Debounced auto-save (750ms) after each batch enrichment
- Cache-hit logging: logs hit ratio when refetching, or "from cache" when
  a whole game's card pool is already in localStorage
- clearScryfallCache() helper exposed on window for manual purges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:32:43 -04:00
rgriffithandClaude Opus 4.6 8fbebc21f3 feat(lan-client): charcoal felt battlefield + top-bar polish + return-to-start
Battlefield (charcoal felt):
- Darken --felt / --felt-light / --felt-dark to #181818 / #202020 / #0d0d0d
- Tighter --felt-line opacities (0.06 / 0.12) on the darker surface
- Finer-grain noise texture (baseFrequency 1.2, tile 180px) at 0.14 alpha
  with mix-blend-mode: overlay so the grain rides on the felt
- Deeper vignette (35% center, rgba(0,0,0,0.6) at edges)

Top bar:
- .top-bar-logo: 14px → 28px, align-self: stretch, flex-centered so
  "COMMANDER" fills the full 44px height
- .phase-step: 10px → 11px, roomier padding (2px 8px), gap 2→4px,
  letter-spacing 0.02em
- .btn-util: 22×22 → 32×32, font 10px → 15px (readable icons again)
- New .btn-util.btn-util-exit ✕ button (red-tinted, wired to
  confirmReset() → return to setup screen)
- Remove deck generator (⚡) and collection (📚) util buttons from the
  top bar (those panels are still reachable from other entry points)

Setup screen:
- Swap .setup-title from MTG gold to the same neon violet sign effect
  as the top-bar logo (4-layer text-shadow, uppercase, ls 0.08em)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:05:47 -04:00
rgriffithandClaude Opus 4.6 7cfdecb2c3 feat(lan-client): polish setup screen — charcoal bg, no rainbow bar, fit-to-screen
- .setup-card background: rgba(26,26,46,0.97) violet-tint → rgba(22,22,22,0.97)
  true charcoal that matches --color-surface-3
- Remove the 3px rainbow p1→p4 gradient strip from .setup-card::before
  (display: none)
- Compress spacing so the full lobby fits without scrolling:
  - padding 2.5rem → 1.25rem 1.5rem
  - title text-3xl → text-2xl, subtitle 2rem margin → 0.85rem
  - section margin 1.5rem → 0.75rem
  - label text-sm → text-xs with tighter margin
  - player-count-btn padding 0.75rem → 0.4rem
  - deck-precon-list max-height 140px → 90px
  - btn-start padding 1rem → 0.6rem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:53:27 -04:00
rgriffithandClaude Opus 4.6 38f3837f5a feat(lan-client): animated smoky purple summoning-sick overlay
Replace the nearly-invisible black diagonal stripes with a layered
animated purple hatch so sick creatures read instantly as "can't attack":

- Double-layer repeating-linear-gradient (two purple stripe opacities)
- sick-drift animation (3s linear) — stripes crawl diagonally
- sick-pulse animation (2.4s ease-in-out) — soft opacity breathing
- Inset 14px purple box-shadow for hazy edge glow

Matches the --accent / violet palette from the AI Lab redesign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:42:27 -04:00
rgriffithandClaude Opus 4.6 681888adcd feat(lan-client): port pure-black + violet + neon palette from AI Lab redesign
Apply the same 5-step visual redesign to the LAN client's MTG battlefield
theme while preserving functional game colors (mana, felt table, player
quadrants p1-p4).

- Swap Google Fonts import from Cinzel+Inter to Barlow Condensed + Barlow
- Pure black surfaces (#080808 / 0e / 11 / 16 / 1a)
- Swap primary accent from blue #5b8dd9 to electric violet #8b5cf6
- Square industrial radii (2/4/6/8px)
- Replace all hardcoded 'Cinzel' / 'Inter' with var(--font-display) /
  var(--font-body)
- Pure black top bar with violet underglow shadow
- Convert .top-bar-logo "Commander" title from gold to 4-layer violet
  neon sign with hover brightening
- Sweep 42 hardcoded rgba(91,141,217,...) blue values downstream to
  rgba(139,92,246,...) violet across buttons, zone tabs, stack items,
  combat UI, etc.

MTG functional colors (mana W/U/B/R/G, felt table, player quadrants)
remain intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:39:30 -04:00
rgriffithandClaude Opus 4.6 88c0d94d39 feat(ui): swap Inter for Barlow / Barlow Condensed display font
Load Barlow + Barlow Condensed from Google Fonts across all 8 HTML
entry points. In global.css introduce --font-display (Barlow Condensed)
alongside --font (Barlow), then wire the display font into every
chrome/heading selector: h1-h6, .page-header, .panel-header, .btn,
.stat-label/.stat-value, .form-label, .badge-status, .data-table
thead, .tag, .nav-brand, .nav-links. Body copy stays in Barlow for
readability.

Also normalize a few hardcoded font-families to design-system vars:
collection.css Cascadia Code stacks and deckgenerator.css Courier New
now use var(--font-mono); canvas rendering in distillation.js and
ai-lab.js swapped from Inter to Barlow so charts match the new type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:26:06 -04:00
KoalaTrapLord 4fdddf332e fix: add logging.basicConfig to trainer.py so training output is visible
The trainer used logger.info() for all output but never configured a
handler, so training ran silently with no console output.
2026-04-05 18:10:09 +00:00
KoalaTrapLord 213b9dc32d fix: total_mem → total_memory in trainer.py (PyTorch API) 2026-04-05 18:06:50 +00:00
KoalaTrapLord 2af1d58104 fix: write ML JSONL to commander-ai-lab/results/ (not Forge CWD)
LabCli passed relative 'results' to enableMlLogging(), which resolved
to Forge-Repo/forge-gui/results/ because the Java subprocess CWD is
forgeWorkDir. Now uses outputPath's parent directory (absolute) so
JSONL training data goes to commander-ai-lab/results/ where the
Python ML pipeline and Training page expect it.
2026-04-05 17:29:21 +00:00
KoalaTrapLord 547a84d8c2 fix: DecisionExtractor regex patterns to match actual Forge verbose output
Forge verbose log uses different verb forms and prefixes than the
patterns expected:
  - Turn: 'Turn: Turn 5 (Ai(1)' not 'Turn 5 (Ai(1)'
  - Cast: 'cast' (no s) not 'casts', 'Add To Stack:' prefix, no period
  - Land: 'played' not 'plays', 'Land:' prefix, card ID suffix '(76)'
  - Draw: 'drew' variant, card ID suffix

Updated all four patterns to match both old and new Forge output
formats. This is the final fix for the training data pipeline —
DecisionExtractor will now find cast/land/draw/attack events in
the Forge game log and emit JSONL training snapshots.
2026-04-05 17:24:34 +00:00
KoalaTrapLord 71b86b74eb fix: compile error in BatchRunner deck name log line 2026-04-05 16:55:38 +00:00
KoalaTrapLord d00093897a fix: read Name= from .dck header for Forge deck lookup
Forge's sim mode looks up decks by the Name= field inside the .dck
file metadata header, NOT by filename. For example:
  Filename: Counter_Intelligence.dck
  Header:   Name=Counter Intelligence
  Forge expects: 'Counter Intelligence' (with space)

BatchRunner now reads the Name= field from the .dck file and passes
that to Forge. Falls back to filename with underscores→spaces if
the Name= field can't be read.
2026-04-05 16:54:38 +00:00
KoalaTrapLord 0b01f1c467 fix: pass bare deck names to Forge sim (not full paths)
Forge's sim mode looks up decks by NAME in its own
%APPDATA%/Forge/decks/commander/ directory. Passing a full path like
D:\...\precon-decks\Counterpunch.dck causes 'Could not load deck'.

Now strips the path and .dck extension so Forge sees just the bare
name (e.g. 'Counterpunch') and finds it in its deck folder.

Precon .dck files must be copied to %APPDATA%/Forge/decks/commander/
for Forge to find them.
2026-04-05 16:47:54 +00:00
KoalaTrapLord 65873555bc fix: show all batch log lines in Live Output (was truncated to last 30)
Frontend: removed .slice(-30) — now shows all lines from backend.
Backend: bumped log line limit from 200 to 2000 so full Forge game
output is captured for debugging.
2026-04-05 16:35:21 +00:00
KoalaTrapLord 8995d743af fix: remove !quiet guard from ML decision logging in BatchRunner
The condition 'mlLoggingEnabled && !quiet && mlDecisionLogger != null'
silently skipped ML training data extraction whenever quiet mode was
active. DecisionExtractor works with the same log lines regardless of
quiet mode, so the guard was unnecessary and prevented JSONL generation.

Now: mlLoggingEnabled && mlDecisionLogger != null — ML logging runs in
all modes as long as --ml-log is passed.
2026-04-05 16:19:56 +00:00
KoalaTrapLord 14ff543135 fix: DecisionLogger JSONL filename prefix — ml-decisions-sim-* for dataset_builder discovery
The Java DecisionLogger was writing files as ml-decisions-{batchId}.jsonl
but the Python dataset_builder.py scans for ml-decisions-sim-*.jsonl to
categorize them as 'forge' source training data.

This one-line fix adds the 'sim-' prefix so the Forge batch heuristic
path now feeds directly into the ML training pipeline:

  BatchRunner → DecisionExtractor → DecisionLogger
    → results/ml-decisions-sim-{batchId}.jsonl
    → dataset_builder.py discovers as 'forge' source
    → encode → train

The full chain was already wired (Issue #83 Phase 1 infrastructure),
this naming mismatch was the only thing preventing it from working.

Refs: Issue #83 Phase 1
2026-04-05 16:04:12 +00:00
rgriffithandClaude Opus 4.6 ea23f97b9c chore: ignore runtime artifacts, add patch scripts & training manifest
Ignore static/, logs/, data/rag_chroma/, api/, image_cache/, .claude/,
precon-decks/, and dependency-reduced-pom.xml — all runtime/build
artifacts that were polluting git status. Also stage the one-off
patch scripts (fix_paths.py, patch_eval.py, patch_phase1.py,
forge_evaluator_FIXED.py) and training_manifest.json so they're
tracked alongside the code they support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:48:35 -04:00
KoalaTrapLord a23fdc55a4 feat: PolicyControllerAi integration — Forge AI decision interception via policy server
## New File
- PolicyControllerAi.java (840 lines): Extends Forge's PlayerControllerAi
  to intercept AI decisions at chooseSpellAbilityToPlay() and route them
  through the Python policy server. Falls back to Forge built-in AI on
  any failure. Inner PolicyAiController overrides AiController with full
  legal action gathering via ComputerUtilAbility.

## Wiring
- GameSession.java: Import + Javadoc for PolicyControllerAi decision flow.
  Logs decision pipeline when policy server connects. End-of-game reward
  submission via PolicyControllerAi lifecycle.
- BatchRunner.java: New setPolicyServer(url) method. Appends
  --policy midrange --policyServer <url> to Forge subprocess command when
  policy server URL is configured.

## Path Updates (Forge-Repo rename)
- README.md: Forge link → KoalaTrapLord/Forge-Repo, build path updated
- .env.example: FORGE_JAR_PATH and FORGE_WORKING_DIR → Forge-Repo
- lab_api.py: Auto-detect paths try Forge-Repo first, forge-repo as legacy

## MacroActionExecutor
- Added public isRemovalCard(), isDrawCard(), isRampCard() wrappers so
  PolicyControllerAi can consult the same card-role databases.

Refs: Issue #83 Phase 2.4
2026-04-05 14:47:15 +00:00
rgriffithandClaude Opus 4.6 4cae97ab44 fix(lan-client): compact phase pills, wrap NEXT PHASE/NEXT TURN labels
The phase track (BEGIN · MAIN 1 · COMBAT · END) was losing labels to
ellipsis on narrow top bars, and the primary action buttons were
clipping "NEXT PHASE"/"NEXT TURN" because they had white-space: nowrap
and a fixed height.

.phase-step:
  - tighter padding (1px 5px) and gap (2px)
  - no letter-spacing, smaller border-radius, flex-shrink: 1 so pills
    compress uniformly when the top bar is crowded
  - merged duplicate .phase-step.active rule and made it bolder so the
    active step still reads well after the shrink

.btn-phase (NEXT PHASE / NEXT TURN):
  - white-space: normal + word-break: break-word enable two-line wrap
  - height: auto + min-height: 34px lets the label breathe inside
    the 44px top bar
  - min-width: 64px / max-width: 80px keeps the buttons square-ish
  - display: flex centers the wrapped text vertically & horizontally
  - align-items: stretch on .top-bar-actions so both buttons match
    the tallest sibling's height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:39:18 -04:00
rgriffithandClaude Opus 4.6 d308e8f413 fix: AI land-only bug — enforce 1-land/turn, populate legal moves, tune policy
Addresses the three root causes in commander-ai-lab-land-only-bug-analysis.md
plus several collateral fixes discovered along the way.

P0 — Land-per-turn enforcement (engine.py, deepseek_engine.py, turn_manager.py)
  * Guard _play_land() against double plays by tracking
    _lands_played_this_turn vs _land_plays_this_turn_limit on each player
  * Reset per-turn counters at the start of each player's turn
  * Ramp cards that grant additional plays can bump the limit

P0 — Legal move pipeline (new legal_move_generator.py, game_state.py)
  * New generate_legal_moves() emits the full macro-action set
    (play_land, cast_creature, cast_removal, cast_ramp, cast_draw,
    cast_commander, attack, block, pass_priority) based on phase,
    active player, land-drop state, mana, and commander tax
  * get_legal_moves() delegates to the new generator instead of the
    pass-only stub that blocked the turn_manager/policy-server path
  * apply_move() gains handlers for play_land, cast_spell,
    cast_commander, and declare_attacker — spells resolve to
    battlefield or graveyard, stats update correctly

P1 — Policy exploration (LearnedPolicy.java, scope.py)
  * Default greedy=false so the neural policy samples its
    distribution instead of always taking argmax
  * Raise ppo_entropy_coeff 0.01 → 0.04 to keep the action
    distribution flat during PPO self-play and prevent the
    pass/land collapse

Collateral fixes pulled in during the same session:
  * lan-client/precon-loader.js: await enrichAllZonesWithScryfall()
    so opening-hand cards have typeLine/cmc before the AI's first turn
  * lan-client/ui.js: closeZoneModal auto-shuffles the library
    whenever the zone modal closes
  * routes/game.py: BatchState() was missing total_games/threads,
    crashing POST /api/game/start
  * routes/deckgen.py: dead code after raise left headers undefined,
    breaking the Anthropic API path

All sim/phase/integration tests pass (650+).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:34:37 -04:00
rgriffithandClaude Opus 4.6 44123f7622 fix: AI only plays lands — sync card enrichment, mana pool check, field alignment
- Add _enrichCardFromCache() in precon-loader.js for synchronous card
  enrichment at draw time (typeLine, cmc, oracle roles available before
  async Scryfall batch completes)
- Call _enrichCardFromCache in drawCard() so AI has complete card data
  on its first turn
- Improve _canAfford in ai-bridge.js: check actual mana pool before
  falling back to untapped land count; free spells (cmc 0) always castable
- Fix totalCards field name mismatch in ui.js and engine.js (lab.py
  returns totalCards, not card_count/total_cards)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 01:43:29 -04:00
rgriffithandClaude Opus 4.6 9eaa41a4ae feat: deck dropdown, user-decks pipeline, deckgen fixes, gpt-oss removal
- Add user-decks/ directory and USER_DECKS_DIR config for exported decks
- Update export-to-sim to write to user-decks/ with static mount in lab_api
- Replace import/paste UI with API-backed "My Decks" dropdown in LAN client
  and AI Lab, fetching from GET /api/lab/decks with source grouping
- Fix GET /api/lab/decks to JOIN deck_cards for totalCards count
- Fix deckgenerator: add missing DOM elements (dg-strategy-bar, dg-stats-bar,
  dg-card-groups) inside #dg-results, null guards on render functions,
  fix setLoading always hiding results
- Replace gpt-oss:20b with claude-opus-4-20250514 across all files
- Add user-decks/ to _BUILTIN_DECK_DIRS in deck_service.py
- Enrich missing type_line from Forge card cache in DB deck path
- Collapse import bar behind <details> toggle in AI Lab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 01:17:30 -04:00
rgriffithandClaude Opus 4.6 409aba26f4 feat: AI bug fixes, deck/collection migration, and enrichment pipeline
- Fix AI turn pipeline: add cmc to Scryfall cache, tag isRamp/isRemoval/
  isBoardWipe from oracle text during enrichment, enable post-combat spell
  casting for all difficulty levels
- Migrate deck builder, deck generator, and collection from ui/ to
  lan-client/ as iframe-embedded panels with LAB_API_BASE patching
- Fix ai-bridge.js: turnNumber, deck_name, battlefield serialization,
  5-playstyle mapping, window exports for cross-script access
- Fix engine.js: battlefieldCards flat array (not player.battlefield),
  aiDeclareAttacks extraction, combat overlay fixes
- Fix collection.js: template literal, column visibility alignment,
  set_code key, oracle text column, sortable category
- Add exile/graveyard last-card image rendering in ui.js
- Make state_encoder.py resilient to mixed input types
- Enrich type_line from Scryfall at card insert in deckbuilder.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 00:05:23 -04:00
rgriffithandClaude Opus 4.6 30c3ab5a89 fix: pass gameState to _canAfford in MacroActionMapper
Static method was referencing bare gameState which is always null.
Now accepts gameState as parameter, and all 5 callers pass it through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 17:02:05 -04:00
KoalaTrapLord b7f9c119cb feat(overnight): add --ds-weight arg to pass DeepSeek upsampling weight to /api/ml/train 2026-04-04 17:01:10 -04:00
rgriffithandClaude Opus 4.6 bbb7f7a966 fix: _getBattlefieldCards reads battlefieldCards instead of battlefield
Was reading gameState.battlefield (doesn't exist) causing AI to
receive empty board state. Now reads gameState.battlefieldCards
and matches by ownerIndex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 17:00:39 -04:00
rgriffithandClaude Opus 4.6 ef739e1f49 feat: UI improvements — silver commander border, life panel border, hand drag-and-drop, card placement
- Add bright silver 3D smoky border around commander card zones
- Wrap all 4 life panels in a single bordered container
- Add drag-and-drop from hand zone to battlefield with ghost preview
- Update card placement to center of battlefield, avoiding overlaid zones
- Fix card dimensions in placement calc (110x150 -> 63x84)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:50:56 -04:00
rgriffithandClaude Opus 4.6 920d7d9787 fix: remove player color border and owner bar from battlefield cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 15:55:56 -04:00
rgriffithandClaude Opus 4.6 52d51ebd5e feat: battlefield UI overhaul — life panels, card sizing, layout
- Move life/cmd-dmg/poison into 3-box life panels as a top-level
  overlay (z-index 50) centered at quadrant intersections
- Remove center emblem
- Match battlefield card size to hand cards (63x84px)
- Overlay zone-row and commander-row on battlefield edges to
  eliminate dead space and maximize battlefield area
- Restructure poison badges from per-player absolute positioning
  into life-panel counters box

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 15:50:35 -04:00
rgriffithandClaude Opus 4.6 db3675aa6a feat: enlarge hand card thumbnails to fill zone height
Scale .hand-thumb from 32x45px to 63x84px so cards fill the
hand zone vertically while maintaining MTG card proportions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:36:50 -04:00
rgriffith 99044342c2 fix: remove stray } after showManaPool stub (line 1856) 2026-04-04 13:33:37 -04:00
rgriffith d5fd648139 fix: update tracked paths — files moved from root to lan-client/ 2026-04-03 23:31:36 -04:00
rgriffith 022f89478d fix: restore ui.js from placeholder corruption + Phase 1 patches 2026-04-03 23:25:01 -04:00
KoalaTrapLord 336f913d4b feat(phase-1): no-op mana pool display functions in ui.js
- renderManaPool() -> stub (floating #mana-pool-display removed from UI)
- showManaPool() -> stub
- Remove 'Mana Auto-Clear' row from renderSettingsPanel()

Internal mana tracking state (manaPool object, canPayManaCost,
clearManaPool, clearAllManaPools, etc.) fully preserved in engine.js.
createInitialState() already had energy/experience/rad/emblems.
setupManaPoolDrag() in engine.js already a no-op stub.
index.html and style.css require no changes.
2026-04-03 18:54:22 -04:00
KoalaTrapLord 661847a4d8 feat(phase-1): no-op mana pool display functions in ui.js
- renderManaPool() → stub comment (floating #mana-pool-display removed)
- showManaPool() → stub comment
- Remove 'Mana Auto-Clear' row from renderSettingsPanel()

Internal mana tracking state (manaPool object, canPayManaCost,
clearManaPool, etc.) fully preserved in engine.js.
createInitialState() already had energy/experience/rad/emblems.
setupManaPoolDrag() in engine.js already stubbed.
index.html and style.css require no changes.

Closes #190 Phase 1
2026-04-03 18:51:15 -04:00
KoalaTrapLord e710fd5e03 placeholder 2026-04-03 18:50:51 -04:00
KoalaTrapLord 85af163123 Wire Forge IPC into POST /api/game/start via run_batch_subprocess + --policy flag 2026-04-03 17:57:35 -04:00
KoalaTrapLord f2e87feb4e Fix default port: 8000 -> 8080 2026-04-03 17:54:15 -04:00
KoalaTrapLord 948f8e0f8f Fix PS1 parse errors: escape apostrophes, fix string terminators, fix scriptblock braces 2026-04-03 17:52:08 -04:00
KoalaTrapLord 84d56bf901 Add Test-PolicyServer.ps1 — smoke-test health + decide endpoint from PowerShell 2026-04-03 17:50:04 -04:00
KoalaTrapLord 74d24d606c fix: wire PolicyClient into real Forge IPC path via Priority interception
Forge's sim mode does not honour --policy/--policyServer CLI flags, so
the Python brain was never called during live games. AI only played lands
because Forge ran its built-in AI for every decision.

Fix: when parseForgeLine() detects a Priority event for an AI seat, the
session now immediately calls policyClient.decide() with the current
state snapshot and POSTs the result to /api/policy/override before Forge
reads the next line. This mirrors what handleHumanTurn() already does
for seat 0. The synthetic fallback path is unchanged.
2026-04-03 17:42:01 -04:00
KoalaTrapLord 98e1092ebc feat: wire DecisionExporter into Forge pipeline + fix reward call signatures
- Add _export_forge_episode() adapter: translates ForgeEpisode transitions
  back into DecisionExporter.record_step() calls so Forge-sourced wins flow
  into the distillation JSONL pipeline (closes Phase 1 for real games)
- ppo_consumer() now accepts an optional `exporter: DecisionExporter` arg;
  begin_episode / record_step / end_episode are called around each episode
  as it is dequeued
- run_forge_pipeline() forwards the exporter kwarg down to ppo_consumer and
  calls exporter.flush() after the pipeline completes, returning the output
  path in the stats dict under "exporter_file"
- Fix snapshots_to_episode(): compute_terminal_reward() was called with
  non-existent kwargs `turns=` and `life_remaining=`; compute_intermediate_reward()
  was called with non-existent kwargs `life_delta=`, `cards_drawn=`,
  `creatures_played=`.  Both now use the correct signatures from reward.py
  (prev_state/curr_state/action_taken for intermediate; won/draw/timeout for
  terminal).  Forge snapshots carry `players` arrays so we reconstruct minimal
  prev/curr dicts for the intermediate path.

Ref: docs/CLOSED-LOOP-DISTILLATION.md Phase 1
2026-04-03 17:09:05 -04:00
KoalaTrapLord 9338ac881b fix(labeler): infer cast sub-type from hand contents when action.card absent
The Forge DecisionExporter writes action={"type":"cast"} without a card
field — action.get("card") and action.get("raw") always returned "",
causing every cast to fall through to the CAST_CREATURE default.

Fix: when card_name is empty, scan the active player's hand against
the removal / draw / ramp / commander name databases and pick the
highest-priority match found.  Priority order mirrors cast importance:
  removal > ramp > draw > commander > creature (default)

A hand-level match is a reasonable proxy for the decision made:
Forge's AI picks the highest-priority action from available options,
so if the hand contains a removal spell during main phase, the cast
action almost certainly represents that removal spell being played.

Also fixes print_label_distribution: was sorting by dict key (string)
instead of by index, causing the display order to be alphabetical
rather than matching the MacroAction enum order — making cast_creature
appear at position 0 in the sorted output even when it had counts.
Now prints in descending count order with correct enum-keyed lookup.
2026-04-03 16:55:59 -04:00
KoalaTrapLord c3ff9f8888 feat(data): add DeepSeek ds- source with 4x default upweight
_discover_jsonl_files now recognises ml-decisions-ds-*.jsonl as its own
"ds" source key instead of falling through to legacy/forge.

DatasetConfig.source_weights gains ds=4.0 as a default so the 8 ds files
(1775 records, ~5.8% raw) are oversampled to ~20% of effective training
tokens, matching their higher signal quality.

CLI gains --ds-weight argument (default 4.0) to override without code changes.

The training_manifest.json produced by PowerShell already encodes weight=4.0
per ds file; this change makes dataset_builder.py honour the same ratio
natively so the manifest and the builder stay in sync.
2026-04-03 16:46:57 -04:00
KoalaTrapLord e4f35a2d8b debug: log top-5 action probabilities on every decide() call
Adds a single INFO log line after each successful inference showing:
  - chosen action + confidence
  - top-5 actions sorted by probability
  - turn, phase, vector_source

This lets us diagnose the 'only plays lands' behaviour without a Python
REPL. Remove or downgrade to DEBUG once the training issue is resolved.
2026-04-03 16:08:50 -04:00
KoalaTrapLord 084742a56a docs: rewrite README — full feature coverage from 52 merged PRs 2026-04-03 20:04:32 +00:00
KoalaTrapLord 45a413837b docs: rewrite README — full feature coverage from 52 merged PRs 2026-04-03 20:04:32 +00:00
KoalaTrapLord 018852c92d fix: collapse cmdr_dmg dict to int in _encode_global before division
Forge sends cmdr_dmg as a dict keyed by opponent seat (e.g. {"0": 14,
"2": 7}). _encode_global read it raw and crashed on `dict / 21.0`.

Fix: resolve cmdr_dmg to a scalar at the read site — sum dict values if
it's a dict, cast to int if it's already a number, default to 0.
This is defensive for all call paths (live Forge IPC, JSONL training,
_build_encoder_snapshot pre-collapsed int) so no upstream change needed.
2026-04-03 15:46:36 -04:00
KoalaTrapLord 4ca21e5e29 debug: add exc_info=True + unique logger names to expose real traceback
Both 'Inference error' log lines use identical logger strings, making it
impossible to tell which except block is catching the crash. Also, broad
except blocks were swallowing the full traceback so the real crash site
and cause were hidden.

Changes:
- policy_server.py: logger renamed to 'ml.serving.policy_server';
  predict() and predict_batch() error handlers add exc_info=True
- routes/policy.py: logger renamed to 'routes.policy.decide';
  _infer_from_vector() error handler adds exc_info=True;
  decide() fallback warning adds exc_info=True

The next crash will print the full stack trace, pinpointing the exact
line and call chain causing the TypeError.
2026-04-03 15:39:54 -04:00
KoalaTrapLord 28665bf663 fix: apply _unwrap_logits guard in _infer_from_vector (routes/policy.py)
The crash `unsupported operand type(s) for /: 'dict' and 'float'` was
firing from _infer_from_vector(), which is the code path taken when
has_precomputed_vector() is True (GameSession 1.1.0+ snapshots with
a precomputed state_vector float[29]).

Unlike PolicyInferenceService.predict() — which was fixed in the
previous commit via _unwrap_logits — this function calls
policy_service.model(state_tensor) directly and immediately uses the
return value in arithmetic (logits / max(temperature, 0.01)), so any
tuple/dict return (DataParallel, torch.compile, PolicyValueNetwork
swap) causes the TypeError.

Fix: import _unwrap_logits from ml.serving.policy_server and apply it
to the raw model output in _infer_from_vector before the temperature
division and argmax calls. All three return shapes are now handled:
  - plain tensor  → passthrough (normal case, zero overhead)
  - tuple/list    → raw[0]  (PolicyValueNetwork (logits, value))
  - dict          → first recognised key or first tensor value
2026-04-03 15:16:49 -04:00
KoalaTrapLord 83d05294c8 fix: type-guard logits after model() to prevent 'dict / float' inference crash
If self.model is wrapped (DataParallel, torch.compile, or future
PolicyValueNetwork path), forward() can return a tuple (logits, value)
or even a dict.  Add an explicit unwrap immediately after the forward
call in both predict() and predict_batch() so every downstream
temperature/softmax operation always receives a plain tensor.
2026-04-03 14:55:58 -04:00
KoalaTrapLord e8f8766d59 fix: register policy routes at module scope before StaticFiles mounts
The root cause of the persistent /api/policy/health 404:
- app.mount("/", StaticFiles(...)) is called at module load time
- StaticFiles acts as a sub-app and consumes matching requests,
  returning its own 404 for unknown files rather than falling through
- register_policy_routes() was called in lifespan (runtime), AFTER
  the "/" mount was already in the route list — so the policy router
  was effectively unreachable

Fix: move route registration to module scope right after all other
app.include_router() calls and BEFORE any app.mount() calls. Only
the model load (_POLICY_SVC.load()) stays in lifespan.
2026-04-03 13:09:12 -04:00
KoalaTrapLord a69b273f5f fix: always register policy routes at startup regardless of model load state
Previously /api/policy/health returned 404 when the model wasn't loaded
because register_policy_routes() was gated behind _policy_svc.load().
The health endpoint itself already handles the unloaded state gracefully
(returns {ready: false, status: degraded}), so the router must be mounted
unconditionally so ai-bridge.js can poll it during startup.
2026-04-03 12:29:18 -04:00
KoalaTrapLord e55ac9aebf feat: add Scryfall image enrichment after .dck load — batch fetch card art for all zones 2026-04-03 11:30:30 -04:00
KoalaTrapLord d2b86ef2ed fix: wire precon-loader.js into index.html script load order 2026-04-03 11:19:37 -04:00
KoalaTrapLord 081dd66121 fix: add startGameWithDecks() and loadPreconDeckFromDck() for precon .dck loading into simulator 2026-04-03 11:17:27 -04:00
KoalaTrapLord 6d89d5bb08 fix: add PRECON_DIR env var (PRECON_DECKS_DIR was never read by lab_api.py) 2026-04-03 11:15:31 -04:00
KoalaTrapLord f29a5a31b5 feat: add /api/lab/precons and /api/lab/precons/deck endpoints
Fixes 404 errors from lan-client ai-bridge.js which calls these routes
to populate the deck selector. Routes were missing from lab.py — only
precon_router (routes/precon.py) existed but served different paths.

Also added PRECON_DECKS_DIR env var alias in lab_api.py _parse_args()
to match the .env variable name users commonly set.
2026-04-03 11:08:12 -04:00
KoalaTrapLord 370d4cd92f Merge pull request #200 from KoalaTrapLord/fix/serve-lan-client-from-backend
fix: Serve lan-client from backend at /lan (same-origin)
2026-04-03 10:48:01 -04:00
Commander AI Lab e90efe1654 fix: serve lan-client from backend at /lan on port 8080
- Mount lan-client/ as static files at /lan with html=True
- Mount precon-decks/ at /precon-decks/ for direct .dck access
- ai-bridge.js: use same-origin (empty URLs, auto-detect from window.location)
- engine.js: simplify deck fetch to use /api/lab/precons/deck endpoint + /precon-decks/ static fallback
- Eliminates cross-origin issues by serving everything from localhost:8080

Access the LAN battlefield at http://localhost:8080/lan/
2026-04-03 14:30:55 +00:00
KoalaTrapLord bf701397b0 Merge pull request #199 from KoalaTrapLord/fix/deck-loading-and-backend-detection
fix: Deck loading — wire setupDeckChoices, fetch .dck content, add precon/deck endpoint
2026-04-03 09:49:33 -04:00
Commander AI Lab 7750b43494 fix: deck loading — wire startGameWithDecks, fetch .dck content, parse [sections]
Three fixes that prevented precon decks from loading into actual gameplay:

1. ui.js: window.startGame() now calls startGameWithDecks() when
   setupDeckChoices has entries (the 3-tab selector stores choices there).
   Previously it called initGame() which looked for old deck-sel-{i}
   dropdowns that no longer exist.

2. engine.js: loadPreconForPlayer() now fetches .dck file content from
   the backend (/api/lab/precons/deck?fileName=...) when precon.decklist
   is empty. Falls back to fetching precon-decks/{file} directly.
   PRECON_DATA entries only have metadata (name, commander, set) — the
   actual card list lives in the .dck files.

3. engine.js: parseDeckList() now handles .dck bracket-style section
   headers like [Commander], [Main], [metadata]. Previously only matched
   bare text like 'COMMANDER:' without brackets. Also skips [metadata]
   lines (e.g. 'Name=Elven Empire').

4. routes/precon.py: Added GET /api/lab/precons/deck?fileName=... endpoint
   to serve raw .dck file content for the LAN client.
2026-04-03 13:45:15 +00:00
KoalaTrapLord fa58463cde Merge pull request #198 from KoalaTrapLord/fix/setup-screen-layout
fix: Setup screen overflows viewport — 2-col deck grid + scroll
2026-04-03 09:38:26 -04:00
Commander AI Lab 94b1e050d8 fix: setup screen overflows viewport — constrain height and use 2-col deck grid
- Add max-height: 92vh and overflow-y: auto to .setup-card
- Widen .setup-card to 860px to fit 2-column deck grid
- Change .deck-assignment-grid from vertical stack to 2-column grid
  so 4 player panels render in 2 rows instead of 4
- Tighten .deck-tab-content (170px) and .deck-precon-list (140px)
- Make top gradient bar sticky on scroll
- Responsive fallback to single column below 640px
2026-04-03 13:37:02 +00:00
KoalaTrapLord 8bfde0230b fix: add precons.js with 163 entries and fix backend precon UI refresh (#197)
- Generate lan-client/precons.js from precon-decks/precon-index.json so
  PRECON_DATA is always available for local/offline mode (163 precons)
- Add scripts/generate-precons-js.js for regeneration when precons update
- Fix _loadBackendPrecons() to re-render the precon tab UI when backend
  precons load successfully (was only updating status indicator text,
  not populating the dropdown)

Without precons.js, PRECON_DATA was undefined and the dropdown showed
'No precon data loaded' even though 163 .dck files exist in the repo.
2026-04-03 09:31:54 -04:00
KoalaTrapLordandClaude Opus 4.6 4214831e59 feat: LAN Battlefield App Integration with AI Pipeline (#196)
* feat: LAN Battlefield App integration with Commander-AI-Lab AI pipeline

Add the LAN Battlefield App as a browser client (lan-client/) that
connects to the Commander-AI-Lab backend for ML-powered AI opponents,
3-category deck selection, and enhanced player panels.

Phase 1 - Foundation:
- Remove floating mana pool display (keep internal tracking)
- Add energy, experience, rad, emblems fields to game state
- No-op renderManaPool(), showManaPool(), setupManaPoolDrag()

Phase 2 - Backend Connection (ai-bridge.js):
- BackendConnector: REST + WebSocket connectivity with retry/timeout
- GameStateBridge: Serializes engine.js state for /api/policy/decide
- MacroActionMapper: Translates 8 ML macro-actions to engine.js calls
- DeckSourceClient: Fetches precon/EDHREC decks from backend API

Phase 3 - Deck System:
- 3-tab deck selector (Precons / EDHREC / My Decks) replaces dropdown
- Precon grouping by set with collapsible sections and filtering
- EDHREC search integration (stub, ready for backend implementation)
- My Decks textarea import placeholder

Phase 4 - AI Integration:
- runAiTurn() priority chain: ML Pipeline > Forge > Local Heuristic
- _runMlAiTurn() async turn loop with multi-phase decision requests
- Backend session initialization on game start
- Automatic fallback to local AI on any ML pipeline error

Phase 5 - Enhanced Player Panels (player-panels.js):
- Collapsible panels with localStorage persistence
- Commander damage progress bars with warning/critical thresholds
- Poison, energy, experience, rad counter widgets
- Emblem tracking with add/remove

Phase 6 - Polish:
- Script loading order: precons > engine > ai-bridge > panels > ui
- Backend auto-detection on page load with status indicator
- Connection status display on setup screen

Closes #184, Closes #185, Closes #186, Closes #187, Closes #188, Closes #189

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

* fix: refine LAN battlefield integration with full JSDoc, proper phase IDs

- Add comprehensive JSDoc comments to all ai-bridge.js classes
- Use optional chaining for PHASES lookup in GameStateBridge
- Direct addLogEntry calls in player-panels.js (loaded after engine.js)
- Remove leftover mana pool CSS that was missed in initial pass
- Minor style.css cleanup for consistency

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-03 09:16:27 -04:00
KoalaTrapLord e2568c731c fix: auto-load .env in state.py so dotenv works without explicit load_dotenv call
When running plain `python script.py` or bare uvicorn, .env is never
loaded automatically. Added a dotenv bootstrap block at the top of
state.py that finds and loads the nearest .env file (walks up from
models/ to the project root). Uses load_dotenv with override=False so
explicitly set environment variables always win over .env values.
Gracefully skips if python-dotenv is not installed.
2026-04-03 08:41:58 -04:00
KoalaTrapLord d6863b6d7e fix: auto-populate all CFG paths from environment variables
CFG.forge_dir, precon_dir, forge_decks_dir, forge_jar, and lab_jar
were only set via CLI args. When the server starts via uvicorn directly
(no CLI), all paths stayed empty and forge_card_loader could not find
the cardsfolder, causing 0 lands in every deck.

Fix: read FORGE_DIR, FORGE_JAR, PRECON_DIR, PRECON_DECKS_DIR,
FORGE_DECKS_DIR, and LAB_JAR from environment at import time, mirroring
the existing ANTHROPIC_API_KEY / XIMILAR_API_KEY pattern.
2026-04-03 04:56:44 -04:00
KoalaTrapLord 1fc99a5d4a fix: auto-discover precon-decks/ and sample-decks/ when CFG paths unset
_find_dck_file only searched CFG.precon_dir / CFG.forge_decks_dir, both
of which are empty when the server is started without CLI args. This
caused all precon deck lookups to return 0 cards.

Fix: add _REPO_ROOT and _BUILTIN_DECK_DIRS constants, then append
precon-decks/, sample-decks/, imported-decks/ to the search list
whenever they exist on disk, regardless of whether CFG paths are set.
2026-04-03 04:50:49 -04:00
KoalaTrapLord eed2c7f126 New HTML battlefield to test features before Unity (#182) 2026-04-03 04:39:33 -04:00
KoalaTrapLord 7a40b5deb8 fix: enrich type_line/oracle from Forge cache in .dck fallback loader
_load_deck_cards_from_dck was hardcoding type_line="" for every card,
causing Card.is_land() to return False for all lands when a deck is
loaded via the .dck file path (i.e. not in the SQLite DB). This meant
the engine never recognised lands as lands, breaking mana production.

Fix: call lookup_forge_card() per card and populate type_line,
oracle_text, cmc, mana_cost, power, toughness, and keywords from the
Forge cardsfolder cache. The cache is lazy-loaded and module-level so
there is no per-game overhead after the first deck load.
2026-04-03 04:01:59 -04:00
KoalaTrapLord af8970f3a8 fix: _load_deck_cards_by_name falls back to .dck file when deck not in DB
Precon decks (Elven_Empire, Blood_Rites, etc.) are stored as .dck files
on disk but not inserted into the SQLite decks/deck_cards tables.
The DeepSeek batch engine calls _load_deck_cards_by_name() which
previously returned [] for any deck not in the DB, causing
'No decks could be loaded.' errors.

Adds _find_dck_file() and _load_deck_cards_from_dck() helpers.
_find_dck_file searches CFG.precon_dir, CFG.forge_decks_dir, and
FORGE_DIR/res/decks/commander with exact + fuzzy matching.
_load_deck_cards_by_name now tries DB first, then .dck fallback.
2026-04-02 22:59:01 -04:00
KoalaTrapLord 53fd1c9257 fix: wire RESULTS_DIR and FORGE_DIR env vars into CFG at startup 2026-04-02 14:24:28 -04:00
KoalaTrapLord 6ead75f96b fix: set FORGE_WORKING_DIR to forge-gui (requires res/ folder for sim to start) 2026-04-02 02:33:14 -04:00
KoalaTrapLord 535bdd7c70 fix: make Forge deck paths fully portable using %~dp0 and auto-generate forge.profile.properties 2026-04-02 00:59:10 -04:00
KoalaTrapLord d98e6331e9 fix: point all deck lookups to precon-decks directory
- start-lab.bat: Set FORGE_DECKS_DIR to D:\ForgeCommander\commander-ai-lab\precon-decks
- BatchRunner.java: resolveForgeDecksPath() now checks PRECON_DECKS_DIR env var
  first, then falls back to the hardcoded precon-decks path, before trying
  %APPDATA%\Forge\decks\commander. Also pass full absolute path to Forge -d flag.
- .env.example: document PRECON_DECKS_DIR variable
2026-04-01 23:10:32 -04:00
KoalaTrapLord 2b3196267f fix: raise default clock from 300ms to 5000ms to prevent turn-1 draw games 2026-04-01 22:56:29 -04:00
rgriffith 433e8bc4d7 feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 22:09:13 -04:00
KoalaTrapLord d74dd0a6bf feat: wire precon card art through local /card-image/ API with Scryfall fallback (full file) 2026-04-01 21:45:15 -04:00
KoalaTrapLord 8037802dff feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 21:45:01 -04:00
KoalaTrapLord ca6c23a881 fix: wrap shutil.copy2 in try/except for install and install-batch endpoints 2026-04-01 21:32:19 -04:00
KoalaTrapLord a81772c68a fix: set cwd=CFG.forge_dir on Popen so Forge finds res/ (#184) 2026-04-01 19:41:52 -04:00
KoalaTrapLord 99ca498b88 docs: add Unity TCGEngine Commander board implementation plan 2026-04-01 14:59:46 -04:00
KoalaTrapLord 395ad06cdd fix: resolve deck names to full .dck paths in build_java_command (#183) 2026-04-01 14:57:57 -04:00
KoalaTrapLord e4a6ce644d fix(#176): draw-game life capture, DrawGameAnalyzer, clock 6000→3000 (#181)
Captures life-at-timeout snapshots for every draw game via [DRAW] [PARSE-SUMMARY] log lines. Adds services/draw_game_analyzer.py with DrawGameSnapshot + summarize_draw_games(). Wires drawGames summary into GET /api/lab/result response. Lowers batch clock default from 6000 to 3000 (30s per-game). Closes #176.
2026-04-01 14:40:29 -04:00
KoalaTrapLord 5411fd6bf6 fix(#175): alternate win condition parser & winType tagging (#179)
Adds win_condition_parser.py, GameResult.win_type field, and winType stamping on both the Java/Forge and DeepSeek batch paths. Closes #175.
2026-04-01 14:28:10 -04:00
KoalaTrapLord 55fdc2ec4a feat: add CreateBattlegroundScene editor script to build full scene programmatically 2026-03-30 20:31:09 -04:00
KoalaTrapLord ddfe8e601e fix(eval): parse batch JSON output instead of JSONL for Forge game results 2026-03-30 16:12:20 -04:00
KoalaTrapLord 9e3cd45936 fix(eval): require >= 3 decks for Forge availability check 2026-03-30 15:45:20 -04:00
KoalaTrapLord c91f4c90bc fix(eval): discover 4 decks for 4-player Commander (Forge requires >= 3) 2026-03-30 15:43:27 -04:00
KoalaTrapLord 80f67dd0a0 fix(eval): use precon-decks/ for deck discovery instead of decks/ 2026-03-30 15:28:19 -04:00
rgriffith 10220ec747 feat: add 130+ precon commander deck files 2026-03-30 15:19:29 -04:00
rgriffith 6fadeaa387 fix(eval): replace Java bridge imports with subprocess CLI launch for Forge eval 2026-03-30 14:23:52 -04:00
rgriffith 517c17047b fix: strip value_head keys when loading PPO checkpoint into PolicyNetwork 2026-03-30 13:31:59 -04:00
KoalaTrapLord c62e75af52 fix(tests): update _build_document assertions to match NL sentence format 2026-03-30 12:36:00 -04:00
KoalaTrapLord 11940835fc fix: validate ollama suggest_cards names against DB + Scryfall before pipeline merge
- Add validate_card_names() helper: checks collection_entries DB first,
  then falls back to Scryfall /cards/named?fuzzy= for unknowns.
  Drops any name that cannot be resolved (hallucinated cards).
- Inject validation in Step 4 immediately after each suggest_cards()
  call so hallucinated names never reach ratio enforcement or assembly.
- Log dropped hallucinations at WARNING level with count + examples.
- Resolves the 'Sonic Hedgehog Charger' / 'Sonic Boom' class of
  LLM-invented card names that were silently entering the deck.
2026-03-30 12:33:28 -04:00
KoalaTrapLord d12b47f874 fix: robust suggest_cards parsing — CSV fallback + one-shot JSON prompt
- Add _parse_card_list_fallback() to recover plain-text CSV output from
  the LLM when json_mode fails to enforce JSON (e.g. qwen2.5:7b drift)
- Harden suggest_cards system prompt with a one-shot JSON example so
  small models are less likely to return prose in the first place
- Log recovered names at WARNING level so the degradation is visible
  without being silent (previously returned [] with no recovery attempt)
- No behaviour changes to any other function
2026-03-30 12:26:28 -04:00
KoalaTrapLord 5b5bac42fb fix: use vars(row) instead of dict(row) in _build_collection_from_bulk
dict() on a types.SimpleNamespace raises TypeError: object is not iterable.
vars() correctly extracts the __dict__ attribute map. Fixes
TestBuildCollectionFailureTracking::test_high_failure_rate_raises_runtime_error.
2026-03-30 10:39:49 -04:00
rgriffith 574bb79a0b fix(eval): correct indentation for load/reload in _load_policy_service 2026-03-30 10:36:43 -04:00
KoalaTrapLord 5554ef4dd7 fix(eval): call load() for embeddings before reload() for checkpoint 2026-03-30 09:53:56 -04:00
KoalaTrapLord cf2d7ed806 fix(eval): use reload() with explicit checkpoint path instead of load()
load() hardcodes best_policy.pt but PPO trainer saves as best_ppo.pt, causing the model to never load during eval. reload() accepts an explicit path.
2026-03-30 09:51:22 -04:00
KoalaTrapLord aec1896094 fix(engine): promote assign_attackers/assign_blockers/resolve_combat_damage to public methods (#86)
The three split-combat helpers were accidentally defined as nested
functions inside _resolve_combat(), making them unreachable as
GameEngine.assign_attackers() etc. from tests and turn_manager.

Move them out to class-level methods with correct 4-space indentation.
_resolve_combat() now remains as the backward-compatible atomic path for
run_n(); the split methods are available for turn_manager priority
windows and direct testing.
2026-03-30 09:50:56 -04:00
KoalaTrapLord 75785ae918 fix(tests): update RESPONSE_PHASES assertion to match current turn_manager design
begin_combat/end_combat are dispatched as named priority windows inside
_phase_combat(), not as entries in RESPONSE_PHASES. The constant is
correctly {"upkeep", "end_step"} — test was asserting stale intent.
2026-03-29 20:39:37 -04:00
KoalaTrapLord 252e46b288 fix(tests): swap Delta/Epsilon for Zeta/Theta to avoid _land_kw collision
Delta matches \bdelta\b in the nonbasic land guard regex, causing enrich_card
to classify it as a land (cmc=0). Zeta and Theta have no land keyword matches.
2026-03-29 20:37:04 -04:00
KoalaTrapLord e1bb2e7209 feat(coach): Phase 1 sim stats pass-through in prompt template (#108)
Surfaces previously-computed-but-unused stats into the Claude prompt:

1. USER_PROMPT_TEMPLATE gains a new '### Sim Insights' section that shows:
   - Deck-level opening hand quality: avg keptInOpeningHandRate across all
     cards, plus the 3 worst offenders with their actual rates
   - Curve velocity: avg avgTurnCast weighted by castRate, flagging cards
     cast on avg turn > 6 as 'late'
   - Synergy outliers: bottom-3 synergyScore cards flagged as 'isolated'
   - Archetype weak spots: archetypes with winRate < 40% flagged explicitly

2. _format_archetypes() now annotates weak spots with [WEAK SPOT] and
   strong matchups with [STRENGTH] so Claude sees them immediately.

3. _format_top_cards() adds keptInOpeningHandRate to each line so
   the 'good' cards also show their hand-keepability signal.

4. New helper _format_sim_insights() computes all deck-level aggregates
   from CardPerformance list — no new fields on DeckMeta needed.

No schema changes. Fully backward-compatible with existing DeckReport.
2026-03-29 05:55:51 -04:00
KoalaTrapLord e77a945805 test(encoder): unit tests for StateEncoder global features + Step 4 total_power (#83 Step 4)
New test file covering:
- TestGlobalFeatureDimensions: output shape (29,), all values in [0,1]
- TestTotalPowerRealValue: real total_power used at index 7 when present
- TestTotalPowerHeuristic: creatures*3 fallback when total_power absent/zero
- TestLandCount: real lands used at index 13 when present; defaults to 0
- TestCreatureCount: real creatures used at index 6; fallback to bf len
- TestManaFeature: mana normalised and clamped to [0,1]
- TestPhaseOneHot: exactly one hot in indices 9-12 per player block
- TestIsActivePlayer: binary 0/1 at index 8
- TestLifeNormalisation: 40 life → 1.0, 0 life → 0.0
- TestTurnFeature: turn clamped to [0,1]
- TestFullEncode: total state vector shape (6177,), dtype float32
- TestEmptyPlayer: _empty_player() has all Step 4 keys
- TestLegacySnapshot: old JSONL snapshot without Step 4 keys still encodes"
2026-03-29 05:46:01 -04:00
KoalaTrapLord 3f5d6d12e8 feat: 4-player Commander Battleground UI (issues #138–#151)
* chore: add battleground UI task tracker (2026-03-28)

* feat(#142): add [NonSerialized] battleground runtime state to CardModel

* feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache

* feat(#148): add GameStateManager singleton and GameState/SeatState POCOs

* feat(#138): add BattlegroundController scene orchestrator

* feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter

* feat(#140): add PhaseTrackerWidget and PhaseStepButton

* feat(#141): add TurnOrderBar and TurnOrderPill

* feat(#142): add CardZone and CardView battlefield zone components

* feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display

* feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView

* chore: mark issues 1-8 as complete in task tracker

* feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay

* feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController

* feat(#151): add Play Live button to SimulationController

* feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch

* fix: add PostGameAction to ApiClient + update TODO.md all issues complete

- ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action
  (fixes compile error in HumanActionBar.cs)
- TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅
  all 14 battleground issues now committed

* feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL

- routes/game.py: POST /api/game/start, POST /api/game/action (new)
- lab_api.py: import + register game_router
- GameWebSocketClient.cs: read game_id from PlayerPrefs, build
  ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py

* fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs

- OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config
- On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs,
  loads Battleground scene
- On failure: shows inline error label (red), re-enables Confirm button
- Added StartGameRequest/Response POCOs matching routes/game.py schema
- Added _errorLabel [SerializeField] for user-facing error feedback
2026-03-29 05:35:29 -04:00
KoalaTrapLord 915c745911 fix(eval): remove Set-StrictMode, fix curly-quotes and -- in strings (issue #153) 2026-03-29 05:31:58 -04:00
KoalaTrapLord 00374c1f03 feat(eval): add run_benchmark.ps1 — full benchmark pipeline for issue #153 2026-03-29 05:13:21 -04:00
KoalaTrapLord 6e71ba88fd fix(encoder): read real total_power from snapshot dict at global index 7 (#83 Step 4)
Previously _encode_global() always computed:
  total_power = creatures * 3  (heuristic)

Now reads p.get('total_power', 0) first, falling back to the heuristic
only when the key is absent (legacy JSONL snapshots without Step 4 data).

Also wires total_toughness, artifacts, and enchantments into _empty_player()
so padding players have consistent keys.
2026-03-29 05:13:19 -04:00
KoalaTrapLord 73d8e8d220 test(policy): add Step 4 board-stats tests + fix schema alias (#83 Step 4)
- TestBoardStatsWiring: verifies creaturesOnField, landCount,
  totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField,
  enchantmentsOnField are accepted without 422 and survive
  a round-trip through the fallback encoder path
- TestBoardStatsFallback: verifies resolver fallbacks when Forge
  fields are absent (creatures inferred from bf length,
  total_power falls back to creatures * 3 heuristic,
  lands defaults to 0)
- TestSchemaAlias: verifies snapshot_schema Field(alias='schema')
  still accepts JSON key 'schema' without 422
- Removes now-irrelevant UserWarning (schema shadow fixed)
2026-03-29 04:51:47 -04:00
KoalaTrapLord bff7b3a81e feat(eval): add forge_evaluator, eval_policy, compare_eval for issue #153
- ml/eval/forge_evaluator.py: reusable eval harness wrapping
  MultiThreadBatchRunner + PolicyInferenceService; collects win rate,
  avg turns, life differential, commander damage, illegal action rate,
  policy entropy per game; writes results/eval-{run_id}.json
- scripts/eval_policy.py: CLI to run N Forge eval games with any
  checkpoint vs forge-builtin or policy-server opponent; supports
  --threads, --clock, --seed, --greedy, --temperature
- scripts/compare_eval.py: loads two result JSONs, computes absolute
  win-rate delta + 95% CI, runs Fisher's exact test for significance,
  prints formatted comparison table and pass/fail verdict
2026-03-29 04:49:43 -04:00
KoalaTrapLord b1c17359f0 feat(policy): wire real Forge board stats into encoder snapshot (#83 Step 4)
Previously _build_encoder_snapshot() stubbed:
  - lands = 0  (always zero)
  - creatures = len(bf_names)  (name-list length, ignores tokens/etc)
  - mana = mana_available  (was already correct)
  - total_power / total_toughness / artifacts / enchantments = missing

Now reads real pre-computed values from PlayerZoneState:
  - creatures      ← creaturesOnField   (Forge board stat)
  - lands          ← landCount          (Forge board stat)
  - total_power    ← totalPowerOnBoard  (fed to global feature index 7)
  - total_toughness← totalToughnessOnBoard
  - artifacts      ← artifactsOnField
  - enchantments   ← enchantmentsOnField
  - mana           ← manaAvailable (unchanged)

Also replaces the `creatures * 3` proxy in _build_encoder_snapshot with
the real totalPowerOnBoard value so index-7 of the global feature vector
tracks actual power rather than a heuristic.

PlayerZoneState gains 5 new optional int fields (default 0) matching
DecisionSnapshot.PlayerSnapshot field names so JSON from both the live
IPC path and the JSONL training path deserialise without changes.
2026-03-29 04:49:40 -04:00
KoalaTrapLord 7440d0ce17 test(policy): add round-trip tests for /api/policy/decide state_vector path (#83 Step 2)
Covers:
  - health endpoint returns {ready, status, model_loaded}
  - precomputed fast path: state_vector float[29] → vector_source=precomputed
  - correct 6177-dim tensor shape reaches the mock model
  - all 8 MacroAction values are valid responses
  - encoder fallback: missing state_vector → vector_source=encoder
  - encoder fallback: wrong-length state_vector → vector_source=encoder
  - GameSession field name aliases (turnNumber, activePlayer,
    commanderTax, handCount, commandZone) are accepted without 422
  - 503 when model not loaded
  - /api/policy/stats tracks precomputed_vector_hits vs encoder_fallbacks
  - both phase strings (MAIN1, main_1) round-trip correctly

Uses a MockPolicyInferenceService that captures the tensor shape passed
to the model so we can assert on dim without needing a real checkpoint.
2026-03-29 04:37:09 -04:00
KoalaTrapLord 4d73f134c2 feat(policy): consume state_vector from GameSession snapshots (#83 Step 2)
DecideRequest now accepts an optional state_vector field (float[29]).
When present, /api/policy/decide uses it directly for the global scalar
block instead of re-deriving it from nested player fields.

StateEncoder gets a new encode_with_precomputed_global() path that:
  - Accepts the precomputed float[29] global block from Java
  - Still runs full zone embedding pooling (hand/battlefield/graveyard/
    command_zone) from the players[] zone contents
  - Appends the playstyle one-hot as normal
  - Returns the same 6177-dim tensor the policy network expects

Fallback: if state_vector is absent or wrong length, encode() is called
as before so all existing callers (batch predict, JSONL training) are
unaffected.

Also maps GameSession player field names to encoder field names:
  handCount  -> hand size proxy when hand[] is empty (AI seats)
  battlefield list -> creatures count proxy
  commanderTax -> cmdr_tax
  turnNumber  -> turn
  activePlayer -> active_seat
2026-03-29 04:32:52 -04:00
KoalaTrapLord 126684bb94 feat(bridge): Phase 2.3 — wire GameSession to real Forge IPC subprocess (issue #83)
Replaces synthetic stub loop with a live Forge process:
- Spawns Forge with --policy + --policyServer flags so Forge calls
  back to /api/policy/decide at each AI decision point
- Reads Forge stdout line-by-line and parses zone/phase/life updates
  via the same regex patterns used in BatchRunner
- Syncs full board state (battlefield, hand, graveyard, exile,
  command zone, stack) on every Forge state-change line
- Routes human input from humanActionQueue via a side-channel
  HTTP POST to /api/policy/override on the policy server
- Handles activePlayer + priorityPlayer correctly for 3-4 player pods
- Falls back to synthetic stub loop if Forge subprocess fails to start
- Watchdog thread kills stalled Forge process after 10 min
2026-03-29 04:31:49 -04:00
KoalaTrapLord 882e2370c1 fix(#127): persist built_at in ChromaDB collection metadata as sidecar fallback
- _write_build_meta: after writing build_meta.json, also stamps built_at
  onto the collection via col.modify() (best-effort, swallows exceptions)
- _collection_is_current: if sidecar built_at is missing/zero, falls back
  to collection.metadata["built_at"] so a deleted sidecar doesn't force
  an unnecessary full rebuild when the ChromaDB data is still valid
2026-03-29 04:29:35 -04:00
KoalaTrapLord bbca0b7985 fix(#126): add batch insert retry logic with exponential backoff
- Add BATCH_RETRY_LIMIT and BATCH_RETRY_DELAY constants after DEFAULT_BATCH_SIZE
- Add _insert_batch_with_retry() helper with exponential backoff
- Replace both bare try/except insert blocks in _build_collection_from_bulk
  with calls to the retry helper so transient ChromaDB errors are retried
  up to BATCH_RETRY_LIMIT times before a batch is counted as failed
2026-03-29 04:24:58 -04:00
KoalaTrapLord da8de77704 feat(bridge): add buildStateVector() emitting state_vector float[] in snapshots (#83 Step 1)
Adds a buildStateVector(int seat) method to GameSession that produces
a float[29] global scalar vector matching STATE_DIMS layout in
ml/config/scope.py:

  Per-player block (14 features × 2 players = 28):
    [0]  life_total           / 40.0
    [1]  commander_damage     / 21.0  (stub 0.0 until real Forge tracks it)
    [2]  mana_available       / 20.0  (stub 0.0 until mana pool wired)
    [3]  commander_tax        / 10.0
    [4]  cards_in_hand        / 15.0
    [5]  cards_in_graveyard   / 100.0
    [6]  creatures_on_battlefield / 30.0
    [7]  total_power_on_board / 100.0  (stub = creature count until P/T data)
    [8]  is_active_player     (0 or 1)
    [9]  phase one-hot[0]  main1
    [10] phase one-hot[1]  combat
    [11] phase one-hot[2]  main2
    [12] phase one-hot[3]  end
    [13] (padding / reserved)

  Global:
    [28] turn_number / max_turns (25)

buildStateSnapshot() now includes "state_vector": float[] so that
PolicyClient.decide(JsonObject) sends a machine-readable vector the
Python /api/policy/decide endpoint can pass directly to the encoder
without re-parsing the nested human-readable fields.

Stub fields (mana_available, commander_damage, total_power_on_board)
are zero until real Forge board state is wired in Step 4.
2026-03-29 04:24:55 -04:00
KoalaTrapLord 11990c2fd3 feat(ml): add --data-source forge flag to PPO trainer (issue #83 phase 1.3)
Wires forge_episode_generator into the PPO training loop:
- New --data-source flag: synthetic (default) | forge | mixed
- New --forge-results-dir, --forge-batch-id, --forge-weight flags
- ForgeDataSource class: runs run_forge_pipeline() in background thread,
  drains episodes into RolloutBuffer via episodes_to_buffer()
- Mixed mode: blends Forge episodes + synthetic self-play per iteration
  using --forge-weight ratio (0.0–1.0)
- Synthetic path unchanged — no regression for existing training runs
2026-03-29 03:02:30 -04:00
KoalaTrapLord aefd0cace6 feat(rag): Phase 1 — rewrite build_card_text to natural-language format (#137)
* feat(rag): Phase 1 — rewrite build_card_text to natural-language format

* feat(rag): Phase 2 — Forge-enriched ChromaDB RAG store (2a–2e)

Phase 2a: Forge lazy import guard (_try_import_forge_lookup)
Phase 2b: _build_document appends Forge trigger/replacement/static/keyword tags
Phase 2c: _build_metadata adds has_trigger, has_replacement, has_static, trigger_modes fields
Phase 2d: query_cards extended with require_trigger, require_replacement, require_static params + ChromaDB where clause support
Phase 2e: get_rag_stats reports forge_enriched and forge_coverage; _write_build_meta tracks coverage; build loop counts enriched cards

* feat(rag): Phase 3 — add mtgrules second ChromaDB collection

Adds build_rules_index() and query_rules() operating on a separate
'mtgrules' ChromaDB collection. Chunks MagicCompRules.txt by rule-number
boundaries, embeds each chunk, and exposes semantic search. Completely
independent from the mtg_oracle card collection.

* feat(coach): Phase 4 — behavior-aware RAG + rules context in coach_service

Phase 4a: run_coaching_session uses dual RAG query
  - Primary: commander synergy (n=10)
  - Secondary: require_replacement=True for graveyard/protection cards (n=5)
  - Deduplicates by name before merging
Phase 4b: query_rules() fetches 3 Commander rules chunks to ground the prompt
  - Passed as rules_context kwarg to build_user_prompt()

* feat(rag): Phase 5 — add /api/rag/build-rules and /api/rag/query-rules endpoints
2026-03-29 02:11:38 -04:00
KoalaTrapLord ac536999eb Update lab_api.pyfeat: wire validate_forge_config into lifespan startup 2026-03-28 14:48:43 -04:00
KoalaTrapLord 2e43511d87 feat: add validate_forge_config() startup check
Validates FORGE_DIR at startup with clear warning if cardsfolder not found.
Checks both forge-gui/res/cardsfolder and res/cardsfolder layouts.
2026-03-28 14:34:50 -04:00
KoalaTrapLord 1bebe38e66 fix: forge_card_loader path resolution + reset_forge_cache helper
- _get_cards_folder() now tries both forge-gui/res/cardsfolder and res/cardsfolder
  so FORGE_DIR can point to either the repo root or the forge-gui subdirectory
- Add reset_forge_cache() to allow hot-reloading without server restart
2026-03-28 14:31:42 -04:00
KoalaTrapLord eca1653024 fix: backfill basic lands on card_entries to guarantee 99 cards
The previous backfill operated on seen_names dict which silently
ignored basics already present (dict keys are unique). Now backfills
directly on card_entries list after Scryfall fetch, incrementing
quantity on existing basics or appending new CardEntry objects.
Distributes shortfall evenly across commander color identity basics.
2026-03-28 00:26:42 -04:00
KoalaTrapLord 08f1cb7581 fix(deckgen): backfill basic lands after cross-category dedup to reach 99 cards 2026-03-28 00:10:52 -04:00
KoalaTrapLord 9d9e8675a9 fix: restore missing # on line 27 section banner in rules.py (SyntaxError on import) 2026-03-27 23:36:02 -04:00
KoalaTrapLord 64526b7bee fix: exclude lands from cmc_buckets in _build_deck_report_from_db (matches deck_service behaviour) 2026-03-27 20:38:49 -04:00
KoalaTrapLord e9732d292f fix: expand _land_kw, hoist to module level, guard has_real_data for cmc=0 lands after Forge lookup 2026-03-27 20:36:22 -04:00
KoalaTrapLord fa5349ea7c fix: basic land name fallback in _build_deck_report_from_db when type_line is missing from collection join 2026-03-27 20:32:15 -04:00
KoalaTrapLord 1c491bc2b0 fix: add Land role detection to _detect_card_roles (fixes basics showing as Unknown) 2026-03-27 20:28:35 -04:00
KoalaTrapLord 81fe5108d2 fix: coach_apply rollback on partial write, 4-space reindent; ws_game hoist inline imports 2026-03-27 20:24:55 -04:00
KoalaTrapLord 61fa37dc48 fix: lifespan indent error, get_running_loop, remove double rag init, register policy routes 2026-03-27 20:22:42 -04:00
KoalaTrapLord 0118950939 fix: add anthropic_api_key to Config with env-var default (fixes AttributeError in ASGI mode) 2026-03-27 20:20:45 -04:00
KoalaTrapLord 22cf23b24d fix: correct query_cards kwarg names in coach_service (n= -> n_results=, colors= -> color_identity=) 2026-03-27 20:17:14 -04:00
KoalaTrapLord 0010b0f394 refactor: RAG improvements — shared card_text, Chroma fallback, staleness monitor (fixes #133, #134, #135) (#136)
* refactor: add shared build_card_text() utility

* refactor: unify _build_document via shared card_text + add is_chroma_available/check_staleness (fixes #133, #134, #135)

* refactor: delegate embeddings._build_card_text to shared utility

* feat: add background RAG staleness monitor to lab_api lifespan (fixes #135)
2026-03-27 16:46:33 -04:00
KoalaTrapLord d0cdafa66a feat(rag): Phase 3 — inject RAG cards into coach prompts (#132)
* feat(rag): add RAG card injection to prompt_template.py

* feat(rag): wire RAG query into coach_service.py

* test(rag): add Phase 3 RAG injection tests
2026-03-27 15:20:49 -04:00
KoalaTrapLord bcf558418a fix(rag): squash all RAG/ChromaDB bugs #123–#130
Fixes critical API contract mismatch, batch_size wiring, lifespan init, failure tracking, build_meta persistence, mana_cost in embeddings, pyproject.toml deps, and adds full test suite.

Closes #123, #124, #125, #126, #127, #128, #129, #130
2026-03-27 13:24:41 -04:00
KoalaTrapLord 61ac349445 fix: re-encode requirements.txt as UTF-8 (fixes pip install failure) 2026-03-27 11:14:34 -04:00
KoalaTrapLord 06ce5cc91a fix(rules): add nonbasic land guard before creature fallback (fixes #118) (#122) 2026-03-27 10:22:05 -04:00
KoalaTrapLord fc2e9d88d5 feat(rag): wire ensure_rag_store into main() startup (#121) 2026-03-27 09:13:03 -04:00
KoalaTrapLord 4b0cd34eda feat(rag): Phase 2 — ChromaDB vector index over bulk card DB (#120)
* feat(rag): Phase 2 — ChromaDB vector index over bulk card DB

* feat(rag): add routes/rag.py with build, search, status endpoints

* Update lab_api.pyfeat(rag): register rag_router in lab_api.py, remove old inline rag_status

* Update requirements.txtfeat(rag): add chromadb>=0.5.0 to requirements.txtfeat(rag): add chromadb to requirements.txt
2026-03-27 08:39:02 -04:00
KoalaTrapLord 83502ce726 feat: Phase 3/5 deep analysis + Quick Digest endpoint (issue #108) (#119)
* feat(coach): Phase 3/5 JSON schema + QUICK_DIGEST prompt (issue #108)

- Append removalCoverage, rampQuality, drawEngineProfile, winConditions,
  antiSynergyFlags, podPresence, tempoAssessment, metaMatchups to schema
- Add 8 new RULES for Phase 3/5 analysis
- Add QUICK_DIGEST_SYSTEM_PROMPT constant
- Add build_quick_system_prompt() function

* feat(coach): Phase 3/5 JSON parsing + run_quick_digest() (issue #108)

* feat(routes): add POST /api/coach/decks/{deck_id}/quick endpoint (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

- Import RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition,
  AntiSynergyFlag, PodPresence, TempoAssessment, MetaMatchup
- Add Phase 3/5 blocks to _populate_session_from_json()
- Add run_quick_digest() method

* feat(ui): Top Priority card + Quick/Deep buttons in coach.html (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(ui): wire Quick Digest button + displayTopPriority() (issue #108)
2026-03-27 07:51:01 -04:00
KoalaTrapLord 8df5900ff9 feat(rag): Phase 1 — Scryfall bulk offline card database (SQLite FTS5) (#117)
* feat(rag): add scryfall_bulk.py — offline SQLite FTS5 card database (RAG Phase 1)

* feat(rag): wire ensure_bulk_db() into startup and add /api/rag/status endpoint (RAG Phase 1)

* fix: migrate on_event to lifespan handler, fix /api/rag/status route ordering
2026-03-27 07:16:55 -04:00
KoalaTrapLord 864cefb8d1 feat(coach): Phase 5 - add PodPresence, TempoAssessment, MetaMatchup models + isQuickDigest flag (#108) 2026-03-27 04:22:23 -04:00
KoalaTrapLord 6c03e56794 fix(#116): dual card_records fallback JOIN, card_type/card_category fields, simulator land detection
- deck_service._compute_deck_analysis: add LEFT JOIN card_records with COALESCE on all fields
- deck_service: add _classify_card_category() with oracle-text keyword matching
- deckbuilder.GET /api/decks/{id}/cards: add card_records fallback JOIN + COALESCE, emit card_type/card_category per card
- deepseek._fetch_deck_card_data: add card_records fallback JOIN so lands get correct type_line before enrich_card()

Closes #116
2026-03-27 04:10:17 -04:00
KoalaTrapLord 4b92a5f364 Update models.pyfeat(coach): Phase 3 - add RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition, AntiSynergyFlag models (#108) 2026-03-27 04:01:25 -04:00
KoalaTrapLord da0193eb11 Update coach_service.pyfeat(coach): Phase 2 - parse upgradePriority, commanderDependency, mulliganAnalysis in coach_service (#108)feat(coach): Phase 2 - parse new fields in coach_service (#108) 2026-03-27 03:45:32 -04:00
KoalaTrapLord 71e0db4985 feat(coach): Phase 2 - add upgradePriority, commanderDependency, mulliganAnalysis to prompt schema (#108) 2026-03-27 03:32:03 -04:00
KoalaTrapLord 825946ae75 fix(deckgen): Phase 1 - non-destructive smart substitution + 99-card guarantee (#111)
- Add SubstitutionMethod enum to track how each card was resolved
- smart_substitute() now always returns a valid card string (never None/drops)
  fallback chain: exact -> fuzzy -> functional analog -> original kept -> basic land
- apply_substitutions() loop no longer silently drops cards on failure
- _pad_to_99() circuit breaker added before BuildResult validation
- Pre-validation count logging per category for fast regression diagnosis
- Startup assertion: sum(SLOT_BUDGET) == 99 raises on app boot if misconfigured
- Prompt injection: slot budget + exact card count requirement sent to LLM
- run_substitution() anthropic fallback failure now pads with basic land instead of leaving slot missing
- SubstitutionBatchResult gains padded_slots field (> 0 = circuit breaker fired)
2026-03-27 03:27:52 -04:00
KoalaTrapLord bd4df0c263 Update models.pyfeat(coach): Phase 2 - add UpgradePriority, CommanderDependency, MulliganAnalysis models (#108) 2026-03-27 02:52:01 -04:00
KoalaTrapLord 113b5c63f0 docs: rewrite README to reflect full current feature set (#115) 2026-03-27 02:51:12 -04:00
364 changed files with 90698 additions and 3726 deletions

No files matched your search

+22 -31
View File
@@ -1,36 +1,27 @@
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# Commander AI Lab — Environment Variables
# Copy this file to .env and fill in your values.
# start-lab.bat will auto-load .env if it exists.
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
# Get yours at: https://console.anthropic.com/settings/keys
# ── API Keys ──────────────────────────────────────────────────────
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Anthropic model for deck generation and coaching (Claude Opus recommended)
ANTHROPIC_MODEL=claude-opus-4-20250514
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
COACH_PROVIDER=anthropic
XIMILAR_API_KEY=your-ximilar-key-here
# 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
# ── Forge JAR Path ────────────────────────────────────────────────
# Point this to your Forge GUI desktop fat JAR.
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
# ── 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
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
# The JAR looks for res/ relative to its working directory. Without this, you get
# ExceptionInInitializerError on startup.
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
# 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
# ── Deck Paths ────────────────────────────────────────────────────
# Directory containing your precon .dck files.
# Forge reads deck files directly from this folder.
#
# IMPORTANT: lab_api.py reads PRECON_DIR (not PRECON_DECKS_DIR).
# PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server.
PRECON_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
+15
View File
@@ -32,9 +32,24 @@ embeddings/
checkpoints/
imported-decks/
# User-created decks (generated at runtime, not committed)
user-decks/*.dck
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Runtime artifacts / caches
static/
api/
image_cache/
data/rag_chroma/
.claude/
dependency-reduced-pom.xml
# Precon decks (generated / imported at runtime)
precon-decks/
+93
View File
@@ -0,0 +1,93 @@
# FarmHouse changes
This branch (`farmhouse`) is `KoalaTrapLord/commander-ai-lab` at commit `54cc5e9` plus the changes below. They were made to run the app
**Python-only** (no Forge/Java, no PyTorch) in a Kubernetes namespace, with a local LLM behind an OpenAI-compatible gateway instead of
Anthropic/DeepSeek, and they were each found by actually running the app, not by reading it. Written 2026-10-26.
Every change started life as an anchored text patch (`patch_app.py` in the farmhouse-os repo, which still builds the deployed image from
the upstream commit); here they are ordinary source edits. Lines carry a `# FARMHOUSE_...` marker where the patch left one.
## What was wrong, and what changed
### The simulator played nothing (the big one)
| Problem | Change |
|---|---|
| Every card reached the simulator with **no type line and no rules text**. The loaders join the user's *collection* and a *card_records* table (both empty on a fresh install) and the Forge card cache that was meant to fill the gap is not installed. A land was indistinguishable from a creature: an 8-game batch cast 0 spells, dealt 0 damage and lost every game. | `services/farmhouse_sim.py` `fill_card_data` fills type, text, mana cost, cmc, P/T, keywords and colour identity from the 113k-card Scryfall bulk database. Wired into `services/forge_runner.py` (batch) and `routes/deepseek.py` (`_fetch_deck_card_data`, which the Monte Carlo and single-deck routes use). |
| The batch loader **did not repeat cards by quantity**: a 100-card deck loaded as 66-84 cards with *one* Island. | `expand_quantities` (same module). |
| The LLM engine asked the model **once per turn** and applied the answer as one action. | `src/commander_ai_lab/sim/farmhouse_turns.py`: up to `BRAIN_STEPS` (default 4) questions per turn (land, spell, spell, attack), re-reading the board after each; stops on attack/hold/no change. `BRAIN_STEPS=1` restores the original. |
| A model that "thinks" spent ~46 s on a one-line move. | `deepseek_brain.py` sends `reasoning_effort` from `LLM_REASONING` (46 s -> 1.8 s per move). |
| The engine dropdown defaulted to **Forge**, which cannot run without Java. | `ui/ai-lab.js`: only "LLM opponents" is offered. |
After the fixes: a 40-game Monte Carlo takes 0.4 s with real numbers (10 spells and 12.7 damage per game); two LLM-piloted games took
212 s with 49 model calls and no fallbacks.
### Deck import
| Problem | Change |
|---|---|
| **Moxfield links were accepted nowhere**, and the Deck Builder import took only pasted text. | `services/farmhouse_deck_links.py`: Moxfield (public decks, `api2.moxfield.com`) and Archidekt links in the simulator URL import and in both Deck Builder imports (a lone link in the paste box). A private deck says to use Moxfield's Export > Copy as text. |
| The Deck Builder import looked **every card up on scryfall.com**; after a few dozen it was rate-limited (HTTP 429) and the app reported the rest as "not found" (250-card deck: 55 matched; even plain `Sol Ring` failed). | `local_card()`: names resolve from the local bulk database first; the website is only the fallback. |
### Deck generation
- `deck_builder/pipeline/farmhouse_fit.py`: the model's list is **filtered to the commander's colour identity** (from the bulk DB), de-duplicated,
fitted to per-category targets and trimmed to 99 + commander. Before this the model returned off-colour cards and 359-card lists.
- Scryfall bulk format changed (`jsonl_download_uri`, gzip JSONL; no `download_uri`).
- Scryfall answers **HTTP 400 to the default `python-requests` User-Agent** (every lookup became "Commander not found"): a named UA is sent everywhere.
- Scryfall 429s: requests back off on `Retry-After` and the pace is 0.25 s.
- `LLM_REASONING` / `LLM_MAX_TOKENS` are sent by the deck builder and coach.
### Coach / startup
- The coach was only initialised in `main()`, not when the app starts under uvicorn; `check_connection_sync()` was called but never defined;
the coach used the first model the gateway listed rather than the configured one. All fixed.
- Status label says "Language model" instead of the hardcoded "Claude Opus".
- `RAG_AUTOBUILD` (default off in our deployment) controls whether the card-search index is built at startup (113k embeddings; about an hour).
### Decision model (Laya / Kev), wired and off
`src/commander_ai_lab/sim/farmhouse_decide.py` asks a `/v1/systemone` server which of the nine actions to take, in one forward pass.
`DECISION_MODE=shadow` records whether it agrees with the LLM (the game still uses the LLM); `assist` uses it when `DECISION_MIN_CONF`
sure; `off` (default) does nothing. `scripts/farmhouse_decision_eval.py` replays recorded decisions through it.
**Measured 2026-10-26 on 149 recorded LLM decisions: zero-shot, both Laya checkpoints agreed with the LLM less often than always
guessing the commonest action** (English 16.1%, typed-decisions 18.6%, baseline 27.5%), at 2-4 s per answer on a 4-thread CPU. It is only
worth using after fine-tuning on this game's decisions; the write-up is `LAYA-EVALUATION.md` in the farmhouse-os repo's commander-lab folder.
### Laya connections (all off by default)
Forge's concrete priority path, extra typed questions in the same call, a capture log, a fine-tuning dataset exporter, and card-role classification:
`src/commander_ai_lab/sim/farmhouse_laya.py`, `scripts/farmhouse_laya_dataset.py`, `scripts/farmhouse_laya_roles_eval.py`, tests `tests/farmhouse/test_farmhouse_laya*.py`.
How it fits the strategic-intelligence work, what is measured and how to fine-tune: [docs/LAYA-CONNECTIONS.md](docs/LAYA-CONNECTIONS.md). Reference copies of the Laya
image, manifests and CI are in `farmhouse/laya/`.
### Forge, and rules-accurate simulation with phase-rs
- **Forge is installed** in the cluster image (it was left out by mistake): `farmhouse/forge/README.md`. Batches are clamped to one game at a time (`FORGE_MAX_THREADS`).
- **Rules-accurate simulation** through the phase-rs engine (Rust, headless): `routes/farmhouse_phase.py`, `ui/phase-sim.html` (linked from the home page) and the service in
`farmhouse/phase/`. Four saved decks play real Commander games; the page shows wins by seat with a 95% interval and warns about cards the engine did not recognise.
Seats can be put on a **model** (twin-turbo through Olla, or Laya): the model chooses among the engine's own legal options only, anything wrong or slow is played by phase's AI,
and a call cap bounds the cost (`farmhouse/phase/README.md`). Assessment and plan: `docs/PHASE-AND-PLAYTESTER.md`.
## Settings (environment variables)
| Variable | Meaning |
|---|---|
| `LLM_URL`, `LLM_MODEL` | OpenAI-compatible endpoint (`.../v1`) and model for the coach and deck builder |
| `LLM_REASONING` (`none`), `LLM_MAX_TOKENS`, `LLM_TIMEOUT` | keep requests lean: reasoning off, cap on answer length |
| `COACH_PROVIDER`, `DECK_GEN_PROVIDER` = `local`, `DECK_GEN_MODEL` | use the local model, not Anthropic |
| `OLLAMA_BASE_URL`, `RAG_EMBEDDING_MODEL`, `RAG_AUTOBUILD` | card-search embeddings (one host only: vectors differ between hosts) |
| `BRAIN_API_BASE` (**no** `/v1`; the client appends it), `BRAIN_MODEL`, `BRAIN_MAX_TOKENS`, `BRAIN_STEPS` | the simulator's LLM opponents |
| `DECISION_URL`, `DECISION_MODEL`, `DECISION_MODE` (`off`/`shadow`/`assist`), `DECISION_MIN_CONF`, `DECISION_TIMEOUT` | the decision model above |
| `ollama` Python package | required by the card-search index build (ChromaDB's Ollama embedder imports it) |
## Tests
`tests/farmhouse/` (standard library only, no network): `python -I -X utf8 tests/farmhouse/test_farmhouse_deck_links.py`, `..._sim.py`, `..._fit.py`.
Run them with `-I` and from outside the package folders; do not put `services/` on `sys.path` (`services/logging.py` shadows the standard library).
## Things still worth doing
- **Make the LLM-opponent turns faster** (a piloted move averages ~7.5 s: about 2.2k tokens in, ~30 out, so reading the prompt dominates).
These are ideas, not measured; none is implemented:
- *Shared prefix:* about 1.2k tokens of every prompt (the system prompt) are identical each time; a llama.cpp server can reuse them
if the calls share the prefix, so only the board state is read each move.
- *Smaller prompt:* send a compact board state instead of the indented JSON the engine builds now (`json.dumps(snapshot, indent=2)`
in `deepseek_brain.py`).
- *Parallel games:* run several games at once, which would also fill the GPU while one game waits on a move. Caveat: for long prompts
two requests at once gave no extra throughput on a V100 (reading the prompt is compute-bound), so measure before relying on it.
- Opponents are the engine's heuristics; only the piloted seat uses the model.
- Build the card-search index and measure it.
- The upstream `LICENSE` is missing (`pyproject.toml` says MIT). Ask the author to add one before publishing a derivative widely.
- Forge-based features (the Java simulator, training) remain unavailable by design.
+344 -90
View File
@@ -1,67 +1,212 @@
# Commander AI Lab
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
A full-stack Magic: The Gathering Commander research platform. Run thousands of simulated games, train neural networks on the outcomes, build decks with AI that learns from simulation data, get LLM-powered coaching, and play live 4-player battles — all from a single unified system.
## What It Does
---
| Layer | What it is |
|---|---|
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
## Overview
Commander AI Lab connects a rules-complete Forge engine, a fast Monte Carlo simulator, an LLM deck builder, an ML training pipeline, and multiple game clients (web, LAN, Unity) into one closed-loop platform. Simulation results feed back into deck building and AI decision-making, so the system improves the more it plays.
### Key Capabilities
- **Batch Simulation** — Run hundreds or thousands of Commander games unattended using Forge (rules-complete) or a headless Python Monte Carlo engine
- **ML Training Pipeline** — Reinforcement learning (PPO) trains a neural net on game decision logs; outcome-weighted scoring nudges card weights automatically
- **AI Deck Builder** — 7-step LLM pipeline (EDHrec → Scryfall → Ollama) with simulation-learned insights injected into prompts
- **LLM Coach** — Streaming coaching sessions powered by Anthropic Claude or local Ollama models, enriched with RAG-retrieved card knowledge
- **RAG Card Knowledge** — ChromaDB vector store over 30,000+ Scryfall cards with semantic search and auto-staleness monitoring
- **LAN Battlefield** — Browser-based 4-player Commander game client with ML-powered AI opponents and precon deck support
- **Unity Client** — Full Unity application with battleground scene, lobby system, WebSocket game state sync, and cross-platform builds
- **Card Scanner** — OCR pipeline for physical card recognition via Ximilar API
- **163 Precon Decks** — Complete preconstructed deck library in `.dck` format
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Web UI (React/TypeScript — frontend/) │
│ Deck Builder · Sim Dashboard · Training · Coach │
└────────────────────────┬────────────────────────────────────────┘
│ HTTP/REST + WebSocket
┌────────────────────────┴────────────────────────────────────────┐
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
└────────┬──────────────────┬──────────────────┬──────────────────┘
│ │ │
Java subprocess Python sim engine Ollama (local LLM)
(Forge rules) (Monte Carlo) qwen2.5:7b + others
│ │
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
│ Forge Engine │ │ src/commander_ai_lab/ │
│ (rules- │ │ sim/ — engine, models, rules │
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
└────────────────┘ │ coach/ — streaming LLM coach │
└────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Web UI │ │ LAN Client │ │ Unity Client │ │
│ │ (React/TS) │ │ (HTML/JS) │ │ (C# / URP) │ │
│ │ /ui │ │ /lan │ │ /unity-client │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────────────────┘ │
│ └────────────────┼────────────────┘ │
│ │ HTTP/REST + WebSocket │
│ ┌───────────────────────┴─────────────────────────────────────────┐│
│ │ FastAPI Backend (lab_api.py — port 8080) ││
│ │ ││
│ │ /api/lab/* Batch simulation (Forge + Monte Carlo) ││
│ │ /api/deck-builder/* 7-step LLM deck construction ││
│ │ /api/coach/* Streaming LLM coaching sessions ││
│ │ /api/ml/* Neural net training + policy server ││
│ │ /api/rag/* ChromaDB vector search ││
│ │ /api/game/* Live game sessions (Forge IPC) ││
│ │ /api/policy/* ML policy decisions for AI players ││
│ │ /ws/game/{id} WebSocket game state streaming ││
│ └───────┬──────────────┬──────────────┬──────────────┬────────────┘│
│ │ │ │ │ │
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐ ┌────┴──────┐ │
│ │ Forge │ │ Monte │ │ Ollama │ │ ChromaDB │ │
│ │ Engine │ │ Carlo │ │ (local LLM) │ │ (vector │ │
│ │ (Java/JVM) │ │ Engine │ │ qwen2.5:7b │ │ store) │ │
│ └─────────────┘ │ (Python) │ └─────────────┘ └───────────┘ │
│ └──────────┘ │
│ ┌──────────────────────────────────────────────────────────────────┐│
│ │ ML Pipeline ││
│ │ decisions_*.jsonl → update_weights.py → learned_weights.json ││
│ │ → /api/ml/train → neural net checkpoints ││
│ └──────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
```
## Quick Start (Windows)
---
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
## Features
### Simulation Engines
| Engine | Description | Speed |
|---|---|---|
| **Forge** | Rules-complete Java engine — handles the stack, instants, triggered abilities, and all Commander rules. Each game spawns a JVM subprocess. | ~0.04 sims/sec (single-threaded) |
| **Monte Carlo** | Headless Python engine — models combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped state, commander zone, multiplayer targeting, and board wipes. No JVM required. | Thousands of games in minutes |
Both engines feed structured results into the same ML pipeline.
### Deck Builder
The `POST /api/deck-builder/build` endpoint runs a 7-step pipeline:
1. Resolve commander via Scryfall
2. Fetch EDHrec recommendations
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
4. Ollama suggests synergy/protection/wincon cards — **simulation insights injected here**
5. Filter by color identity, collection, and ban list
6. Ollama enforces ratios (trim/expand to 99) — **simulation insights injected here**
7. Assemble final deck with smart substitution (guarantees 99-card output)
### AI Coach
LLM-powered coaching with two modes:
- **Full Session** — Deep analysis including removal coverage, ramp quality, draw engine profiling, win conditions, anti-synergy detection, pod meta evaluation, and tempo assessment
- **Quick Digest** — Fast ~5-second summary for rapid feedback
Coaching prompts are enriched with semantically relevant cards from the ChromaDB RAG store (15 cards for full sessions, 8 for quick digests). Supports both Anthropic Claude and local Ollama models with streaming responses.
### RAG Card Knowledge
- **SQLite FTS5** bulk card database from Scryfall data
- **ChromaDB** vector index with natural-language card embeddings
- Automatic staleness monitoring (checks every 6 hours, auto-rebuilds if >14 days old)
- Graceful fallback — RAG failures never block coaching or deck building
### ML Training Pipeline
```
Overnight sims → decisions_*.jsonl
↓
update_weights.py → learned_weights.json (feeds deck builder)
↓
/api/ml/train → neural net training on decision dataset
↓
/api/policy/decide → ML policy serves AI decisions in live games
```
- **Outcome-weighted RL** nudges card scoring weights from game results
- **Neural net** trains on the full decision log dataset
- **Distillation loop** — self-play → collect data → retrain → repeat
- **ELO tracking** and model tournaments for comparing AI versions
- Weights clamped to `[-20.0, 20.0]`, stored in `learned_weights.json`
### Win Condition Tracking
Games are classified by win type — not just "Combat" vs "Other":
- **Combat** — standard creature damage wins
- **Combo** — Approach of the Second Sun, Felidar Sovereign, Happily Ever After, and 30+ known alternate-win cards
- **Drain** — life drain effects
- **Mill** — library depletion
- **Poison** — infect/poison counter wins
Draw games (timeout) are analyzed separately with per-player life-at-timeout snapshots and likely cause identification.
### LAN Battlefield Client
Browser-based 4-player Commander game at `/lan`:
- **AI Bridge** — REST + WebSocket connectivity with ML policy decisions as Priority 1 (falls back to local heuristic AI)
- **Deck System** — 3-tab selector: Precons (163 decks, grouped by set), EDHREC search, and custom deck import
- **Player Panels** — Life counters, commander damage bars, poison/energy/experience/rad counters, emblem management
- **Backend Detection** — Auto-detects the Commander AI Lab backend; works offline with local precon data
### Unity Client
Full Unity application (URP, .NET Standard 2.1) with:
- **Battleground Scene** — 4-seat layout with rotated perspectives, life/poison/commander damage widgets, phase tracker, turn order bar
- **Card System** — Hand, battlefield, graveyard, exile zones with card art, tapped rotation, face-down state, summoning sickness indicators
- **Stack & Mana** — LIFO stack display and 6-pip mana pool
- **Lobby System** — Configure 4 seats (human/AI), POST to backend, WebSocket game state sync
- **Elimination** — Grayscale shader overlay on eliminated players
- **Token System** — Create, tap, add counters, delete tokens
- **Cross-Platform** — Windows, WebGL, Android, iOS build targets
- **Scenes** — MainMenu, Collection, DeckBuilder, DeckGenerator, Simulator, Coach, Scanner, Training, Precon, Battleground
### Card Scanner
OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from photos and match them to the Scryfall database.
---
## Quick Start
### Prerequisites
| Tool | Version |
|---|---|
| Python | 3.10+ |
| Java | 17+ |
| Maven | 3.8+ |
| Node.js | 18+ (for LAN client scripts) |
| Ollama | Latest (for local LLM inference) |
| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` |
### Setup (Windows)
```powershell
# 1. Build Forge (one-time)
cd D:\ForgeCommander\forge-repo
cd D:\ForgeCommander\Forge-Repo
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
# 2. Build Commander AI Lab
cd D:\ForgeCommander\commander-ai-lab
mvn package "-DskipTests"
# 3. Install Python deps
pip install -e .
# 3. Install Python dependencies
pip install -e ".[rag,dev]"
# 4. Launch
# 4. Pull an Ollama model for the deck builder
ollama pull qwen2.5:7b
# 5. Launch
.\start-lab.bat
# → open http://localhost:8080
# → Web UI: http://localhost:8080
# → LAN Client: http://localhost:8080/lan/
```
Or use the automated installer:
```powershell
.\install-commander-ai-lab.ps1
```
---
## Overnight AFK Runner
Runs batch sims unattended, then updates card weights and trains the neural net:
Run batch simulations unattended, then automatically update card weights and train the neural net:
```powershell
python overnight-run.py # default: 11.5h DeepSeek sims
@@ -74,9 +219,9 @@ python overnight-run.py --no-train # skip neural net training
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
2. `/api/ml/train` — trains the neural net on the full decision dataset
## Sim → Deck Builder Feedback Loop
---
Simulation outcomes automatically improve deck recommendations:
## Simulation → Deck Builder Feedback Loop
```
Overnight sims → decisions_*.jsonl
@@ -90,24 +235,63 @@ Ollama prompts (suggest_cards + enforce_deck_ratios)
Better card recommendations
```
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
## Deck Builder Pipeline
---
The `POST /api/deck-builder/build` endpoint runs 7 steps:
## API Reference
1. Resolve commander via Scryfall
2. Fetch EDHrec recommendations
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
5. Filter by color identity, collection, ban list
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
7. Assemble final deck
### Simulation
## ML Pipeline
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
| `/api/lab/history` | GET | List past batch runs |
| `/api/lab/precons/deck` | GET | Fetch precon `.dck` file content |
### Deck Builder & Coach
| Endpoint | Method | Description |
|---|---|---|
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
| `/api/coach/chat` | POST | Full coaching session (streaming) |
| `/api/coach/quick-digest` | POST | Fast ~5s coaching summary |
### ML & Policy
| Endpoint | Method | Description |
|---|---|---|
| `/api/ml/train` | POST | Start neural net training |
| `/api/ml/train/status` | GET | Poll training progress |
| `/api/policy/decide` | POST | Get ML policy decision for game state |
### RAG
| Endpoint | Method | Description |
|---|---|---|
| `/api/rag/build` | POST | Build/rebuild ChromaDB index |
| `/api/rag/search` | POST | Semantic card search |
| `/api/rag/status` | GET | Index health and staleness |
### Live Game
| Endpoint | Method | Description |
|---|---|---|
| `/api/game/start` | POST | Create a live game session |
| `/api/game/action` | POST | Submit a player action |
| `/api/game/{id}/actions` | GET | Poll Forge IPC actions |
| `/ws/game/{game_id}` | WS | Real-time game state streaming |
---
## ML Scripts
```powershell
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
# Update card weights from decision logs
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
# Reset weights to defaults
@@ -117,7 +301,7 @@ python -m ml.scripts.update_weights --reset
python ml/scripts/generate_synthetic_data.py
```
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
---
## Python Simulator API
@@ -129,52 +313,122 @@ stats = run_simulation(deck, num_games=100, name_a="My Deck")
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
```
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
## API Reference
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
| `/api/lab/history` | GET | List past batch runs |
| `/api/ml/train` | POST | Start neural net training |
| `/api/ml/train/status` | GET | Poll training progress |
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
---
## Project Structure
```
commander-ai-lab/
├── lab_api.py # FastAPI server entry point
├── overnight-run.py # AFK batch runner
├── start-lab.bat # Windows one-click launcher
├── lab_api.py # FastAPI server entry point
├── frontend/ # React + TypeScript web UI
├── ui/ # Legacy static HTML UI
├── install-commander-ai-lab.ps1 # Automated setup script
├── pom.xml # Maven build (Java/Forge components)
├── pyproject.toml # Python package config
│
├── routes/ # FastAPI route modules
│ ├── lab.py # Batch simulation
│ ├── deckbuilder.py # Deck builder pipeline
│ ├── coach.py # LLM coaching
│ ├── ml.py # ML training
│ ├── rag.py # ChromaDB vector search
│ ├── game.py # Live game sessions
│ ├── policy.py # ML policy server
│ ├── precon.py # Precon deck serving
│ ├── scanner.py # Card OCR
│ └── ws_game.py # WebSocket game state
│
├── services/ # Core service layer
│ ├── forge_runner.py # Forge subprocess management
│ ├── draw_game_analyzer.py # Draw/timeout analysis
│ ├── rag_store.py # ChromaDB vector store
│ └── card_text.py # Shared card text builder
│
├── src/commander_ai_lab/
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
│ ├── deck_builder/ # LLM deck builder pipeline
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
│ │ ├── core/ # Models, rules engine, assembler
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
│ └── coach/ # LLM coaching (Anthropic + Ollama)
├── ml/
│ ├── scripts/ # update_weights.py, ml_cli.py
│ ├── training/ # Neural net trainer
│ ├── encoder/ # Feature encoding
│ └── models/ # Saved model checkpoints
├── scanner/ # Card OCR pipeline
├── logs/decisions/ # ML decision logs (gitignored)
└── src/main/java/commanderailab/ # Java/Forge batch runner
│ ├── sim/ # Monte Carlo engine
│ │ ├── engine.py # Game loop
│ │ ├── models.py # GameResult, Card, Player
│ │ ├── rules.py # Combat, keywords, commander rules
│ │ ├── win_condition_parser.py # Alternate win detection
│ │ └── learned_weights.json # ML-learned card weights
│ ├── deck_builder/ # 7-step LLM pipeline
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
│ │ ├── core/ # Models, rules engine, assembler
│ │ └── pipeline/ # build_deck.py orchestrator
│ └── coach/ # LLM coaching
│ ├── coach_service.py # Session + quick digest
│ ├── prompt_template.py # System/user prompts
│ └── embeddings.py # Card embedding utilities
│
├── ml/ # ML training pipeline
│ ├── scripts/ # update_weights.py, ml_cli.py
│ ├── training/ # Neural net trainer
│ ├── encoder/ # Feature encoding
│ └── models/ # Saved checkpoints
│
├── lan-client/ # Browser-based LAN battlefield
│ ├── index.html # Entry point
│ ├── engine.js # Game engine + ML AI turns
│ ├── ai-bridge.js # Backend connector + policy bridge
│ ├── player-panels.js # Enhanced player panels
│ ├── ui.js # 3-tab deck selector + setup
│ └── precons.js # 163 precon deck data
│
├── unity-client/ # Unity game client
│ └── Assets/
│ ├── Scenes/ # 11 scenes (MainMenu → Battleground)
│ ├── Scripts/ # C# controllers, models, services
│ ├── Prefabs/ # Card, deck slot, chat bubble
│ └── Materials/ # URP materials + grayscale shader
│
├── scanner/ # Card OCR pipeline (Ximilar)
├── precon-decks/ # 163 preconstructed decks (.dck)
├── sample-decks/ # Sample deck files
├── data/ # Runtime data (ChromaDB, bulk DB)
├── logs/ # Simulation + decision logs
├── docs/ # Architecture docs + schemas
├── tests/ # Test suite
├── scripts/ # Utility scripts
├── .github/workflows/ # CI + Unity build automation
└── src/main/java/ # Java/Forge batch runner
└── commanderailab/
```
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.10+, FastAPI, Uvicorn |
| Simulation (rules-complete) | Java 17, Maven, Forge |
| Simulation (fast) | Python Monte Carlo engine |
| LLM Inference | Ollama (qwen2.5:7b), Anthropic Claude |
| Vector Store | ChromaDB with all-MiniLM-L6-v2 embeddings |
| Card Database | SQLite FTS5 (Scryfall bulk data) |
| ML Training | PyTorch, PPO reinforcement learning |
| Web Client | HTML/CSS/JS |
| Unity Client | Unity 2022 LTS, URP, C# |
| CI/CD | GitHub Actions |
---
## Known Limitations
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
- **Forge winner life total** not captured (quiet mode only reports losers)
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
- **Monte Carlo engine** — no stack, no instants, no continuous type-changing effects (animated lands don't attack)
- **Forge engine** — each game spawns a new JVM (~2–3s overhead); ~0.04 sims/sec single-threaded
- **Forge** — winner life total not captured in quiet mode (only reports losers)
- **Deck builder** — Ollama suggestions depend on the local model quality; `qwen2.5:7b` works well
- **LAN client** — EDHREC commander search is a stub (backend-powered, not yet fully wired)
---
## Contributing
Commander AI Lab is an active research project. Check the [open issues](https://github.com/KoalaTrapLord/commander-ai-lab/issues) for areas where contributions are welcome.
---
## License
See repository for license details.
+257
View File
@@ -0,0 +1,257 @@
<#
.SYNOPSIS
Smoke-tests the Commander AI Lab policy server from PowerShell.
Checks: server reachable -> /api/policy/health -> /api/policy/decide.
Prints a plain-English pass/fail for every step.
.USAGE
# From repo root (server already running in another window):
.\Test-PolicyServer.ps1
# Custom host/port:
.\Test-PolicyServer.ps1 -BaseUrl http://localhost:9000
# Pretty-print the full decide response:
.\Test-PolicyServer.ps1 -Verbose
#>
param(
[string]$BaseUrl = "http://localhost:8080",
[switch]$Verbose
)
$ErrorActionPreference = "Stop"
# ---- helpers ----------------------------------------------------------------
function Write-Pass { param([string]$msg) Write-Host " [PASS] $msg" -ForegroundColor Green }
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
function Write-Info { param([string]$msg) Write-Host " [INFO] $msg" -ForegroundColor Cyan }
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
function Write-Step { param([string]$msg) Write-Host "`n-- $msg" -ForegroundColor White }
$allPassed = $true
function Invoke-Step {
param(
[string] $Name,
[scriptblock] $Block
)
Write-Step $Name
try { & $Block }
catch {
Write-Fail $_.Exception.Message
$script:allPassed = $false
}
}
# ---- Step 1: server reachable -----------------------------------------------
Invoke-Step "Step 1 -- Server reachable at $BaseUrl" {
$r1 = $null
try { $r1 = Invoke-WebRequest -Uri "$BaseUrl/docs" -UseBasicParsing -TimeoutSec 5 } catch {}
if ($r1 -and $r1.StatusCode -eq 200) {
Write-Pass "FastAPI server is up (GET /docs -> 200)"
return
}
$r2 = $null
try { $r2 = Invoke-WebRequest -Uri "$BaseUrl/" -UseBasicParsing -TimeoutSec 5 } catch {}
if ($r2 -and $r2.StatusCode -lt 500) {
Write-Pass "FastAPI server is up (GET / -> $($r2.StatusCode))"
return
}
throw "Cannot reach $BaseUrl -- is lab_api.py / uvicorn running?"
}
# ---- Step 2: /api/policy/health ---------------------------------------------
$modelLoaded = $false
Invoke-Step "Step 2 -- GET $BaseUrl/api/policy/health" {
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/policy/health" -Method Get -TimeoutSec 10
if ($Verbose) { $resp | ConvertTo-Json -Depth 5 | Write-Host }
if ($resp.ready -eq $true) {
Write-Pass "Model is loaded and ready (ready=true)"
$script:modelLoaded = $true
} elseif ($resp.status -eq "degraded") {
Write-Fail "Health returned status=degraded / ready=false"
Write-Warn " -> Policy model is NOT loaded."
Write-Warn " -> Start the server with --load-policy, or call POST /api/ml/load_model first."
Write-Warn " -> Confirm models/ contains a valid .pt checkpoint."
$script:allPassed = $false
} else {
Write-Warn "Unexpected health response -- dumping:"
$resp | ConvertTo-Json -Depth 5 | Write-Host
$script:allPassed = $false
}
}
# ---- Step 3: /api/policy/decide (synthetic snapshot) ------------------------
Invoke-Step "Step 3 -- POST $BaseUrl/api/policy/decide (synthetic 4-player snapshot)" {
if (-not $script:modelLoaded) {
Write-Warn "Skipping /decide -- model not loaded (Step 2 failed)."
Write-Warn "Fix Step 2 first, then re-run."
return
}
$body = [ordered]@{
game_id = "ps1-smoke-test"
turn = 3
phase = "MAIN1"
active_player = 0
playstyle = "midrange"
temperature = 1.0
greedy = $false
players = @(
[ordered]@{
seat = 0
name = "TestPlayer0"
life = 40
manaAvailable = 3
landCount = 3
creaturesOnField = 1
totalPowerOnBoard = 2
hand = @("Cultivate", "Solemn Simulacrum", "Sol Ring")
battlefield = @("Forest", "Forest", "Forest", "Birds of Paradise")
graveyard = @()
commandZone = @("Atraxa, Praetors Voice")
},
[ordered]@{
seat = 1
name = "TestPlayer1"
life = 38
manaAvailable = 2
landCount = 2
creaturesOnField = 0
totalPowerOnBoard = 0
handCount = 4
hand = @()
battlefield = @("Island", "Swamp")
graveyard = @()
commandZone = @("Ur-Dragon")
},
[ordered]@{
seat = 2
name = "TestPlayer2"
life = 35
manaAvailable = 4
landCount = 4
creaturesOnField = 2
totalPowerOnBoard = 5
handCount = 3
hand = @()
battlefield = @("Mountain", "Mountain", "Mountain", "Mountain", "Goblin Guide", "Monastery Swiftspear")
graveyard = @()
commandZone = @("Krenko, Mob Boss")
},
[ordered]@{
seat = 3
name = "TestPlayer3"
life = 40
manaAvailable = 0
landCount = 0
creaturesOnField = 0
totalPowerOnBoard = 0
handCount = 7
hand = @()
battlefield = @()
graveyard = @()
commandZone = @("Prossh, Skyraider of Kher")
}
)
} | ConvertTo-Json -Depth 10
$resp = Invoke-RestMethod `
-Uri "$BaseUrl/api/policy/decide" `
-Method Post `
-Body $body `
-ContentType "application/json" `
-TimeoutSec 30
if ($Verbose) {
Write-Host ""
Write-Host " Full /decide response:" -ForegroundColor White
$resp | ConvertTo-Json -Depth 6 | Write-Host
}
$ok = $true
if ([string]::IsNullOrEmpty($resp.action)) {
Write-Fail "response.action is empty"
$ok = $false
}
if ($null -eq $resp.confidence) {
Write-Fail "response.confidence is missing"
$ok = $false
}
if ($resp.PSObject.Properties["error"] -and -not [string]::IsNullOrEmpty($resp.error)) {
Write-Fail "Server returned error: $($resp.error)"
$ok = $false
}
if ($ok) {
$confPct = [math]::Round($resp.confidence * 100, 1)
Write-Pass "Decision received -> action='$($resp.action)' conf=$($confPct)% latency=$($resp.inference_ms)ms src=$($resp.vector_source)"
if ($resp.probabilities) {
$top3 = $resp.probabilities.PSObject.Properties |
Sort-Object { $_.Value } -Descending |
Select-Object -First 3
Write-Info "Top-3 action probabilities:"
foreach ($p in $top3) {
$pct = [math]::Round($p.Value * 100, 1)
Write-Info (" " + $p.Name.PadRight(22) + "$pct%")
}
}
} else {
$script:allPassed = $false
}
}
# ---- Step 4: log line guidance ----------------------------------------------
Invoke-Step "Step 4 -- Confirm [routes.policy.decide] log line in server console" {
Write-Info "After Step 3 you should see in the uvicorn window:"
Write-Info " [decide] turn=3 phase=main_1 -> cast_ramp (conf=0.810) | top5=[...] | src=encoder"
Write-Info ""
Write-Info "If that line is MISSING:"
Write-Info " 1. Confirm uvicorn started with --log-level info (the default)"
Write-Info " 2. Confirm lab_api.py calls register_policy_routes(app, policy_svc)"
Write-Info " 3. Confirm routes/policy.py is included via routes/__init__.py"
Write-Info " 4. Logger name is [routes.policy.decide] -- check no filter silences it"
Write-Pass "Guidance printed"
}
# ---- Step 5: why Forge ignores the policy server ----------------------------
Invoke-Step "Step 5 -- Forge IPC status (informational)" {
Write-Warn "Forge does NOT call /api/policy/decide on its own."
Write-Warn "routes/game.py POST /api/game/start has a TODO: wire Forge engine launch."
Write-Warn ""
Write-Warn "Until wired, /decide is only hit by:"
Write-Warn " - This script"
Write-Warn " - overnight-run.py (batch sim path)"
Write-Warn " - Manual Invoke-RestMethod / curl calls"
Write-Warn ""
Write-Warn "Forge launches its built-in AI and plays lands because it has"
Write-Warn "no HTTP client pointed at localhost:8080."
Write-Warn ""
Write-Warn "Next step: wire GameSession inside POST /api/game/start,"
Write-Warn "or add a PolicyClient hook in Forge AiController.java."
Write-Pass "Status explained"
}
# ---- Summary ----------------------------------------------------------------
Write-Host ""
if ($allPassed) {
Write-Host "==========================================" -ForegroundColor Green
Write-Host " ALL STEPS PASSED -- policy server live" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Green
} else {
Write-Host "==========================================" -ForegroundColor Red
Write-Host " ONE OR MORE STEPS FAILED -- see above" -ForegroundColor Red
Write-Host "==========================================" -ForegroundColor Red
exit 1
}
+216 -40
View File
@@ -5,7 +5,6 @@ Main orchestrator: loads deck reports, identifies underperformers,
finds replacement candidates via embeddings, builds prompts,
calls the LLM, parses responses, and persists coaching sessions.
"""
import json
import logging
import uuid
@@ -24,6 +23,10 @@ from .config import (
from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
RemovalCoverage, RampQuality, DrawEngineProfile,
WinCondition, AntiSynergyFlag,
PodPresence, TempoAssessment, MetaMatchup,
)
from .llm_client import LLMClient
from .embeddings import MTGEmbeddingIndex
@@ -61,8 +64,7 @@ class CoachService:
self.embeddings = embeddings
ensure_dirs()
# ── Deck Report I/O ────────────────────────────────────────
# ── Deck Report I/O ──────────────────────
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
"""Load a deck report from disk."""
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
@@ -71,13 +73,11 @@ class CoachService:
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
with open(report_path, "r", encoding="utf-8") as f:
data = json.load(f)
return DeckReport(**data)
except Exception as e:
@@ -90,13 +90,12 @@ class CoachService:
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────────────────────────
# ── Coach Session I/O ──────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk and SQLite."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
@@ -148,7 +147,7 @@ class CoachService:
if not path.exists():
return None
try:
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return CoachSession(**data)
except Exception as e:
@@ -169,7 +168,7 @@ class CoachService:
sessions = []
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
try:
with open(f, "r") as fh:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
if deck_id and data.get("deckId", "") != deck_id:
continue
@@ -185,8 +184,7 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
# ── Main Coaching Pipeline ───────────────────────
async def run_coaching_session(
self,
deck_id: str,
@@ -203,7 +201,6 @@ class CoachService:
6. Parse response
7. Persist session
"""
# 0. Auto-load embeddings if not loaded
if not self.embeddings.loaded:
logger.info("Embeddings not loaded — attempting auto-load...")
try:
@@ -215,7 +212,6 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
@@ -223,10 +219,7 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
@@ -255,19 +248,47 @@ class CoachService:
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# 4. Build prompts
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
# Phase 4a: behavior-aware RAG — primary + replacement-effect secondary query
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
# Primary: commander synergy context
base_query = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
rag_cards = query_cards(base_query, n_results=10, color_identity=report.colorIdentity)
# Secondary: replacement effects / graveyard hate / protection
if len(rag_cards) < 15:
repl_cards = query_cards(
f"replacement effect graveyard {' '.join(report.colorIdentity)}",
n_results=5,
color_identity=report.colorIdentity,
require_replacement=True,
)
# Deduplicate by name before merging
existing_names = {c["name"] for c in rag_cards}
rag_cards += [c for c in repl_cards if c["name"] not in existing_names]
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
except Exception as e:
logger.warning("RAG query failed (non-fatal): %s", e)
# 5. Call LLM
# Phase 4b: rules context for grounding the coach prompt
rules_context: List[dict] = []
try:
from services.rag_store import query_rules
rules_context = query_rules(
f"Commander rules {report.commander} {' '.join(report.colorIdentity)}",
n_results=3,
)
except Exception:
pass
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards, rules_context=rules_context)
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
# Use streaming to avoid the Anthropic SDK 10-minute non-streaming timeout.
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
@@ -278,10 +299,7 @@ class CoachService:
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
# Build an LLMResponse-compatible object
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
@@ -304,7 +322,6 @@ class CoachService:
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
@@ -315,24 +332,106 @@ class CoachService:
completionTokens=llm_response.completion_tokens,
goals=goals,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
session.rawTextExplanation = llm_response.content
session.summary = "LLM returned non-JSON response. See raw explanation."
# 7. Persist
self.save_session(session)
logger.info("Coaching session saved: %s", session_id)
return session
# ── Quick Digest ──────────────────────────────
async def run_quick_digest(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# Phase 4a: light RAG query for quick digest
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
except Exception:
pass
from .prompt_template import build_quick_system_prompt, build_user_prompt
system_prompt = build_quick_system_prompt(report, goals)
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # no candidates for speed
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=1024,
temperature=0.5,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
timestamp=datetime.now(timezone.utc).isoformat(),
modelUsed=llm_response.model,
promptTokens=llm_response.prompt_tokens,
completionTokens=llm_response.completion_tokens,
goals=goals,
isQuickDigest=True,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
session.rawTextExplanation = llm_response.content
session.summary = "Quick digest returned non-JSON. See raw explanation."
self.save_session(session)
return session
# ── JSON Parser ───────────────────────────────
def _populate_session_from_json(self, session: CoachSession, data: dict):
"""Parse LLM JSON response into CoachSession fields."""
session.summary = data.get("summary", "")
session.rawTextExplanation = data.get("rawTextExplanation", "")
session.manaBaseAdvice = data.get("manaBaseAdvice")
hints = data.get("heuristicHints", [])
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
@@ -343,7 +442,6 @@ class CoachService:
reason=cut_data.get("reason", ""),
replacementOptions=cut_data.get("replacementOptions", []),
))
for add_data in data.get("suggestedAdds", []):
if isinstance(add_data, dict):
session.suggestedAdds.append(SuggestedAdd(
@@ -352,13 +450,93 @@ class CoachService:
reason=add_data.get("reason", ""),
synergyWith=add_data.get("synergyWith", []),
))
for item in data.get("upgradePriority", []):
if isinstance(item, dict):
session.upgradePriority.append(UpgradePriorityItem(
rank=item.get("rank", 0),
cut=item.get("cut", ""),
add=item.get("add", ""),
reasoning=item.get("reasoning", ""),
expectedImpact=item.get("expectedImpact", ""),
))
cd = data.get("commanderDependency")
if isinstance(cd, dict):
session.commanderDependency = CommanderDependency(
score=cd.get("score", 5),
dependentCards=cd.get("dependentCards", []),
recoveryPlan=cd.get("recoveryPlan", ""),
)
ma = data.get("mulliganAnalysis")
if isinstance(ma, dict):
session.mulliganAnalysis = MulliganAnalysis(
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
worstOffenders=ma.get("worstOffenders", []),
recommendation=ma.get("recommendation", ""),
)
rc = data.get("removalCoverage")
if isinstance(rc, dict):
session.removalCoverage = RemovalCoverage(**{
k: rc.get(k, "" if k != "gaps" else [])
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
})
rq = data.get("rampQuality")
if isinstance(rq, dict):
session.rampQuality = RampQuality(**{
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
})
dep = data.get("drawEngineProfile")
if isinstance(dep, dict):
session.drawEngineProfile = DrawEngineProfile(
burstDraw=dep.get("burstDraw", []),
repeatableEngines=dep.get("repeatableEngines", []),
assessment=dep.get("assessment", ""),
sustainability=dep.get("sustainability", ""),
)
for wc in data.get("winConditions", []):
if isinstance(wc, dict):
session.winConditions.append(WinCondition(
name=wc.get("name", ""),
cards=wc.get("cards", []),
independence=wc.get("independence", ""),
description=wc.get("description", ""),
))
for af in data.get("antiSynergyFlags", []):
if isinstance(af, dict):
session.antiSynergyFlags.append(AntiSynergyFlag(
cards=af.get("cards", []),
conflict=af.get("conflict", ""),
severity=af.get("severity", ""),
))
pp = data.get("podPresence")
if isinstance(pp, dict):
session.podPresence = PodPresence(
threatLevel=pp.get("threatLevel", ""),
politicalTools=pp.get("politicalTools", []),
adversarialRating=pp.get("adversarialRating", ""),
recommendation=pp.get("recommendation", ""),
)
ta = data.get("tempoAssessment")
if isinstance(ta, dict):
session.tempoAssessment = TempoAssessment(
peakTurn=ta.get("peakTurn", 0),
profile=ta.get("profile", ""),
survivalWindow=ta.get("survivalWindow", ""),
assessment=ta.get("assessment", ""),
)
for mm in data.get("metaMatchups", []):
if isinstance(mm, dict):
session.metaMatchups.append(MetaMatchup(
archetype=mm.get("archetype", ""),
assessment=mm.get("assessment", ""),
keyThreats=mm.get("keyThreats", []),
keyAnswers=mm.get("keyAnswers", []),
overallRating=mm.get("overallRating", ""),
))
# ── Status Check ───────────────────────────────────────────
# ── Status Check ──────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
status = CoachStatus()
if COACH_PROVIDER == "anthropic":
anthropic_key = ANTHROPIC_API_KEY
if anthropic_key:
@@ -371,15 +549,13 @@ class CoachService:
status.llmModels = []
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
else:
llm_status = self.llm.check_connection()
llm_status = self.llm.check_connection_sync() # FARMHOUSE_COACH_STATUS_V1 (was an un-awaited coroutine)
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")
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+1 -1
View File
@@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren
# ── Ollama (OpenAI-compatible API) ────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
+3 -9
View File
@@ -28,6 +28,7 @@ from urllib.parse import quote
import numpy as np
from services.card_text import build_card_text
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
logger = logging.getLogger("coach.embeddings")
@@ -114,15 +115,8 @@ def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
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)
"""Delegate to shared build_card_text() -- see services/card_text.py."""
return build_card_text(card)
class CardMatch:
+20 -1
View File
@@ -74,7 +74,7 @@ class LLMClient:
try:
status = await self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
self._resolved_model = self.model if self.model in (status.get("models") or []) else status["active_model"] # FARMHOUSE_MODEL_PIN_V1
logger.info("Resolved Ollama model: %s", self._resolved_model)
return self._resolved_model
except Exception:
@@ -103,6 +103,14 @@ class LLMClient:
}
if force_json:
body["response_format"] = {"type": "json_object"}
# FARMHOUSE_LLM_KNOBS_V1 (see patch_app.py)
import os as _fh_os
_fh_effort = _fh_os.environ.get("LLM_REASONING", "").strip()
if _fh_effort:
body["reasoning_effort"] = _fh_effort
_fh_cap = int(_fh_os.environ.get("LLM_MAX_TOKENS", "0") or 0)
if _fh_cap:
body["max_tokens"] = min(body["max_tokens"], _fh_cap)
return body
# ── Response parsing ──────────────────────────────────────
@@ -260,6 +268,17 @@ class LLMClient:
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
def check_connection_sync(self) -> dict: # FARMHOUSE_LLM_SYNC_V1
"""Blocking version of check_connection (upstream called it and never defined it)."""
try:
_r = httpx.get(f"{self.base_url}/models", timeout=5.0, headers={"Content-Type": "application/json"})
_r.raise_for_status()
_ids = [m.get("id", "unknown") for m in _r.json().get("data", [])]
return {"connected": True, "models": _ids,
"active_model": self.model if self.model in _ids else (_ids[0] if _ids else self.model)}
except Exception as _e:
return {"connected": False, "error": str(_e), "models": [], "active_model": None}
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
try:
+140 -16
View File
@@ -4,11 +4,9 @@ Commander AI Lab — Coach Data Models
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
@@ -16,16 +14,16 @@ from pydantic import BaseModel, Field
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
drawnRate: float = 0.0
castRate: float = 0.0
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
deadCardRate: float = 0.0
impactScore: float = 0.0
synergyScore: float = 0.0
clunkinessScore: float = 0.0
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
tags: List[str] = Field(default_factory=list)
class MatchupRecord(BaseModel):
@@ -47,7 +45,7 @@ class DeckMeta(BaseModel):
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
@@ -56,7 +54,7 @@ class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
assemblyRate: float = 0.0
class DeckReport(BaseModel):
@@ -83,10 +81,10 @@ class DeckReport(BaseModel):
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
targetPowerLevel: Optional[int] = None
metaFocus: Optional[str] = None
budget: Optional[str] = None
focusAreas: List[str] = Field(default_factory=list)
class CoachRequest(BaseModel):
@@ -106,12 +104,120 @@ class SuggestedCut(BaseModel):
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = "" # ramp, draw, removal, finisher, etc.
role: str = ""
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
# ══════════════════════════════════════════════════════════════
# Phase 2: Structured Analysis Models
# ══════════════════════════════════════════════════════════════
class UpgradePriorityItem(BaseModel):
"""A single prioritized cut/add swap ranked by expected impact."""
rank: int
cut: str
add: str
reasoning: str = ""
expectedImpact: str = ""
class CommanderDependency(BaseModel):
"""How reliant the deck is on having the commander in play."""
score: int = 5
dependentCards: List[str] = Field(default_factory=list)
recoveryPlan: str = ""
class MulliganAnalysis(BaseModel):
"""Opening hand keepability assessment."""
estimatedKeepRate: str = ""
worstOffenders: List[str] = Field(default_factory=list)
recommendation: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 3: Qualitative Analysis Models
# ══════════════════════════════════════════════════════════════
class RemovalCoverage(BaseModel):
"""Categorized removal package by permanent type."""
creatures: str = ""
artifacts: str = ""
enchantments: str = ""
planeswalkers: str = ""
lands: str = ""
massRemoval: str = ""
gaps: List[str] = Field(default_factory=list)
class RampQuality(BaseModel):
"""Ramp package breakdown by subtype and fragility."""
manaRocks: str = ""
landFetch: str = ""
manaDorks: str = ""
costReducers: str = ""
fragility: str = ""
canReachFourByTurnThree: str = ""
class DrawEngineProfile(BaseModel):
"""Card draw sustainability analysis."""
burstDraw: List[str] = Field(default_factory=list)
repeatableEngines: List[str] = Field(default_factory=list)
assessment: str = ""
sustainability: str = ""
class WinCondition(BaseModel):
"""A named win condition with independence rating."""
name: str
cards: List[str] = Field(default_factory=list)
independence: str = ""
description: str = ""
class AntiSynergyFlag(BaseModel):
"""An internal conflict between cards in the deck."""
cards: List[str] = Field(default_factory=list)
conflict: str = ""
severity: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 5: Pod Politics & Meta Awareness Models
# ══════════════════════════════════════════════════════════════
class PodPresence(BaseModel):
"""Commander pod politics and threat assessment."""
threatLevel: str = "" # early/medium/late/low
politicalTools: List[str] = Field(default_factory=list)
adversarialRating: str = "" # low/medium/high
recommendation: str = ""
class TempoAssessment(BaseModel):
"""When the deck 'comes online' and whether that's fast enough."""
peakTurn: int = 0
profile: str = "" # early/mid/late
survivalWindow: str = "" # Assessment of whether it survives to peak
assessment: str = ""
class MetaMatchup(BaseModel):
"""Strategic reasoning about deck vs a meta archetype."""
archetype: str # fast-combo, stax, control, aggro, etc.
assessment: str = ""
keyThreats: List[str] = Field(default_factory=list)
keyAnswers: List[str] = Field(default_factory=list)
overallRating: str = "" # favored/even/unfavored
# ══════════════════════════════════════════════════════════════
# Coach Session (persisted result)
# ══════════════════════════════════════════════════════════════
class CoachSession(BaseModel):
"""
Complete coaching session result.
@@ -130,6 +236,24 @@ class CoachSession(BaseModel):
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
isQuickDigest: bool = False # True for quick digest, False for full analysis
# Phase 2: Structured analysis fields
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
commanderDependency: Optional[CommanderDependency] = None
mulliganAnalysis: Optional[MulliganAnalysis] = None
# Phase 3: Qualitative analysis fields
removalCoverage: Optional[RemovalCoverage] = None
rampQuality: Optional[RampQuality] = None
drawEngineProfile: Optional[DrawEngineProfile] = None
winConditions: List[WinCondition] = Field(default_factory=list)
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
# Phase 5: Pod politics & meta
podPresence: Optional[PodPresence] = None
tempoAssessment: Optional[TempoAssessment] = None
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
class ApplyRequest(BaseModel):
+314 -26
View File
@@ -4,6 +4,13 @@ Commander AI Lab — Coach Prompt Template
Constructs system and user prompts for the LLM coach.
Injects deck report data, underperformer analysis, and
candidate replacement cards into a structured template.
Phase 1 additions (#108):
- Sim Insights section: deck-level opening hand quality, curve velocity,
synergy outliers, and archetype weak spots surfaced directly in the
user prompt so Claude reasons from numbers, not vibes.
- _format_archetypes() annotates weak spots and strengths inline.
- _format_top_cards() adds keptInOpeningHandRate per card.
"""
from typing import List, Dict, Optional
@@ -12,9 +19,9 @@ from .models import DeckReport, CoachGoals, CardPerformance
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
# ══════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════
# System Prompt
# ══════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
You analyze deck performance reports from simulation data and provide specific,
@@ -36,7 +43,23 @@ RULES:
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- The Sim Insights section shows pre-computed deck-level signals — use these directly in your reasoning.
- Cards flagged as [OPENING HAND TRAP] have keptInOpeningHandRate below the deck average — treat as mulligan liabilities.
- Cards flagged as [LATE CASTER] have avgTurnCast > 6 — consider whether they fit the deck's tempo.
- Cards flagged as [ISOLATED] have synergyScore in the bottom 3 of the deck — they may not support the gameplan.
- Archetypes flagged as [WEAK SPOT] in the Archetype Win Rates section are where this deck loses most — prioritize fixes for those matchups.
- Provide at least 5 heuristic hints covering different strategic dimensions.
- Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans.
- Rank your suggested swaps by priority so the user knows what to change first.
- Categorize the removal package by permanent type and flag coverage gaps.
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
- Distinguish one-time draw from repeatable draw engines.
- Identify the top 3 win conditions and rate each for independence.
- Flag internal anti-synergies or card conflicts.
- Assess pod threat perception: does this deck draw too much hate too early?
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
@@ -61,6 +84,25 @@ You MUST respond ONLY with valid JSON matching this exact structure:
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
}}
],
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is the single highest-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
}},
"mulliganAnalysis": {{
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
"worstOffenders": ["Card most responsible for unkeepable hands"],
"recommendation": "What to change to improve opening hand quality"
}},
"manaCurveAnalysis": {{
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
"recommendations": "Specific suggestions for curve adjustments with reasoning",
@@ -80,15 +122,108 @@ You MUST respond ONLY with valid JSON matching this exact structure:
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate"
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates and the [WEAK SPOT] archetypes flagged in Sim Insights, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{
"creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal",
"enchantments": "Assessment of enchantment removal",
"planeswalkers": "Assessment of planeswalker removal",
"lands": "Assessment of land destruction",
"massRemoval": "Assessment of board wipes",
"gaps": ["Permanent types with zero or weak coverage"]
}},
"rampQuality": {{
"manaRocks": "2-mana rocks, signets, etc.",
"landFetch": "Cultivate, Kodama's Reach, etc.",
"manaDorks": "Llanowar Elves, Birds, etc.",
"costReducers": "Medallions, Helm of Awakening, etc.",
"fragility": "Overall fragility assessment",
"canReachFourByTurnThree": "yes/no with reasoning"
}},
"drawEngineProfile": {{
"burstDraw": ["One-time draw spells"],
"repeatableEngines": ["Sustained draw engines"],
"assessment": "Overall draw quality",
"sustainability": "Whether draw is sustainable or burst-only"
}},
"winConditions": [
{{
"name": "Name of win condition",
"cards": ["Key cards involved"],
"independence": "high|medium|low",
"description": "How this win con functions"
}}
],
"antiSynergyFlags": [
{{
"cards": ["Card A", "Card B"],
"conflict": "Description of conflict",
"severity": "high|medium|low"
}}
],
"podPresence": {{
"threatLevel": "early|medium|late|low",
"politicalTools": ["Pillowfort or group effect cards"],
"adversarialRating": "low|medium|high",
"recommendation": "How to manage threat perception in a pod"
}},
"tempoAssessment": {{
"peakTurn": 5,
"profile": "early|mid|late",
"survivalWindow": "Assessment of whether the deck survives to its power window",
"assessment": "Overall tempo evaluation"
}},
"metaMatchups": [
{{
"archetype": "fast-combo|stax|control|aggro|midrange",
"assessment": "How the deck fares against this archetype",
"keyThreats": ["Cards/strategies that threaten this deck"],
"keyAnswers": ["Cards in the deck that answer this archetype"],
"overallRating": "favored|even|unfavored"
}}
]
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ════════════════════════════════════════════════════════════
# Quick Digest Prompt
# ════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
# User Prompt Builder
# ══════════════════════════════════════════════════════════════
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
Provide a FAST summary analysis of this deck. Be concise.
RULES:
- NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}.
- Cards flagged [OPENING HAND TRAP] have low keptInOpeningHandRate — prime cut candidates.
- Cards flagged [WEAK SPOT] in archetype win rates show where the deck loses most.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is a high-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Brief recovery strategy"
}}
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ════════════════════════════════════════════════════════════
# User Prompt Template
# ════════════════════════════════════════════════════════════
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
Commander: {commander}
@@ -103,6 +238,9 @@ Avg Game Length: {avg_game_length:.1f} turns
- Card Types: {type_summary}
- Functional Roles: {role_summary}
### Sim Insights (pre-computed deck-level signals)
{sim_insights_section}
### Matchup Data
{matchup_section}
@@ -115,6 +253,9 @@ Avg Game Length: {avg_game_length:.1f} turns
### Underperforming Cards (candidates for cuts)
{underperformer_section}
### RAG-Retrieved Similar Cards (from ChromaDB)
{rag_section}
### Candidate Replacement Cards (from embeddings search)
{candidate_section}
@@ -127,7 +268,6 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
@@ -135,18 +275,14 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
@@ -158,7 +294,11 @@ def _format_curve(buckets: List[int]) -> str:
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""Format the best performing cards with extended stats."""
"""Format the best performing cards with extended stats.
Phase 1: adds keptInOpeningHandRate so Claude sees hand quality for
the good cards too, not just the underperformers.
"""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
@@ -167,13 +307,15 @@ def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}{tags}"
)
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str:
underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats."""
card_map = {c.name: c for c in cards}
lines = []
@@ -181,8 +323,16 @@ def _format_underperformers(cards: List[CardPerformance],
c = card_map.get(name)
if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
flags = []
if c.keptInOpeningHandRate < 0.25:
flags.append("OPENING HAND TRAP")
if c.avgTurnCast is not None and c.avgTurnCast > 6:
flags.append("LATE CASTER")
if c.synergyScore < 0.1:
flags.append("ISOLATED")
flag_str = f" [{', '.join(flags)}]" if flags else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"- {c.name}{flag_str}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
@@ -195,6 +345,87 @@ def _format_underperformers(cards: List[CardPerformance],
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_sim_insights(cards: List[CardPerformance],
archetype_rates: Dict[str, float]) -> str:
"""Compute and format deck-level sim signals for the Sim Insights section.
Phase 1 (#108): surfaces pre-computed per-card stats as deck-level
aggregates so Claude reasons from numbers directly.
Signals produced:
1. Opening hand quality — deck avg keptInOpeningHandRate + worst offenders
2. Curve velocity — castRate-weighted avg avgTurnCast + late-caster flags
3. Synergy outliers — bottom-3 synergyScore cards flagged as [ISOLATED]
4. Archetype weak spots — archetypes < 40% win rate flagged [WEAK SPOT]
"""
if not cards:
return "No simulation data available."
lines = []
# ── 1. Opening hand quality ───────────────────────────────────────────
hand_rates = [c.keptInOpeningHandRate for c in cards if c.keptInOpeningHandRate > 0]
if hand_rates:
avg_keep = sum(hand_rates) / len(hand_rates)
lines.append(f"Opening Hand Quality: deck avg keptInOpeningHandRate = {avg_keep:.1%}")
# Worst offenders: cards significantly below the deck average
threshold = avg_keep * 0.6 # >40% below avg is an outlier
offenders = sorted(
[c for c in cards if c.keptInOpeningHandRate > 0 and c.keptInOpeningHandRate < threshold],
key=lambda c: c.keptInOpeningHandRate
)[:3]
if offenders:
lines.append(" Opening hand traps (keptInOpeningHandRate far below deck avg):")
for c in offenders:
lines.append(f" - {c.name}: {c.keptInOpeningHandRate:.1%} [OPENING HAND TRAP]")
else:
lines.append("Opening Hand Quality: no keptInOpeningHandRate data available")
# ── 2. Curve velocity ───────────────────────────────────────────────
cast_cards = [c for c in cards if c.avgTurnCast is not None and c.castRate > 0]
if cast_cards:
total_weight = sum(c.castRate for c in cast_cards)
if total_weight > 0:
weighted_avg_turn = sum(
c.avgTurnCast * c.castRate for c in cast_cards
) / total_weight
lines.append(f"Curve Velocity: castRate-weighted avg avgTurnCast = {weighted_avg_turn:.1f}")
late_casters = sorted(
[c for c in cast_cards if c.avgTurnCast > 6],
key=lambda c: c.avgTurnCast,
reverse=True
)[:3]
if late_casters:
lines.append(" Late casters (avgTurnCast > 6 — may not fit the curve):")
for c in late_casters:
lines.append(f" - {c.name}: avgTurnCast={c.avgTurnCast:.1f} [LATE CASTER]")
else:
lines.append("Curve Velocity: no avgTurnCast data available")
# ── 3. Synergy outliers ──────────────────────────────────────────────
synergy_cards = sorted(
[c for c in cards if c.castRate > 0.05], # ignore near-never-cast cards
key=lambda c: c.synergyScore
)
if synergy_cards:
bottom3 = synergy_cards[:3]
lines.append("Synergy Outliers (lowest synergyScore among cast cards):")
for c in bottom3:
lines.append(f" - {c.name}: synergyScore={c.synergyScore:.3f} [ISOLATED]")
# ── 4. Archetype weak spots ──────────────────────────────────────────
if archetype_rates:
weak = {k: v for k, v in archetype_rates.items() if v < 0.40}
if weak:
lines.append("Archetype Weak Spots (win rate < 40%):")
for arch, wr in sorted(weak.items(), key=lambda x: x[1]):
lines.append(f" - vs {arch}: {wr:.1%} [WEAK SPOT]")
else:
lines.append("Archetype Weak Spots: none (all matchups >= 40% win rate)")
return "\n".join(lines) if lines else "No sim insights available."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
@@ -205,7 +436,6 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)."
)
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
@@ -218,12 +448,29 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview:
lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines)
def _format_rag_cards(rag_cards: List[dict]) -> str:
"""Format RAG-retrieved cards from ChromaDB vector search."""
if not rag_cards:
return "No RAG results available."
lines = []
for card in rag_cards[:15]:
name = card.get("name", "Unknown")
types = card.get("types", "")
mv = card.get("mana_value", "?")
mc = card.get("mana_cost", "")
dist = card.get("distance", 0)
text = card.get("text", "")[:80]
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
if text:
lines.append(f" Oracle: {text}")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format per-opponent matchup data."""
if not report.matchups:
@@ -238,13 +485,23 @@ def _format_matchups(report: DeckReport) -> str:
def _format_archetypes(report: DeckReport) -> str:
"""Format per-archetype win rates from DeckMeta."""
"""Format per-archetype win rates from DeckMeta.
Phase 1: annotates weak spots ([WEAK SPOT] < 40%) and strengths
([STRENGTH] >= 55%) so Claude sees the signal immediately.
"""
rates = report.meta.perArchetypeWinRates
if not rates:
return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
lines.append(f"- vs {archetype}: {wr:.1%}")
if wr < 0.40:
tag = " [WEAK SPOT]"
elif wr >= 0.55:
tag = " [STRENGTH]"
else:
tag = ""
lines.append(f"- vs {archetype}: {wr:.1%}{tag}")
return "\n".join(lines)
@@ -262,9 +519,9 @@ def _format_combos(report: DeckReport) -> str:
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════
# Main Builder Functions
# ══════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
@@ -277,16 +534,44 @@ def build_system_prompt(report: DeckReport,
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
def build_quick_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the quick digest system prompt (~400-600 token target)."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return QUICK_DIGEST_SYSTEM_PROMPT.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None,
rules_context: List[dict] = None) -> str: # FARMHOUSE_RULES_CONTEXT
text = _farmhouse_build_user_prompt(report, candidates, rag_cards)
rules = []
for item in (rules_context or [])[:3]:
body = (item.get('text') or item.get('document') or item.get('content')) if isinstance(item, dict) else str(item)
if body:
rules.append('- ' + str(body).strip()[:700])
if rules:
text += '\n\n## Relevant rules (for grounding; do not quote them back)\n' + '\n'.join(rules)
return text
def _farmhouse_build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data.
Phase 1: adds sim_insights_section computed from card-level stats.
"""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
@@ -298,11 +583,14 @@ def build_user_prompt(report: DeckReport,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
sim_insights_section=_format_sim_insights(
report.cards, report.meta.perArchetypeWinRates),
matchup_section=_format_matchups(report),
archetype_section=_format_archetypes(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
rag_section=_format_rag_cards(rag_cards or []),
combo_section=_format_combos(report),
)
+11
View File
@@ -1,6 +1,9 @@
"""
Commander AI Lab — Deck Generation System Prompt
═════════════════════════════════════════════════
Phase 1 changes:
- Added explicit 99-card count enforcement rule
- Clarified that commander counts separately (not in the 99)
"""
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
RULES:
- Exactly 100 cards total: 1 commander + 99 other cards
- The commander IS included in the cards list but counts as card #100
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
- No more than 1 copy of any card except basic lands
- All cards must share the commander's color identity
- All cards must be legal in Commander format
@@ -16,6 +21,12 @@ RULES:
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
- Build a cohesive strategy around the commander's abilities
CARD COUNT ENFORCEMENT (critical):
- The user prompt will specify a SLOT BUDGET with exact per-category counts
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
- If you cannot find a perfect fit for a slot, use the best available card for that role
- Basic lands count toward the Land slot total
BRACKET RULES (Commander Rules Committee):
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
+41 -12
View File
@@ -1,11 +1,33 @@
"""
Commander AI Lab — Deck Generation User Prompt Builder
═══════════════════════════════════════════════════════
Constructs the user message for Claude Opus deck generation
from a DeckGenV3Request.
Constructs the user message for LLM deck generation.
Phase 1 changes:
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
- Explicit card count requirement in prompt text
"""
from typing import List
from typing import Dict, List, Optional
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
"""
Build the slot budget prompt segment.
Informs the LLM of exact per-category card counts so it never undershoots.
"""
total = sum(slot_budget.values())
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
for cat, count in slot_budget.items():
lines.append(f" {cat}: {count}")
lines.append(f" TOTAL: {total}")
lines.append("")
lines.append(
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
"(not counting the commander). Every slot in the budget above must be filled. "
"Do not return more or fewer cards."
)
return "\n".join(lines)
def build_user_prompt(
@@ -18,6 +40,7 @@ def build_user_prompt(
budget_mode: str = "total",
omit_cards: List[str] = None,
collection_summary: str = "",
slot_budget: Optional[Dict[str, int]] = None,
) -> str:
"""
Build the user prompt for deck generation.
@@ -26,12 +49,13 @@ def build_user_prompt(
commander: Commander card name
commander_type: Type line from Scryfall
color_identity: List of color letters, e.g. ["U", "B"]
strategy: User-specified strategy focus (e.g. "zombie tokens")
strategy: User-specified strategy focus
target_bracket: Target bracket level 1-4
budget_usd: Optional budget constraint
budget_mode: "total" for total deck cost, "per_card" for per-card max
omit_cards: Cards to exclude
collection_summary: Pre-built collection summary text block
slot_budget: Dict of category -> card count (must sum to 99)
"""
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
@@ -60,16 +84,21 @@ def build_user_prompt(
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
if collection_summary:
lines.append(f"")
lines.append("")
lines.append(collection_summary)
lines.append(f"")
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
lines.append("")
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
else:
lines.append(f"")
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
lines.append("")
lines.append("No collection data available — suggest the best cards regardless of ownership.")
lines.append(f"")
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
# Inject slot budget so LLM knows exact target counts
if slot_budget:
lines.append("")
lines.append(build_slot_budget_segment(slot_budget))
lines.append("")
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
return "\n".join(lines)
+20 -4
View File
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
════════════════════════════════════════
Pydantic models and JSON Schema dicts for the
Smart Substitution Engine output.
Phase 1 changes:
- SubstitutionBatchResult gains padded_slots field
- DeckCardWithStatus gains original_card field (was missing)
"""
from typing import List, Optional
@@ -25,9 +29,10 @@ class CardAlternative(BaseModel):
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
owned_qty: int = 0
scryfall_id: str = ""
status: str = Field(default="owned", description="owned | substituted | missing")
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
original_card: Optional[str] = Field(
default=None,
description="Original card name before substitution (None if not substituted)"
)
alternatives: List[CardAlternative] = Field(default_factory=list)
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
selected_substitute: Optional[str] = Field(
default=None,
description="The substitute card name chosen for this slot"
)
class SubstitutionRequest(BaseModel):
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
owned_count: int = 0
substituted_count: int = 0
missing_count: int = 0
padded_slots: int = Field(
default=0,
description="Number of slots filled by the pad-to-99 circuit breaker. "
"Any value > 0 indicates a bug in the substitution pipeline."
)
cards: List[DeckCardWithStatus] = Field(default_factory=list)
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Perplexity fallback substitution)
# JSON Schema dicts (for LLM fallback substitution)
# ══════════════════════════════════════════════════════════════
SUBSTITUTION_RESPONSE_SCHEMA = {
+391 -107
View File
@@ -4,12 +4,21 @@ Commander AI Lab — V3 Deck Generator Service
Generates Commander decks using Anthropic Claude
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
@@ -32,6 +41,236 @@ from ..config import (
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Anthropic Claude structured output.
@@ -42,18 +281,14 @@ class DeckGeneratorV3:
3. Call Anthropic Claude with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
7. Return finalized deck with substitution data
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
"""
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
embedding_index=None):
"""
Args:
anthropic_client: Configured AnthropicClient instance
db_conn_factory: Callable that returns sqlite3.Connection
embedding_index: Optional MTGEmbeddingIndex for substitution
"""
self.anthropic = anthropic_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
if not rows:
return ""
# Filter by color identity
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
name = row["name"]
type_line = row["type_line"] or ""
# Basic lands and colorless are always allowed
if ci_set:
# Simple color check from type_line and name
# For proper filtering we'd check mana cost but this is good enough for summary
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
if not filtered:
return ""
# Group by card type
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
group = "Other"
groups.setdefault(group, []).append(row)
# Sort each group by popularity/price and build text
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
# Sort by price descending (higher value = likely better card)
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
@@ -194,23 +424,32 @@ class DeckGeneratorV3:
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check)
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
# Step 2: Collection summary
collection_block = ""
if use_collection:
if use_collection and scryfall_found:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts
# Step 3: Build prompts (with slot budget injected)
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
@@ -221,11 +460,12 @@ class DeckGeneratorV3:
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call Anthropic Claude with structured output
# Step 4: Call LLM with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
logger.info("Calling LLM (%s) for deck generation...", use_model)
response = self.anthropic.chat_structured(
system_prompt=SYSTEM_PROMPT,
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
if not response.ok:
raise ValueError(
f"Anthropic Claude returned invalid response: {response.content[:300]}"
f"LLM returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
# Step 5: Check ownership and build enriched card list
# Step 5: Check ownership
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to hit exactly 100 cards
# Step 5b: Fill basic lands to reach target_total=100
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary safety-net deduplication
seen = {}
deduped = []
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
deduped.append(c)
cards_with_status = deduped
# Step 6: Compute stats
# Step 6: Run non-destructive smart substitution
# For non-canonical commanders (e.g. Sonic), skip collection filter
owned_pool: set[str] = set()
if use_collection and scryfall_found:
conn = self.db_conn_factory()
rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in rows}
cards_with_status, substitution_log = apply_substitutions(
cards_with_status, owned_pool, color_identity, self.embedding_index
)
# Step 7: Pad-to-99 circuit breaker
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
# Step 8: Pre-validation count logging
_log_pre_validation_counts(cards_with_status)
# Step 9: Compute stats
stats = self._compute_stats(cards_with_status)
return {
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
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.
Deduplicates by name (case-insensitive).
"""
conn = self.db_conn_factory()
enriched = []
seen_names: dict[str, int] = {} # name_lower → index in enriched
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
for card in cards:
name = card.get("name", "")
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
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)
logger.warning("Duplicate card '%s' merged", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
def _fill_basic_lands(
self,
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ['Wastes']
basic_names = ["Wastes"]
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category='Land',
category="Land",
role_tags=[],
reason='Basic land for mana fixing',
reason="Basic land for mana fixing",
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status='owned',
status="owned",
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution ───────────────────────────
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
def run_substitution(
self,
cards: List[DeckCardWithStatus],
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
"""
Run the Smart Substitution Engine on missing cards.
Pipeline:
a) Identify missing cards
b) For each: try embedding similarity (free, fast)
c) For low-confidence matches: batch Anthropic Claude fallback
d) Auto-select best substitute per card
Returns SubstitutionBatchResult with all cards updated.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
@@ -431,47 +691,63 @@ class DeckGeneratorV3:
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build list of owned card names for substitution pool
# Build owned pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = [r["name"] for r in owned_pool_rows]
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
# Exclude cards already in the deck
deck_names = set(c.name.lower() for c in cards)
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
# a) Embedding pass
needs_anthropic_fallback = []
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, available_pool
card, color_identity, list(available_pool)
)
card.alternatives = embedding_alts
# Check if best match meets threshold
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
# Remove selected from pool
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
available_pool = [n for n in available_pool
if n.lower() != card.selected_substitute.lower()]
card.owned = True
available_pool.discard(card.selected_substitute.lower())
else:
needs_anthropic_fallback.append(card)
# b) Anthropic Claude fallback for low-confidence matches
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, available_pool,
needs_anthropic_fallback, list(available_pool),
commander, strategy
)
# Count results
# c) Any card STILL missing after both passes gets a basic land — never drop it
padded = 0
for card in missing:
if card.status == "missing":
basic = _get_basic_land(color_identity)
logger.warning(
"[substitution] '%s' unresolved after all passes — "
"replacing with basic land '%s'",
card.name, basic,
)
card.name = basic
card.category = "Land"
card.role_tags = []
card.owned = True
card.status = "substituted"
card.original_card = card.name
padded += 1
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
@@ -490,33 +766,31 @@ class DeckGeneratorV3:
if not self.embedding_index or not self.embedding_index.loaded:
return []
# Use the embeddings search to find similar cards
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
)
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
# Filter to only cards in the available pool
pool_lower = set(n.lower() for n in available_pool)
pool_lower = {n.lower() for n in available_pool}
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
# Compute role overlap
match_roles = [] # Would need card's role_tags from DB
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
@@ -531,12 +805,13 @@ class DeckGeneratorV3:
):
"""
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates the cards in-place with substitution data.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
# Build the request
missing_dicts = [
{
"name": c.name,
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200], # Limit to avoid token overflow
allowed_cards=available_pool[:200],
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
)
if not response.ok:
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
return
subs = response.parsed_json.get("substitutions", [])
logger.info("Anthropic Claude suggested %d substitutions", len(subs))
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
# Apply substitutions
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
if orig in card_by_name and substitute:
card = card_by_name[orig]
# Add as top alternative
anthropic_alt = CardAlternative(
name=substitute,
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
similarity_score=0.80,
reason=reason,
source="Anthropic Claude",
source="anthropic",
role_overlap=sub.get("role_overlap", []),
)
# Insert at front
card.alternatives.insert(0, anthropic_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as e:
logger.error("Anthropic Claude substitution fallback error: %s", e)
logger.error(
"[anthropic_substitution] Exception: %s — "
"%d cards will fall through to basic land fallback",
e, len(missing_cards),
)
# Do NOT re-raise — cards stay status='missing' and
# run_substitution()'s basic-land fallback will handle them
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category = {}
by_status = {"owned": 0, "substituted": 0, "missing": 0}
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts = {}
role_counts: dict[str, int] = {}
for card in cards:
cat = card.category or "Other"
+67
View File
@@ -0,0 +1,67 @@
# Laya connections
Laya (and Kev: same `POST /v1/systemone` API) is a **decision model**: typed multiple-choice, yes/no and score questions about a text, answered in
one forward pass, no generated text. This page is where it plugs into the strategic-intelligence work in this repository
([INTELLIGENCE-HANDOFF.md](INTELLIGENCE-HANDOFF.md)), what is measured, and how to make it worth using. Written 2026-10-26.
**Short version.** Every connection below is built, tested offline and **off by default**. Zero-shot, Laya is a poor action chooser
(below the always-guess baseline) but a reasonable card classifier (48% against 17% by chance); it is too slow on a CPU for Forge's
priority path (8 s against a 1.5 s budget). The way forward is fine-tuning on the decisions these connections record.
## What connects where
| Connection | Code | Mode | Notes |
|---|---|---|---|
| Farmhouse macro choice: Laya picks among CSIE's candidate macros, reading the CSIE ranking and ITI objective in its text | `farmhouse_decide.py` (called from `deepseek_brain.py`) | `DECISION_MODE=shadow` records agreement with the LLM; `assist` uses a pick it is `DECISION_MIN_CONF` sure of | existing; only offered candidates are accepted |
| **Forge concrete priority choice**: Laya picks among the same concrete candidate IDs Forge sent to `/api/policy/decide/concrete` | `farmhouse_laya.advise_concrete` / `attach_concrete`, called from `routes/policy.py` | `shadow`: adds `laya` to the response, selection unchanged. `assist`: may replace `selected_id`, never one Forge tagged `stops_immediate_win` / `wins_now`; the original is kept as `csie_selected_id` | candidate described by kind, card, cost, target, tags (never by its opaque id); Forge still executes only an ID it sent |
| **Extra questions in the same call**: "how dangerous are the opponents' boards?" (none/low/moderate/high/lethal) and "is there a threat that must be answered?" | `farmhouse_laya._extra_questions` | whenever Laya is asked | one forward pass answers all questions, so they cost almost nothing; recorded as weak signals for the learned-threat work, **not used to decide** |
| **Capture**: every call is appended to `data/decisions/laya-shadow-<date>.jsonl` (`laya-shadow-v1`) | `farmhouse_laya.capture` | shadow / assist | the text Laya read, the offered options, its answer and extras, and the other deciders' picks (`labels`: `llm`, `csie`, `selected`) |
| **Fine-tuning data**: capture log -> `train.jsonl` / `valid.jsonl` / `manifest.json`, split by game, with the baselines to beat | `scripts/farmhouse_laya_dataset.py` | offline | the manifest reports commonest-action, CSIE-vs-teacher and Laya-zero-shot agreement on the validation side |
| **Card role evidence**: removal, board wipe, ramp, card draw, counterspell, protection, tutor, creature, other, from Oracle text | `farmhouse_laya.classify_roles` (batched, cached, with `source: laya:<model>` provenance); `scripts/farmhouse_laya_roles_eval.py` measures it | on demand | evidence for ITI / Tagger-style use, never a rules fact |
Not built (still the open items in the handoff, none of which Laya can substitute for): compiling and running the Forge controller, the bounded
combo executor, the multi-turn planner, the calibrated learned-threat model. Laya's "threat" and "must answer" extras are only raw material for that last one.
## Where the hooks are
- `src/commander_ai_lab/sim/farmhouse_decide.py` (committed): the macro-choice client gained the extra questions, the state text/options in its
answer, and `record()` writes the capture line.
- `routes/policy.py` (**working tree only, uncommitted**: the route itself is part of the pending strategic-evaluator work, so the hook was
left for that commit). It is one block in `decide_concrete`, after the result is computed:
`if _L.mode() != "off": advice = await run_in_threadpool(_L.advise_concrete, ...); result = _L.attach_concrete(...)`, guarded by `except ImportError`.
- Everything else is new files: `farmhouse_laya.py`, the two scripts, `tests/farmhouse/test_farmhouse_laya*.py`, `farmhouse/laya/`.
## Settings
`DECISION_URL` (e.g. `http://laya.commander-lab.svc.cluster.local:8080`), `DECISION_MODE` (`off` default / `shadow` / `assist`), `DECISION_MODEL`
(a checkpoint such as `typed-decisions`), `DECISION_MIN_CONF` (0.7), `DECISION_TIMEOUT` (5 s), `DECISION_TIMEOUT_FORGE` (**1.5 s**: Forge waits on this),
`DECISION_API_KEY`, `LAYA_CAPTURE_DIR` (`data/decisions`), `LAYA_CAPTURE=0` to stop writing capture lines. Three failures in a row pause asking for a minute.
## Rules the code enforces
1. Text is built from public facts and the acting player's own hand. An opponent's `hand` field is never read, even if a client sends one (tested).
2. Laya's answer must be one of the options that were offered; anything else is discarded.
3. In assist, a pick Forge marked `stops_immediate_win` / `wins_now` is never overridden, and the response says what CSIE chose.
4. A missing, slow or broken server returns "no answer"; nothing raises and the game proceeds as without Laya.
5. Scores and probabilities are relative signals; Laya's probabilities are not calibrated for this game.
## What was measured (2026-10-26, `typed-decisions` checkpoint, 4-thread CPU)
- **Macro action vs the LLM, 149 recorded decisions:** 16.1% (English) and 18.6% (typed-decisions); always guessing the commonest action 27.5%.
- **Forge-shaped priority choice, 3 scenarios, with CSIE's ranking in the text:** agreed with CSIE all three times (expected: the ranking is in the
text it reads), 42-48% sure, **7.5-8.4 s per call** with the extra questions. That is five times Forge's 1.5 s budget: on this CPU the Forge path
would time out and fall back; it needs a GPU (33 ms quoted) or a smaller text.
- **Extras:** "threat" said *high* for a lethal stack, a calm board and a behind board alike (useless zero-shot); "must answer" gave 0.48 for the
lethal stack against 0.25 and 0.29 for the others (right direction, weak).
- **Card roles from Oracle text, 150 unambiguous cards:** 48.0% (16.7% by chance among the six roles present); counterspell 76%, board wipe 64%,
removal 40%, tutor 40%, ramp 36%, card draw 32%; when 70% sure, 69% right but only 11% of cards reach that. **3.2 s per card** in batches on the CPU:
fine for the cards in a hand (cached), not for tagging 30,000 cards (about a day).
## How to make it worth using
1. Run games with `DECISION_URL` set and `DECISION_MODE=shadow` (Farmhouse LLM games, and Forge games through `/api/policy/decide/concrete`).
2. `python scripts/farmhouse_laya_dataset.py --teacher llm` (or `csie`, or `selected`). Read `manifest.json`: the **validation baselines** are the bar.
A student of CSIE can at best equal CSIE, which is free; a student of the LLM is the useful one. Several thousand rows are needed.
3. Fine-tune with Laya's own notebook/script (github.com/NandhaKishorM/laya, `notebooks/`; 2xT4 about 4-5 hours, or Apple silicon). Its worked example
(browser agent choosing among ~45 concrete candidates) went from 0.10 zero-shot to 0.66, so a concrete-candidate head is the shape that works.
Adapt the row field names if its loader differs.
4. Serve the result (`LAYA_EXTRA_MODELS` / `DECISION_MODEL`), rerun `scripts/farmhouse_decision_eval.py` and the dataset manifest, and only then
try `assist`. A GPU host makes the Forge path possible.
5. Keep game result and decision quality separate (handoff boundary 6 / learned-threat contract): a captured label is a teacher's choice, not proof it was right.
Deployment reference copies are in `farmhouse/laya/` (image, manifests, CI); the source of truth is the farmhouse-os GitOps repo.
+74
View File
@@ -0,0 +1,74 @@
# phase-rs and commander-playtester: assessment and plan
Researched 2026-10-26 from the public repositories and data files only. **Nothing from either project has been run, vendored or deployed.**
Facts marked (verified) were read from the source; (unverified) are inferences to check before building on them.
## phase-rs/phase: a rules engine
A Rust rules engine (turns, priority, the stack, combat, layers, triggers, replacement effects) that compiles to native and WebAssembly, with a game
client, a WebSocket server and its own AI. Dual licensed Apache-2.0 / MIT (`LICENSE-APACHE`, `LICENSE-MIT`; GitHub's licence field shows "none" only
because there are two files). Card data from MTGJSON (MIT); card images come from Scryfall at runtime; the repo says it bundles no card art or
Comprehensive Rules text and has a DMCA/fan-content policy. One maintainer, young (weeks to months), fast-moving (latest engine tag v0.100.0).
Why it matters here: our Python simulator has no rules stack (the handoff says so: `stack_supported: false`) and Forge needs a JDK and Maven that
are not in the image. Phase is a rules-accurate engine that can run headless.
### Coverage of the decks in this app (verified, from phase's published `coverage-data.json`)
Phase reports 88.9% of 36,037 cards implemented overall and 92.5% for Commander. For the five saved decks, distinct cards supported:
Empress V8 72/77, Landfall 65/66, God and Anime 69/71, Im not dead yet 82/84, Pew Pew 88/93: **376 of 391 (96.2%)**. The 15 unsupported cards have
one or two parsing gaps each (e.g. Gale Waterdeep Prodigy, Hall of Echoes, Precursor Golem, Invigorate, Mavinda, Drana and Linvala, Tibalt's Trickery).
"Supported" means every ability parsed; it is not a guarantee that each card plays correctly. How the engine treats an unsupported card in a game
(blank, no-op, refused) is **unverified**; test it before trusting a result that involves one.
### The parts that fit
| Part | What it gives us | Notes |
|---|---|---|
| `ai-duel` (crate `phase-ai`) | headless AI-vs-AI games from the command line: seeded, deterministic "measurement mode", batches, JSON reports, a 4-player Commander suite, paired-seed comparison | decks are plain JSON `{"name", "main":[{"name","count"}]}` (verified). **No prebuilt binary is released**: it needs a Rust build (in CI or a container, not on the workstation). How a custom Commander deck and commander are supplied to `--commander-suite` is unverified (that suite reads a feed file) |
| `phase-server` | a WebSocket game server (lobby, hidden information, spectators) | a prebuilt image is published (`ghcr.io/phase-rs/phase-server`, tags `latest`, `preview`, `sha-...`). A Python client could play seats and record every decision with real rules (protocol documentation not yet read) |
| `phase-llm` | **model-driven AI seats**: the engine hands the model its legal option list, the model returns an index, the engine re-validates it, and any failure falls back to the heuristic AI | same principle as `farmhouse_laya.py`. Speaks OpenAI-style chat (so Olla / twin-turbo) **and the System One protocol Laya and Kev serve** (verified: `WireProtocol::SystemOneRelay`, question id `pick`, 2 to 255 options) |
| `POST /jev/systemone` relay in `phase-server` | forwards the System One request to the URL in `TYPESAFE_API_URL` (verified, env var, `http` allowed for local setups) | point it at `http://laya...:8080/v1/systemone` to put Laya or Kev in a seat. The relay sends the key as `Authorization: Bearer` and does not follow redirects |
The attraction for the Laya work: a captured decision from phase is an **engine-issued option list plus the pick**, in rules-accurate games. That
is a better fine-tuning source than this repository's approximate simulator, and the exporter (`scripts/farmhouse_laya_dataset.py`) could take it with a small adapter.
## RafaelAugustScherer/commander-playtester: a browser app on top of phase
TypeScript/React single-page app, no backend. Runs phase's engine as WebAssembly in a Web Worker. Deck library with Moxfield / Archidekt / plain-text
import, a goldfishing Monte Carlo (mulligan rate, screw and flood, land drops, ramp by turn 3, curve), real engine matches (watch, or pilot one seat),
and 1-50 match head-to-head runs with a win rate and 95% confidence interval. Pins the phase engine in `engine-manifest.json` (v0.100.0; its README
still says v0.57.0) and fetches the WASM and card data with SHA-256 checks. Zero stars, last pushed 2026-10-24.
**It has no LICENSE file** (verified: the repository metadata shows none). Without a licence the code is all rights reserved: it can be read and used
as a reference, but not copied into this repository or redistributed. Ask the author to add a licence (MIT would do) before reusing any of it. What is
worth taking as ideas, not code: the goldfishing metrics, the head-to-head confidence-interval report, the name-only deck payload for the engine, and
the engine-smoke gate that boots the real engine and plays a match to completion after every upgrade.
## Recommended plan, smallest useful step first
1. **Prove it on one deck.** Build `ai-duel` from the pinned phase tag in CI (Rust, container), convert one saved deck to the JSON format, run a seeded
batch against a baseline deck, and read the result. Answers: does it run a Commander game end to end, what does it do with an unsupported card, how
long is a game. This replaces the guesswork about the Python simulator's win rates.
2. **A `phase-sim` service** in the commander-lab namespace (no egress, called only by the app) with an endpoint "run N rules-accurate games for deck X
against Y", returning win rate, confidence interval, wins by seat and the game log; the app's results panel reads it. The Python simulator stays for
instant, rough Monte Carlo.
3. **Model seats.** Run `phase-server` with `TYPESAFE_API_URL` at Laya (or at a fine-tuned one later) and the OpenAI-compatible seat at Olla; compare
heuristic-only, LLM and Laya seats. Record every decision for the Laya dataset.
4. **Only then** consider the playtester's UI ideas, after the author adds a licence or as a clean reimplementation.
## What was built from this plan (2026-10-27)
1. **The headless runner** (`ai-commander`, Commander four-player, batch mode) is built from the v0.118.0 tag with the card database that release was published with, in a
container (`farmhouse/phase/Dockerfile`). Not `ai-duel` for the decks: `ai-commander` takes a feed JSON of four decks, which is exactly what the app has.
2. **The service and the app side**: `farmhouse/phase/phase_sim.py` (HTTP, jobs, parser, warnings) and `routes/farmhouse_phase.py` + `ui/phase-sim.html`. Games that stall, hit the
action cap or panic are reported and never counted.
3. **Model seats**: not a setting. phase's model support lives only in its browser client (`engine-wasm` calls `phase-llm`; the HTTP is done by TypeScript), and
`phase-llm` depends on `phase-ai`, so the headless binary cannot use it. `farmhouse/phase/llm-seat/` is a new crate that includes the runner and installs a hook
(two anchored edits to phase's source). The server-side Jev relay and the browser client were not needed. Details: `farmhouse/phase/README.md`.
Not done: the playtester's UI (no licence), a Python client for phase's WebSocket server (a large, fast-moving protocol), feeding the captured model-seat decisions into
`scripts/farmhouse_laya_dataset.py` (the natural next step: each is an engine-issued option list plus a pick).
## Risks and open questions
- Phase is a one-person, fast-moving project: pin tags, keep a smoke test that plays a full game, expect breaking changes.
- Rust builds are heavy; keep them in CI/containers. Resource and image size are unmeasured.
- Card coverage is parse-level; correctness per card is unmeasured. Unsupported cards must be flagged in any result.
- The phase server and its AI use CPU; its measured game speed is unknown (the README says a game is thousands of AI decisions).
- Legal: phase is Apache/MIT with Wizards fan-content wording; the playtester has no licence. The card data is MTGJSON (MIT).
- Not yet read: phase's WebSocket protocol, how `--commander-suite` accepts custom decks, and whether the server can run unattended self-play.
+785
View File
@@ -0,0 +1,785 @@
# Commander-AI-Lab — Unity TCGEngine Commander Board: Implementation Plan (v5)
> **Architecture Principle:** Forge = Rules Authority · FastAPI = Hub · TCGEngine Unity = Renderer · Dual-Brain AI = Decision Engine
---
## System Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ UNITY (TCGEngine — 2D Board) │
│ PlayerArea x4: Hand | Library | Graveyard | Exile | Command │
│ Battlefield zones per player · Card art from local cache │
│ GameNetworkManager.cs ←── WebSocket ──► FastAPI │
└─────────────────────────────────────────────────────────────────┘
▲ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FASTAPI (commander-ai-lab) │
│ /ws/game — WebSocket relay │
│ /game/action — receive player/AI action, forward to Forge │
│ ForgeSubprocessManager — stdin/stdout pipe to Forge JVM │
│ CardEnricher — injects art_url from local image cache │
│ AIOrchestrator — routes decisions to DeepSeek-R1 or GPT-OSS │
│ ChromaDB RAG — card knowledge scoped per player deck │
│ ai_decisions.jsonl — logs every AI decision (PPO dataset) │
└─────────────────────────────────────────────────────────────────┘
▲ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FORGE (Java — Headless Rules Engine) │
│ Full Commander rules: tax, 21-commander-damage, 40 life │
│ 4-player turn queue · Zone redirects · State-based actions │
│ Emits game state JSON to stdout · Receives actions via stdin │
└─────────────────────────────────────────────────────────────────┘
```
---
## Dual-Brain AI Decision Flow
This is the **core feature** of Commander-AI-Lab. Every AI player decision runs through this pipeline:
```
Forge emits GameStateDTO (JSON)
└─► FastAPI detects active player is AI
└─► select_brain(phase, stack_size)
├─ DeepSeek-R1-14B → combat, stack decisions, removal
└─ GPT-OSS-20B → main phase, ramp, card advantage
└─► ChromaDB.query(player_deck_cards, top_k=5)
└─► build_prompt(state, rag_context, personality)
└─► LLM.generate(prompt)
└─► parse_action_json(response)
├─ validate vs legal_actions[]
├─ log to ai_decisions.jsonl
└─► Forge stdin ← chosen action
```
### Brain Selector (`ai/brain_selector.py`)
```python
from enum import Enum
class Brain(Enum):
DEEPSEEK = "deepseek-r1:14b"
GPT_OSS = "gpt-oss-20b" # adjust to your Ollama model tag
def select_brain(phase: str, stack_size: int) -> Brain:
"""
DeepSeek-R1: combat math, instant-speed stack decisions, targeted removal.
GPT-OSS: sorcery-speed strategy, ramp sequencing, card selection.
"""
combat_phases = {"declare_attackers", "declare_blockers", "combat_damage"}
if phase in combat_phases or stack_size > 0:
return Brain.DEEPSEEK
return Brain.GPT_OSS
```
### Prompt Builder (`ai/prompt_builder.py`)
```python
from chromadb import Client
from .personality import get_personality_prompt
chroma = Client()
card_collection = chroma.get_collection("mtg_cards")
def build_prompt(state: dict, player_id: str, personality: str) -> str:
# Pull card knowledge scoped to THIS player's deck
player_cards = [c["name"] for c in state["players"][player_id]["library"]]
rag_results = card_collection.query(
query_texts=player_cards[:10],
n_results=5
)
card_context = "\n".join(rag_results["documents"][0])
personality_prompt = get_personality_prompt(personality)
return f"""You are an AI playing Magic: The Gathering Commander.
{personality_prompt}
## Current Game State
- Phase: {state['phase']}
- Active Player: {player_id}
- Life Totals: {state['life_totals']}
- Commander Damage Dealt: {state['commander_damage']}
- Stack: {state['stack']}
- Your Hand: {[c['name'] for c in state['players'][player_id]['hand']]}
- Battlefield: {[c['name'] for c in state['players'][player_id]['battlefield']]}
- Available Mana: {state['players'][player_id]['mana_pool']}
## Legal Actions
{state['legal_actions']}
## Card Knowledge (RAG Context)
{card_context}
Respond with a single JSON object:
{{"action": "<action_type>", "card_id": "<id_or_null>", "targets": [], "reasoning": "<brief>"}}
"""
```
### Personality System (`ai/personality.py`)
```python
import random
PERSONALITIES = {
"aggro_beatdown": "You play aggressively. Attack every turn. Remove blockers. Win through combat damage as fast as possible.",
"control_lockdown": "You play control. Counter key threats. Hold up mana for interaction. Win only when the board is locked.",
"combo_enabler": "You dig for your combo pieces. Every decision is evaluated by how it advances your win condition.",
"political_diplomat": "You form alliances. Attack the leader. Make deals. Never be the archenemy.",
"voltron_commander": "Everything buffs your commander. Protect it at all costs. Win through commander damage.",
"stax_prison": "You tax and restrict opponents. Deploy hate pieces. Win through attrition and resource denial.",
"tempo_value": "You trade efficiently. Generate card advantage. Win by being ahead on resources.",
"chaos_wildcard": "You make unpredictable decisions. Prioritize fun and chaos over optimal play.",
}
def assign_personalities(player_ids: list[str], deck_archetypes: dict) -> dict[str, str]:
"""
Assign a personality per AI player at game creation.
80% chance: archetype-matched personality.
20% chance: random wildcard for unpredictability.
"""
assignments = {}
for pid in player_ids:
archetype = deck_archetypes.get(pid, "tempo_value")
if random.random() < 0.20:
assignments[pid] = random.choice(list(PERSONALITIES.keys()))
else:
assignments[pid] = archetype
return assignments
def get_personality_prompt(personality: str) -> str:
return f"## Your Playstyle\n{PERSONALITIES.get(personality, PERSONALITIES['tempo_value'])}"
```
### AI Orchestrator (`ai/ai_orchestrator.py`)
```python
import json, time, logging
from pathlib import Path
from .brain_selector import select_brain, Brain
from .prompt_builder import build_prompt
import ollama
LOG_PATH = Path("logs/ai_decisions.jsonl")
LOG_PATH.parent.mkdir(exist_ok=True)
async def get_ai_action(state: dict, player_id: str, personality: str) -> dict:
brain = select_brain(state["phase"], len(state["stack"]))
prompt = build_prompt(state, player_id, personality)
t_start = time.time()
try:
response = ollama.chat(
model=brain.value,
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0.3, "num_predict": 256}
)
raw = response["message"]["content"]
# Extract JSON from response (model may wrap in markdown)
action = json.loads(raw[raw.find("{"):raw.rfind("}")+1])
except Exception as e:
logging.warning(f"AI parse failed ({brain.value}): {e}. Passing turn.")
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "parse_error"}
# Validate against Forge legal actions
if action.get("action") not in [a["type"] for a in state["legal_actions"]]:
logging.warning(f"Illegal action returned by {brain.value}. Falling back to pass.")
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "illegal_action"}
# Log for PPO training dataset
log_entry = {
"timestamp": time.time(),
"player_id": player_id,
"brain": brain.value,
"personality": personality,
"phase": state["phase"],
"action": action,
"reasoning": action.get("reasoning", ""),
"latency_ms": round((time.time() - t_start) * 1000),
}
with open(LOG_PATH, "a") as f:
f.write(json.dumps(log_entry) + "\n")
return action
```
---
## Phase 0 — Local Card Image Cache
**Build this first.** Every downstream system depends on local art URLs.
### Step 1 — Bulk Download (`scripts/download_card_images.py`)
```python
import requests, json, os
from pathlib import Path
from tqdm import tqdm
BULK_URL = "https://api.scryfall.com/bulk-data/default-cards"
IMAGE_DIR = Path("static/card-images")
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
def download_images():
# Get bulk data URL
meta = requests.get(BULK_URL).json()
cards = requests.get(meta["download_uri"], stream=True).json()
for card in tqdm(cards, desc="Downloading card images"):
image_uris = card.get("image_uris") or {}
url = image_uris.get("normal")
if not url:
continue # skip DFCs without top-level image_uris
safe_name = card["name"].replace("/", "_").replace(" ", "_")
set_code = card.get("set", "unknown")
filename = IMAGE_DIR / set_code / f"{safe_name}_{card['collector_number']}.jpg"
filename.parent.mkdir(parents=True, exist_ok=True)
if filename.exists():
continue # already cached
img_data = requests.get(url, timeout=10).content
filename.write_bytes(img_data)
if __name__ == "__main__":
download_images()
```
### Step 2 — Build SQLite Index (`scripts/build_image_index.py`)
```python
import sqlite3
from pathlib import Path
IMAGE_DIR = Path("static/card-images")
DB_PATH = Path("data/card_images.db")
DB_PATH.parent.mkdir(exist_ok=True)
def build_index():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS card_images (
name TEXT PRIMARY KEY,
path TEXT NOT NULL
)
""")
for img_path in IMAGE_DIR.rglob("*.jpg"):
card_name = img_path.stem.rsplit("_", 1)[0].replace("_", " ")
conn.execute("INSERT OR REPLACE INTO card_images VALUES (?, ?)",
(card_name, str(img_path)))
conn.commit()
conn.close()
if __name__ == "__main__":
build_index()
```
### Step 3 — Card Enricher (`api/card_enricher.py`)
```python
import sqlite3
from pathlib import Path
DB_PATH = Path("data/card_images.db")
STATIC_URL = "http://localhost:8000/static/card-images"
def enrich_card(card: dict) -> dict:
"""Inject local art_url into every CardDTO before sending to Unity."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.execute("SELECT path FROM card_images WHERE name = ?", (card["name"],))
row = cursor.fetchone()
conn.close()
if row:
relative = Path(row[0]).relative_to("static/card-images")
card["art_url"] = f"{STATIC_URL}/{relative}"
else:
card["art_url"] = f"{STATIC_URL}/card_back.jpg"
return card
```
### Step 4 — FastAPI Static Mount (`main.py` addition)
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static/card-images", StaticFiles(directory="static/card-images"), name="card-images")
```
---
## Phase 1 — Forge Bridge (Rules Engine)
### ForgeSubprocessManager (`forge/subprocess_manager.py`)
```python
import subprocess, json, asyncio, threading
from pathlib import Path
FORGE_JAR = Path("forge/forge-game.jar")
class ForgeSubprocessManager:
def __init__(self):
self.process = None
self._lock = threading.Lock()
def start(self, deck_paths: list[str], format: str = "commander"):
cmd = [
"java", "-jar", str(FORGE_JAR),
"--headless",
"--format", format,
"--decks", *deck_paths,
"--output", "json"
]
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
def read_state(self) -> dict:
with self._lock:
line = self.process.stdout.readline()
return json.loads(line)
def send_action(self, action: dict):
with self._lock:
self.process.stdin.write(json.dumps(action) + "\n")
self.process.stdin.flush()
def stop(self):
if self.process:
self.process.terminate()
```
### Data Transfer Objects (`api/dtos.py`)
```python
from pydantic import BaseModel
from typing import Optional
class CardDTO(BaseModel):
id: str
name: str
type: str
power: Optional[str]
toughness: Optional[str]
mana_cost: str
art_url: str = ""
tapped: bool = False
zone: str = "hand"
class PlayerDTO(BaseModel):
id: str
life: int
commander_damage: dict[str, int]
commander_tax: int
hand: list[CardDTO]
battlefield: list[CardDTO]
graveyard: list[CardDTO]
exile: list[CardDTO]
command_zone: list[CardDTO]
library_count: int
mana_pool: dict[str, int]
is_ai: bool
personality: Optional[str]
class GameStateDTO(BaseModel):
game_id: str
phase: str
active_player: str
priority: str
turn_number: int
players: list[PlayerDTO]
stack: list[dict]
legal_actions: list[dict]
winner: Optional[str]
```
---
## Phase 2 — FastAPI WebSocket Game Server
### Game Loop (`api/game_loop.py`)
```python
import asyncio, uuid
from .dtos import GameStateDTO
from forge.subprocess_manager import ForgeSubprocessManager
from ai.ai_orchestrator import get_ai_action
from ai.personality import assign_personalities
from api.card_enricher import enrich_card
active_games: dict[str, dict] = {}
async def start_game(deck_paths: list[str], ai_players: list[str]) -> str:
game_id = str(uuid.uuid4())
forge = ForgeSubprocessManager()
forge.start(deck_paths)
# Assign AI personalities at game creation
archetypes = detect_archetypes(deck_paths, ai_players) # ChromaDB query
personalities = assign_personalities(ai_players, archetypes)
active_games[game_id] = {
"forge": forge,
"ai_players": ai_players,
"personalities": personalities,
"websockets": []
}
asyncio.create_task(game_loop(game_id))
return game_id
async def game_loop(game_id: str):
game = active_games[game_id]
forge = game["forge"]
while True:
raw_state = forge.read_state()
if raw_state.get("winner"):
await broadcast(game_id, raw_state)
break
# Enrich every card with local art_url
state = enrich_state(raw_state)
await broadcast(game_id, state)
priority_player = state["priority"]
if priority_player in game["ai_players"]:
personality = game["personalities"][priority_player]
action = await get_ai_action(state, priority_player, personality)
forge.send_action(action)
# else: wait for Unity client to send action via WebSocket
async def broadcast(game_id: str, state: dict):
game = active_games[game_id]
for ws in game["websockets"]:
await ws.send_json(state)
def enrich_state(state: dict) -> dict:
for player in state["players"]:
for zone in ["hand", "battlefield", "graveyard", "exile", "command_zone"]:
player[zone] = [enrich_card(c) for c in player.get(zone, [])]
return state
```
### WebSocket Endpoint (`api/routes/game.py`)
```python
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.game_loop import active_games, start_game
router = APIRouter()
@router.websocket("/ws/game/{game_id}")
async def game_ws(websocket: WebSocket, game_id: str):
await websocket.accept()
game = active_games.get(game_id)
if not game:
await websocket.close(code=4004)
return
game["websockets"].append(websocket)
try:
while True:
data = await websocket.receive_json()
# Human player action forwarded directly to Forge
game["forge"].send_action(data)
except WebSocketDisconnect:
game["websockets"].remove(websocket)
```
---
## Phase 3 — Unity TCGEngine Board (2D)
### Project Layout
```
unity-commander-ui/
├── Assets/
│ ├── TCGEngine/ ← TCGEngine base (unmodified)
│ ├── Commander/
│ │ ├── Scripts/
│ │ │ ├── GameNetworkManager.cs ← WebSocket ↔ FastAPI
│ │ │ ├── CommanderBoardUI.cs ← 4-player layout controller
│ │ │ ├── PlayerAreaUI.cs ← per-player zone panel
│ │ │ ├── CardImageLoader.cs ← local cache texture fetcher
│ │ │ ├── ZonePanel.cs ← Hand/Library/Graveyard/Exile/Command
│ │ │ └── AIThinkingIndicator.cs ← spinner while AI brain is deciding
│ │ └── Prefabs/
│ │ ├── CardPrefab.prefab
│ │ ├── PlayerAreaPrefab.prefab
│ │ └── ZonePanelPrefab.prefab
│ └── Scenes/
│ └── CommanderBoard.unity
```
### Board Layout (matches screenshot)
```
┌──────────────────────────────────────────────────────────────────┐
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 3) │
│ [COMMAND] │
├──────────────────────────────┬───────────────────────────────────┤
│ │ │
│ │ │
│ BATTLEFIELD │ BATTLEFIELD │
│ (Player 1) │ (Player 2) │
│ BATTLEFIELD (center) │
│ BATTLEFIELD │ BATTLEFIELD │
│ (Player 4) │ (Player 3) │
│ │ │
├──────────────────────────────┴───────────────────────────────────┤
│ [COMMAND] │
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 1) │
└──────────────────────────────────────────────────────────────────┘
```
### GameNetworkManager.cs
```csharp
using UnityEngine;
using NativeWebSocket;
using Newtonsoft.Json;
public class GameNetworkManager : MonoBehaviour
{
public static GameNetworkManager Instance { get; private set; }
[SerializeField] private string serverUrl = "ws://localhost:8000/ws/game/";
[SerializeField] private string gameId;
private WebSocket _socket;
public delegate void GameStateReceived(GameStateDTO state);
public event GameStateReceived OnGameStateReceived;
async void Awake()
{
Instance = this;
_socket = new WebSocket($"{serverUrl}{gameId}");
_socket.OnMessage += (bytes) =>
{
var json = System.Text.Encoding.UTF8.GetString(bytes);
var state = JsonConvert.DeserializeObject<GameStateDTO>(json);
UnityMainThreadDispatcher.Enqueue(() => OnGameStateReceived?.Invoke(state));
};
await _socket.Connect();
}
public async void SendAction(object action)
{
var json = JsonConvert.SerializeObject(action);
await _socket.SendText(json);
}
void Update() => _socket?.DispatchMessageQueue();
async void OnDestroy() => await _socket?.Close();
}
```
### CardImageLoader.cs
```csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
public class CardImageLoader : MonoBehaviour
{
private static Dictionary<string, Texture2D> _cache = new();
public static IEnumerator LoadCardArt(string artUrl, RawImage target)
{
if (_cache.TryGetValue(artUrl, out var cached))
{
target.texture = cached;
yield break;
}
using var req = UnityWebRequestTexture.GetTexture(artUrl);
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success)
{
var tex = DownloadHandlerTexture.GetContent(req);
_cache[artUrl] = tex;
target.texture = tex;
}
else
{
Debug.LogWarning($"Failed to load card art: {artUrl}");
}
}
}
```
### AIThinkingIndicator.cs
```csharp
using UnityEngine;
using UnityEngine.UI;
/// Shows a spinner + brain label while the AI is deciding.
public class AIThinkingIndicator : MonoBehaviour
{
[SerializeField] private GameObject spinnerPanel;
[SerializeField] private Text brainLabel;
[SerializeField] private Text personalityLabel;
public void Show(string brain, string personality)
{
spinnerPanel.SetActive(true);
brainLabel.text = $"🧠 {brain}";
personalityLabel.text = $"🎭 {personality}";
}
public void Hide() => spinnerPanel.SetActive(false);
}
```
---
## Phase 4 — Commander Zone UIs
### Zone Definitions per Player
| Zone Panel | Content | Notes |
|---|---|---|
| `LIBRARY` | Count only | Click → draw/look at top |
| `GRAVEYARD` | Scrollable card list | Full card art on hover |
| `EXILE` | Scrollable card list | Includes face-down indicators |
| `HAND` | Wide panel, card art | Hidden to opponents |
| `COMMAND` | Commander card(s) | Shows commander tax counter |
| `BATTLEFIELD` | Open grid | Tapped/untapped state, counters |
### Commander-Specific UI Elements
```csharp
public class CommanderZoneUI : MonoBehaviour
{
[SerializeField] private Text taxCounter; // "Tax: +{2N}"
[SerializeField] private RawImage commanderArt;
[SerializeField] private Text commanderDamageDealt; // per opponent
public void Refresh(PlayerDTO player)
{
int tax = player.CommanderTax;
taxCounter.text = tax > 0 ? $"+{tax * 2}" : "";
if (player.CommandZone.Count > 0)
StartCoroutine(CardImageLoader.LoadCardArt(
player.CommandZone[0].ArtUrl, commanderArt));
}
}
```
---
## Phase 5 — Commander Rules Validation Layer
Forge handles all rules natively. FastAPI validates the AI response is within the legal actions list Forge returns — no custom rules code needed in Python or Unity.
Key Commander rules confirmed handled by Forge headless:
- **Commander Tax** — +2 generic per previous cast from command zone
- **21 Commander Damage** — tracked per attacker/defender pair
- **40 Starting Life**
- **Zone Redirect** — commander goes to command zone instead of graveyard/exile (player choice)
- **Color Identity** — deck validation at game start
- **Singleton** — deck validation at game start
---
## Phase 6 — AI Decision Logging & PPO Pipeline
Every AI decision written to `logs/ai_decisions.jsonl`:
```json
{
"timestamp": 1743520800.123,
"player_id": "player_2",
"brain": "deepseek-r1:14b",
"personality": "aggro_beatdown",
"phase": "declare_attackers",
"action": {"action": "attack", "card_id": "c_042", "targets": ["player_1"], "reasoning": "Open attack, no blockers visible"},
"latency_ms": 847
}
```
This log becomes the **supervised learning dataset** for the next PPO training iteration. Each (state, action) pair from winning games is a positive training example.
---
## Project Directory Structure
```
commander-ai-lab/
├── main.py ← FastAPI app, mounts static files
├── api/
│ ├── dtos.py ← Pydantic DTOs
│ ├── card_enricher.py ← Injects art_url into CardDTOs
│ ├── game_loop.py ← Main game orchestration loop
│ └── routes/
│ └── game.py ← WebSocket + REST endpoints
├── ai/
│ ├── ai_orchestrator.py ← Main AI decision entry point
│ ├── brain_selector.py ← DeepSeek-R1 vs GPT-OSS routing
│ ├── prompt_builder.py ← Game state + RAG → prompt
│ └── personality.py ← 8 personalities + assignment
├── forge/
│ └── subprocess_manager.py ← Forge JVM stdin/stdout bridge
├── scripts/
│ ├── download_card_images.py ← One-time Scryfall bulk download
│ └── build_image_index.py ← SQLite name→path index
├── static/
│ └── card-images/ ← ~2GB local image cache
│ └── {set_code}/
│ └── {CardName}_{num}.jpg
├── data/
│ └── card_images.db ← SQLite image index
├── logs/
│ └── ai_decisions.jsonl ← PPO training dataset
├── unity-commander-ui/ ← Unity 2D TCGEngine project
└── docs/
└── implementation_plan.md ← This document
```
---
## Phased Timeline
| Phase | Scope | Est. Time |
|---|---|---|
| **0** | Scryfall image download + SQLite index + FastAPI static mount | 1 day |
| **1** | Forge subprocess bridge + DTO definitions | 2–3 days |
| **2** | AI Orchestrator + brain selector + prompt builder + personality system | 3–4 days |
| **3** | Unity TCGEngine project setup + WebSocket GameNetworkManager | 3–4 days |
| **4** | PlayerAreaUI + ZonePanels + CardImageLoader + board layout | 3–4 days |
| **5** | Commander zone UI (tax counter, commander damage, zone redirect) | 1–2 days |
| **6** | AI decision logging + end-to-end game loop test | 1–2 days |
**Total estimated: ~3 weeks to first playable Commander game with live AI opponents.**
---
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Forge = rules authority | Forge already handles all Commander rules natively. No custom rules code. |
| TCGEngine = dumb renderer | All game logic lives in Forge. Unity only renders state it receives. |
| FastAPI = hub | Bridges Forge ↔ Unity ↔ AI. Single point of truth. |
| Dual-brain routing | DeepSeek-R1 excels at analytical/math decisions; GPT-OSS at strategic sequencing. |
| ChromaDB scoped per player | RAG context relevant to the active player's deck, not global card knowledge. |
| Personality persists per game | Consistent playstyle per AI across all 4 players per session. |
| Local image cache | Eliminates network latency for card art. ~2GB one-time download. |
| ai_decisions.jsonl | Every AI decision is a future PPO training sample. |
+32
View File
@@ -0,0 +1,32 @@
# Forge in the cluster image
The batch simulator on the Simulator page runs on **Forge**, the Java rules engine. The first cluster builds left it out by mistake ("Python-only"
was read as "no Java"), so the page answered `Forge JAR not found`. It is now built into the image (reference copy of the Dockerfile stage:
`infrastructure/apps/commander-lab/image/Dockerfile` in the farmhouse-os repo).
What the image build does:
1. **Stage 1** (`maven:3.9-eclipse-temurin-17`): fetch the official `Card-Forge/forge` at commit `edb28158f6d8` (the commit `KoalaTrapLord/Forge-Repo`
is on; that fork is 0 commits ahead of upstream, so this is the code the lab's Java was written against; newer Forge may have changed APIs), then
`mvn install -pl forge-gui-desktop -am -DskipTests -Drevision=2.0.12-SNAPSHOT`, retried up to three times (Maven Central downloads fail now and then).
2. **The lab's Java tool** (`LabCli`, from this repository's `pom.xml`) is built against it. Forge's installed poms keep `${revision}` as their parent
version, which Maven cannot resolve for a consumer even with `-Drevision`; the placeholder is rewritten in the installed `.pom` files first.
3. **Runtime**: the full `openjdk-17-jre` (not `-headless`) plus `xvfb`, the two jars, and Forge's `res/` data without `adventure`, `music`, `sound`, `quest` and `skins`
(about 190 MB the headless simulator never reads). The environment the app reads: `FORGE_JAR`, `FORGE_DIR`, `LAB_JAR`, `FORGE_DECKS_DIR`, `PRECON_DIR`, `JAVA_HOME`.
4. `FORGE_MAX_THREADS` (default 1) clamps the batch's thread count: every Forge game is its own JVM with up to a 4 GB heap, and the cluster has two small nodes.
5. CI starts the app, begins a batch through the app's own `/api/lab/start` and requires a Commander game with turns played and a readable `/api/lab/result` before the image is published.
## Things that only showed up by running it in the cluster (2026-10-27)
Each of these made a batch fail while every individual piece looked installed. Together they are why the CI test starts a batch through the app's own API.
- **Forge needs a display, even for `sim`.** `GuiDesktop`'s static initialiser asks AWT for the default screen device; the headless JRE throws `HeadlessException`, and
`forge.view.Main` then exits 1 with no output at all (the lab tool records every game as an instant draw: "Forge produced NO output"). Fix: the full `openjdk-17-jre`,
`xvfb`, `DISPLAY=:99`, and the entrypoint starts `Xvfb :99`.
- **Three folders, one deck.** The app writes saved decks to `USER_DECKS_DIR`, the lab's Java tool resolves deck files through `PRECON_DECKS_DIR`, and Forge finds a deck BY
NAME in its own commander folder ("Could not load deck"). All three are the Forge folder (`FORGE_DECKS_DIR`), which the entrypoint seeds with the 163 bundled precons.
- **Writable folders.** The tool writes `results/` beside Forge's data and Forge a debug log beside its jar; both folders are owned by uid 10001 in the image.
- **Result path.** The app asked for `results/ml-decision-<id>.json` (relative) but ran the tool from Forge's folder, so the file landed there and `/api/lab/result` answered
"Result file not found". The path is made absolute (patch `forge-results-abs`) and `/app/results` is linked to `/state/results`.
- `docker cp` cannot write into a tmpfs and Docker mounts `/tmp` `noexec` (it stops SQLite's native library loading): test-harness traps, not cluster problems.
Speed: about 25 seconds per game, one at a time (the project's own figure is ~0.04 simulations/s single-threaded). For many games with real rules, the
phase-rs engine (`farmhouse/phase/`) is far faster. Forge is GPL-3.0: private use, do not redistribute the built image.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Keep the app's state on the persistent volume (/state) while the code stays in the image.
# The upstream app writes next to its code: collection.db, scryfall_cache.db, data/ (Scryfall bulk DB, ChromaDB index),
# user-decks/. Point each at /state with a symlink; seed data/ and user-decks/ from the image the first time.
set -eu
S="${STATE_DIR:-/state}"
mkdir -p "$S"
for d in data user-decks results; do
if [ ! -e "$S/$d" ]; then
if [ -d "/app/$d" ]; then cp -a "/app/$d" "$S/$d"; else mkdir -p "$S/$d"; fi
fi
rm -rf "/app/$d"
ln -s "$S/$d" "/app/$d"
done
for f in collection.db scryfall_cache.db; do
rm -f "/app/$f"
ln -s "$S/$f" "/app/$f" # sqlite creates the target on first open
done
# Forge looks decks up by name in its user folder; the app exports the decks it simulates there.
mkdir -p "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"
# Seed it with the bundled precon decks (163 small files); never overwrite a deck that is already there.
if [ -d /app/precon-decks ]; then cp -n /app/precon-decks/*.dck "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"/ 2>/dev/null || true; fi
# A virtual display for Forge (it initialises AWT even in command-line mode; see the Dockerfile). Best effort: the app itself does not need it.
if command -v Xvfb >/dev/null 2>&1; then
mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix 2>/dev/null || true
Xvfb "${DISPLAY:-:99}" -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] && break
sleep 0.3
done
fi
exec "$@"
+34
View File
@@ -0,0 +1,34 @@
# Laya (https://github.com/NandhaKishorM/laya, Apache-2.0): a "decision model". It answers typed multiple-choice / yes-no / score
# questions about a piece of text in ONE forward pass (about 33 ms on a GPU) and never writes text. Here it is staged for the Commander
# AI Lab simulator, which asks an LLM "which action?" about 25 times per game; a small classifier can answer that class of question
# far faster, if it is accurate enough (it is measured in shadow mode first, see ../README.md).
#
# CPU only. The weights are baked in at a pinned Hugging Face commit and the pod has NO network access (NetworkPolicy), so the
# running service fetches nothing. Two checkpoints are included so they can be compared: the English base and the fine-tuned
# "typed-decisions" one (about 840 MB each).
FROM python:3.12-slim-bookworm
ARG LAYA_VERSION=0.4.15
ARG LAYA_HF_REVISION=7b928d828b7b0e022f929d9bd2e44165aa270148
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONUNBUFFERED=1 HF_HOME=/opt/hf
RUN pip install torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install "laya[serve]==${LAYA_VERSION}"
# Weights at the pinned commit. HF's offline lookup resolves the "main" ref, which a commit-pinned download does not create, so it is
# written by hand to the same commit.
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('convaiinnovations/laya', revision='${LAYA_HF_REVISION}', ignore_patterns=['multilingual/*','assets/*','eval/*'])" \
&& mkdir -p /opt/hf/hub/models--convaiinnovations--laya/refs \
&& printf '%s' "${LAYA_HF_REVISION}" > /opt/hf/hub/models--convaiinnovations--laya/refs/main \
&& chmod -R a+rX /opt/hf
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin laya
USER 10001
ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 \
LAYA_HOST=0.0.0.0 LAYA_PORT=8080 \
LAYA_MODELS=english,typed-decisions LAYA_DEFAULT_MODEL=english LAYA_MAX_LOADED=2 LAYA_PRELOAD=1 LAYA_THREADS=4
EXPOSE 8080
CMD ["laya-serve"]
+52
View File
@@ -0,0 +1,52 @@
# Laya as the simulator's "which action?" model: evaluation, 2026-10-26
**Result: not usable as shipped.** Both checkpoints agreed with the LLM less often than a model that always answers the commonest action.
## What was measured
The simulator's LLM brain ("twin-turbo") picks one of nine actions about 25 times a game. A decision model (Laya, or Kev; both serve
`POST /v1/systemone`) could answer that kind of question in one forward pass instead of a 7-second generation. To find out whether it
could stand in, every LLM decision the app had recorded (`/app/logs/decisions/*.jsonl`: the game state and the action the LLM chose)
was replayed through Laya with the same question, and the answers compared. The LLM is the *teacher*, not the truth: this measures
whether a fast model can imitate the slow one, not which plays better.
Script: `image/farmhouse_decision_eval.py` (in the app image as `/app/scripts/farmhouse_decision_eval.py`):
`DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py /app/logs/decisions english typed-decisions`
## Numbers (149 recorded decisions)
LLM's choices: play_land 41, hold 31, attack_all 28, cast_creature 20, cast_spell 13, attack_safe 7, cast_removal 5, cast_ramp 4.
| | agrees with the LLM | answers | time per answer | what it picked |
|---|---|---|---|---|
| always "play_land" (baseline) | **27.5%** | 149 | 0 | |
| `english` (ModernBERT-large, base) | 16.1% | 149 | 1.9 s | cast_creature 105, attack_all 26; never hold, almost never play_land |
| `typed-decisions` (fine-tuned) | 18.6% | 140 (9 timed out at 5 s) | ~4 s (shared CPU) | cast_creature 99, attack_all 26 |
It could say "cast_creature" and "attack_all" and little else; it never recognised "hold" (31 of the LLM's choices) or a removal spell.
## Why, and caveats
- The shipped checkpoints are trained on generic ticket-triage style decisions. Laya's own README says fine-tuning "on decisions from your
own domain is where accuracy jumps" (0.766 against 0.362 for the base on its own benchmark). This game's decisions were never in its training.
- Speed on this cluster's CPU (4 threads) was 2-4 s per answer, not the ~33 ms quoted for a GPU. Against the LLM's ~7.5 s that is not the
speed-up that was hoped for either, without a GPU.
- The question wording (the nine action descriptions) and the compact text it reads (`farmhouse_decide.compact_state`) are mine; a
better prompt might help a little, not from 18% to usable.
- Part of the recorded data predates the card-data fix (v10), so some states had thinner hands than they would now.
- The LLM being the "truth" also caps the result: where the LLM is wrong, agreeing with it is not a win.
## Where this leaves it
- Laya is **staged but scaled to 0 replicas** (`deployment-laya.yaml`: `replicas: 0`; set to 1 to run it). The image
(`ghcr.io/fez5587/farmhouse-laya:v1`, offline, weights baked in), the service and the network policy remain.
- The app is wired to use it (`DECISION_URL`, `DECISION_MODE`) and `DECISION_MODE` is `off`. `shadow` records agreement without
affecting games; `assist` uses it when it is `DECISION_MIN_CONF` sure. Kev speaks the same API, so swapping is a URL.
- To make it worth using: fine-tune on this game's decisions (Laya ships a Kaggle 2xT4 notebook, about 4 hours, and an Apple/CPU
script). The labels would be the LLM's recorded decisions; a few thousand are needed, so generate them first (LLM games at ~7 s a
decision, or label states with the engine's own heuristic for the cheap, abundant version). Then re-run this evaluation.
- Node memory: it needed about 2 GB (3.6 GB with both checkpoints, which pushed the control-plane node to 92%; it is pinned to the other node).
## Later the same day: the new connections (see docs/LAYA-CONNECTIONS.md in the fork)
- **Forge-shaped priority choice** (CSIE's ranking in the text, three scenarios): agreed with CSIE each time, 42-48% sure, 7.5-8.4 s per call on the CPU against Forge's
1.5 s budget. Not usable on a CPU; the code falls back silently.
- **Card roles from Oracle text** (150 unambiguous cards, 6 roles present, 9 offered): 48% against 17% by chance (counterspell 76%, board wipe 64%, removal 40%, tutor 40%,
ramp 36%, card draw 32%), 3.2 s per card in batches. Much better than the action choice; useful as on-demand evidence for the cards in a hand, too slow to tag the whole pool.
- **Extra questions** in the same call: "threat" said *high* for everything; "must answer" gave 0.48 for a lethal stack against 0.25-0.29 otherwise (right direction, weak).
- Capture, dataset export and the rest are built and off by default; fine-tuning on the captured decisions is the path to making it useful.
+21
View File
@@ -0,0 +1,21 @@
# Laya in this repository
Laya (https://github.com/NandhaKishorM/laya, Apache-2.0) is a **decision model**: it answers typed multiple-choice, yes/no and score
questions about a piece of text in one forward pass and never writes text. Here it is a fast second opinion beside the LLM and CSIE.
Kev (https://github.com/jaredpalmer/kev) serves the same `POST /v1/systemone` API, so swapping is a URL change (Kev needs a GPU).
| File | What |
|---|---|
| `Dockerfile` | CPU image: `laya[serve]` 0.4.15 + CPU torch, English and `typed-decisions` checkpoints baked in at Hugging Face commit `7b928d8`; runs offline |
| `deployment-laya.yaml` | Kubernetes Deployment + Service + NetworkPolicy (no egress; only the app may call it). Currently `replicas: 0` |
| `build-farmhouse-laya.workflow.yml` | the CI workflow that builds and smoke-tests the image offline (it lives in `.github/workflows/` of the farmhouse-os GitOps repo) |
| `LAYA-EVALUATION.md` | why it is off: zero-shot both checkpoints agreed with the LLM less often than always guessing the commonest action |
The deployment source of truth is the farmhouse-os repo (`infrastructure/apps/commander-lab/`); these are reference copies so the
fork is self-contained. Things learned running it:
- A Kubernetes Service named `laya` injects `LAYA_PORT=tcp://...`, which `laya-serve` reads as its own port setting and refuses. Set
`enableServiceLinks: false` and `LAYA_PORT=8080` explicitly.
- Two checkpoints resident use 3.6 GB; one uses about 1.9 GB. Do not place it on a control-plane node.
- The CPU answers in 2-4 s, not the ~33 ms quoted for a GPU.
- How the code in this repository connects to it, and how to fine-tune it: `docs/LAYA-CONNECTIONS.md`.
@@ -0,0 +1,96 @@
name: Build FarmHouse Laya
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "infrastructure/apps/commander-lab/laya/**"
- ".github/workflows/build-farmhouse-laya.yml"
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/farmhouse-laya
# Bump together with the image tag in infrastructure/apps/commander-lab/deployment-laya.yaml.
IMAGE_TAG: v1
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set image name lowercase
shell: bash
run: |
echo "IMAGE=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build validation image
uses: docker/build-push-action@v6
with:
context: infrastructure/apps/commander-lab/laya
load: true
push: false
tags: laya:test
# The service must answer with NO network (that is how it runs in the cluster) as the unprivileged user, for both checkpoints.
- name: Smoke test (boots offline, answers a game-state question with both checkpoints)
shell: bash
run: |
set -euo pipefail
docker run -d --name laya --network none laya:test
cat > /tmp/ask.py <<'PY'
import json, sys, time, urllib.request
model = sys.argv[1]
body = {"model": model,
"state": {"body": "Turn 4. You have 4 lands untapped. Hand: Llanowar Elves, Swords to Plowshares, Forest. Opponent has a 6/6 creature attacking you."},
"questions": {"action": {"type": "choice", "instructions": "Which action is best this turn?",
"criteria": {"cast_creature": "put a creature onto the battlefield",
"cast_removal": "destroy or exile an opposing permanent",
"play_land": "play a land",
"hold": "do nothing this turn"}}}}
req = urllib.request.Request("http://127.0.0.1:8080/v1/systemone", data=json.dumps(body).encode(), headers={"content-type": "application/json"})
t = time.time()
out = json.load(urllib.request.urlopen(req, timeout=120))
print(model, "%.0f ms" % ((time.time() - t) * 1000), json.dumps(out)[:600])
assert "answers" in out and "action" in out["answers"], out
PY
docker cp /tmp/ask.py laya:/tmp/ask.py
ok=0
for i in $(seq 1 120); do
if docker exec laya python -c "import socket; socket.create_connection(('127.0.0.1', 8080), 2)" 2>/dev/null; then ok=1; break; fi
sleep 3
done
if [ "$ok" != 1 ]; then echo "laya did not open its port"; docker logs laya | tail -60; exit 1; fi
for m in english typed-decisions; do docker exec laya python /tmp/ask.py "$m"; done
docker exec laya id
docker rm -f laya >/dev/null
echo SMOKE OK
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: infrastructure/apps/commander-lab/laya
push: true
tags: |
${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
${{ env.IMAGE }}:${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
+93
View File
@@ -0,0 +1,93 @@
# Laya, a CPU decision model (laya/Dockerfile), staged for the simulator. Reached only by the app, over the cluster network, on 8080.
# No internet, no volume (weights are in the image), no service-account token.
apiVersion: apps/v1
kind: Deployment
metadata:
name: laya
namespace: commander-lab
labels: {app: laya}
spec:
# 0 since 2026-10-26: both checkpoints scored BELOW the always-guess baseline against the LLM's recorded decisions (English 16.1%, typed-decisions
# 18.6%, baseline 27.5%, 149 states), and ~2 GB of the node's memory was needed for the card-search index build. Set to 1 to bring it back
# (image, service and policy stay in place); it is only worth running once it is fine-tuned on this game's decisions (README).
replicas: 0
selector:
matchLabels: {app: laya}
template:
metadata:
labels: {app: laya}
spec:
automountServiceAccountToken: false
# Kubernetes injects LAYA_PORT=tcp://<ip>:8080 for a Service named laya, and laya-serve reads LAYA_PORT as its own port setting
# ("invalid LAYA_PORT 'tcp://...'", crash loop). Service-link variables are not wanted here at all.
enableServiceLinks: false
# NOT on talos-gmw: that node runs the Kubernetes API server, and Laya's first placement there (two checkpoints, 3.6 GB) took it from
# 68% to 92% memory on 2026-10-26.
nodeSelector: {kubernetes.io/hostname: talos-noy-0r4}
imagePullSecrets:
- name: ghcr-pull
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: laya
image: ghcr.io/fez5587/farmhouse-laya:v1
ports:
- {name: http, containerPort: 8080}
env:
- {name: LAYA_THREADS, value: "4"}
- {name: LAYA_PORT, value: "8080"}
# One checkpoint only (about 1.9 GB): the English base scored below the always-guess baseline against the LLM; typed-decisions is the
# fine-tuned one still being measured.
- {name: LAYA_MODELS, value: "typed-decisions"}
- {name: LAYA_DEFAULT_MODEL, value: "typed-decisions"}
- {name: LAYA_MAX_LOADED, value: "1"}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
requests: {cpu: "500m", memory: 2500Mi}
limits: {cpu: "4", memory: 3Gi}
# Loading two checkpoints takes a while; the port opens when the server is up.
startupProbe:
tcpSocket: {port: http}
periodSeconds: 5
failureThreshold: 90
readinessProbe:
tcpSocket: {port: http}
periodSeconds: 10
livenessProbe:
tcpSocket: {port: http}
periodSeconds: 30
failureThreshold: 6
---
apiVersion: v1
kind: Service
metadata:
name: laya
namespace: commander-lab
spec:
type: ClusterIP
selector: {app: laya}
ports:
- {name: http, port: 8080, targetPort: http}
---
# IN: only the app. OUT: nothing at all (the weights are in the image; it needs no DNS and calls no one).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: laya
namespace: commander-lab
spec:
podSelector:
matchLabels: {app: laya}
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels: {app: commander-lab}
ports:
- {protocol: TCP, port: 8080}
egress: []
+61
View File
@@ -0,0 +1,61 @@
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
#
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
#
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
FROM rust:slim-bookworm AS build
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
ARG PHASE_TAG=v0.118.0
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
mkdir /phase; cd /phase; \
git init -q; \
git remote add origin "$PHASE_REPO"; \
git fetch -q --depth 1 origin "$PHASE_SHA"; \
git checkout -q FETCH_HEAD; \
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
WORKDIR /phase
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
ENV CARGO_BUILD_JOBS=2 \
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
RUN set -eux; \
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
FROM python:3.12-slim-bookworm
ARG PHASE_TAG=v0.118.0
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
COPY --from=build /card-data.json /opt/phase/data/card-data.json
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
COPY phase_sim.py phase_smoke.py /opt/phase/
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
PYTHONUNBUFFERED=1 PORT=8090
USER 10001
WORKDIR /tmp
EXPOSE 8090
CMD ["python", "/opt/phase/phase_sim.py"]
+72
View File
@@ -0,0 +1,72 @@
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
#
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
#
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
FROM rust:slim-bookworm AS build
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
ARG PHASE_TAG=v0.118.0
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
mkdir /phase; cd /phase; \
git init -q; \
git remote add origin "$PHASE_REPO"; \
git fetch -q --depth 1 origin "$PHASE_SHA"; \
git checkout -q FETCH_HEAD; \
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
WORKDIR /phase
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
ENV CARGO_BUILD_JOBS=2 \
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
# ---- model seats (this Dockerfile only) -------------------------------------------------------------------------------------------
# Everything above is identical to Dockerfile, so the long compile above comes from the build cache. These steps add `ai-commander-llm`: the
# same runner with a hook that lets a language model or a decision model (Laya / Kev) choose for named seats among the engine's OWN legal
# options. Two anchored edits to phase's source (apply_phase_patches.py: the build stops if phase moved either line) and one new crate.
# phase-llm depends on phase-ai, so the hook cannot live in phase-ai's binary; the new crate depends on both and includes the runner.
COPY llm-seat /llm-seat
RUN python3 /llm-seat/apply_phase_patches.py /phase /llm-seat
RUN cargo build --profile server-release -p farmhouse-llm-seat --bin ai-commander-llm
RUN set -eux; \
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
FROM python:3.12-slim-bookworm
ARG PHASE_TAG=v0.118.0
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
COPY --from=build /phase/target/server-release/ai-commander-llm /opt/phase/ai-commander-llm
COPY --from=build /card-data.json /opt/phase/data/card-data.json
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
COPY phase_sim.py phase_smoke.py /opt/phase/
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
PHASE_AI_COMMANDER_LLM=/opt/phase/ai-commander-llm \
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
PYTHONUNBUFFERED=1 PORT=8090
USER 10001
WORKDIR /tmp
EXPOSE 8090
CMD ["python", "/opt/phase/phase_sim.py"]
+36
View File
@@ -0,0 +1,36 @@
# phase-sim: rules-accurate Commander games with phase-rs
Reference copies of what runs in the cluster (source of truth: `infrastructure/apps/commander-lab/` in the farmhouse-os repo). Background and the assessment
that led here: `docs/PHASE-AND-PLAYTESTER.md`.
| File | What |
|---|---|
| `phase_sim.py` | the service: stdlib HTTP, one job at a time. `POST /run` four decks -> `GET /job/<id>` per-game results, wins by seat, 95% interval, warnings. Optional model seats and two loopback-only relays (`/llm/v1/chat/completions`, `/jev/systemone`) |
| `Dockerfile` | builds phase-rs `v0.118.0` (`ai-commander`, `ai-duel`) and takes the card database that release was published with (SHA-256 checked) |
| `Dockerfile.llm` | the same plus `ai-commander-llm`: model-driven seats |
| `llm-seat/` | the Rust crate and the two anchored edits to phase's source that make model seats possible |
| `phase_smoke.py`, `test_phase_sim.py` | CI: real games through the service; 49 offline checks against a stub engine |
| `deployment-phase.yaml` | Deployment, Service and a NetworkPolicy (in: the app only; out: DNS, Olla and Laya only) |
The app side is `routes/farmhouse_phase.py` (four saved decks -> the service) and `ui/phase-sim.html`; both are real source in this repository, registered by
`lab_api.py` and linked from the home page.
## Model seats: how they work and why they are safe
phase's headless runner has no model support; the model seats exist only in phase's browser client. `phase-llm` (the crate) deliberately does no networking:
it renders the engine's option list into a prompt, builds the HTTP request, and parses the reply, and the host performs the call. `llm-seat/` is that host
for the headless runner:
1. `apply_phase_patches.py` adds a process-wide `ACTION_OVERRIDE` hook in `phase-ai/src/auto_play.rs` (asked before the built-in AI chooses) and makes the runner's `main` `pub`.
2. The crate `farmhouse-llm-seat` includes the runner's source as a module (`#[path]`) and installs the hook. `phase-llm` depends on `phase-ai`, so the hook cannot
live inside phase-ai's own binary.
3. For each decision on a model seat: the engine issues its option list (`AiDecisionContract`); phase-llm builds the prompt (a forced move with one option is never asked);
this crate makes the HTTP call (blocking, no TLS, 20 s limit); phase-llm turns the reply into one of the engine's options; the choice is checked against the contract
here and again by `run_ai_action`. **Any** problem returns "no opinion" and phase's own AI plays the move. A cap on model calls per run (default 300) bounds the cost.
4. The service supplies the endpoints through its own loopback-only relays: the chat relay switches the model's thinking off (twin-turbo otherwise thinks for tens of
seconds a move) and caps the answer; the decision relay unwraps phase's `Jev` envelope and forwards the System One request to Laya or Kev.
The model never authors an action and never sees hidden cards beyond what phase-llm renders for that seat.
## Status
Written 2026-10-27. The service logic is tested offline; the Rust crate has not compiled outside CI when this was written (no Rust toolchain on the workstation), so
its first CI run is the first real test. Check `.github/workflows/build-farmhouse-phase-sim.yml` results in the farmhouse-os repo.
+111
View File
@@ -0,0 +1,111 @@
# phase-sim (phase/Dockerfile): rules-accurate four-player Commander games over a small HTTP API. Only the app may call it; it has no
# network and no volume (engine and card data are in the image). One game at a time, CPU heavy.
apiVersion: apps/v1
kind: Deployment
metadata:
name: phase-sim
namespace: commander-lab
labels: {app: phase-sim}
spec:
replicas: 1
selector:
matchLabels: {app: phase-sim}
template:
metadata:
labels: {app: phase-sim}
spec:
automountServiceAccountToken: false
# A Service named like the app injects PHASE_SIM_* variables into the pod; nothing here wants them (the same kind of collision
# crash-looped Laya on LAYA_PORT).
enableServiceLinks: false
imagePullSecrets:
- name: ghcr-pull
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: phase-sim
image: ghcr.io/fez5587/farmhouse-phase-sim:v1-llm # -llm = the baseline plus ai-commander-llm (model seats)
ports:
- {name: http, containerPort: 8090}
env:
- {name: PORT, value: "8090"}
- {name: PHASE_SIM_MAX_GAMES, value: "50"}
- {name: PHASE_SIM_TIMEOUT, value: "3600"}
# Model seats. The engine talks to THIS service's loopback-only relays, which forward to these (see phase_sim.py): chat = an
# OpenAI-style endpoint (Olla -> twin-turbo; the relay switches thinking off and caps the answer), decision = a System One server
# (Laya / Kev; the deployment is scaled to 0 until it is fine-tuned, so a decision-seat run answers 502 and falls back to phase's AI).
- {name: PHASE_LLM_CHAT_URL, value: "http://olla.ai-system.svc.cluster.local:40114/olla/openai/v1"}
- {name: PHASE_LLM_DECISION_URL, value: "http://laya.commander-lab.svc.cluster.local:8080/v1/systemone"}
- {name: PHASE_LLM_REASONING, value: "none"}
- {name: PHASE_LLM_MAX_TOKENS_CAP, value: "256"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: tmp, mountPath: /tmp}
resources:
requests: {cpu: "500m", memory: 1Gi}
limits: {cpu: "3", memory: 3Gi}
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 30
failureThreshold: 6
volumes:
- name: tmp
emptyDir: {sizeLimit: 512Mi}
---
apiVersion: v1
kind: Service
metadata:
name: phase-sim
namespace: commander-lab
spec:
type: ClusterIP
selector: {app: phase-sim}
ports:
- {name: http, port: 8090, targetPort: http}
---
# IN: only the app. OUT: DNS and the two model endpoints the relays use (Olla for the chat model, Laya for the decision model); nothing else.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: phase-sim
namespace: commander-lab
spec:
podSelector:
matchLabels: {app: phase-sim}
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels: {app: commander-lab}
ports:
- {protocol: TCP, port: 8090}
egress:
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kube-system}
podSelector:
matchLabels: {k8s-app: kube-dns}
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: ai-system}
podSelector:
matchLabels: {app.kubernetes.io/name: olla}
ports:
- {protocol: TCP, port: 40114}
- to:
- podSelector:
matchLabels: {app: laya}
ports:
- {protocol: TCP, port: 8080}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "farmhouse-llm-seat"
version.workspace = true
edition = "2021"
license.workspace = true
publish = false
# FarmHouse: phase's headless four-player Commander runner (`ai-commander`) with MODEL-DRIVEN SEATS. The runner's own source is included
# as a module (src/main.rs, #[path]); this crate adds only the network transport phase-llm deliberately leaves to the host. phase-llm
# depends on phase-ai, so the hook cannot live inside phase-ai's own binary: a new crate that depends on both is the way round that.
[[bin]]
name = "ai-commander-llm"
path = "src/main.rs"
[dependencies]
engine = { path = "../engine", package = "phase-engine" }
phase-ai = { path = "../phase-ai" }
phase-llm = { path = "../phase-llm" }
serde = { workspace = true }
serde_json = "1"
rand = "0.9"
tracing = "0.1"
mimalloc = "0.1"
# Blocking HTTP, no TLS: the model endpoints are cluster-internal http:// relays (phase-sim's own /llm and /jev routes).
ureq = { version = "2", default-features = false }
@@ -0,0 +1,74 @@
"""Apply the FarmHouse edits to a phase-rs checkout (run in the Docker build, from the repository root).
python apply_phase_patches.py <phase-checkout> <llm-seat-crate-dir>
1. crates/phase-ai/src/auto_play.rs: a process-wide ACTION_OVERRIDE hook, asked before the built-in AI chooses. It returns Option<GameAction>;
None (the default, and what every failure returns) leaves phase's own choice untouched. The chosen action still goes through the same
contract check (`contract.permits`) as before.
2. crates/phase-ai/src/bin/ai_commander.rs: `fn main` becomes `pub fn main` so the new crate can include and call it.
3. The new crate is copied into crates/farmhouse-llm-seat (the workspace takes every crates/* member).
Every anchor must match exactly once or the build stops, so a phase update that moves a line is reported here, not in a compile error."""
import os
import shutil
import sys
root, crate = sys.argv[1], sys.argv[2]
def edit(rel, edits):
path = os.path.join(root, rel)
text = open(path, encoding="utf-8", newline="").read()
for old, new in edits:
n = text.count(old)
if n != 1:
raise SystemExit("%s: anchor found %d times (need 1): %r" % (rel, n, old[:80]))
text = text.replace(old, new)
open(path, "w", encoding="utf-8", newline="").write(text)
print("patched", rel)
HOOK = '''use crate::search::choose_action_with_session;
/// FarmHouse: an optional process-wide override, asked BEFORE the built-in AI chooses for a seat. A host (see crates/farmhouse-llm-seat)
/// installs one to let a model pick among the engine's own options. It returns None to leave the choice to the built-in AI, which is also
/// what it must return on any failure; whatever it returns still has to pass the same decision-contract check below.
pub type ActionOverride = Box<
dyn Fn(
&engine::types::game_state::GameState,
&engine::ai_support::AiDecisionContract,
engine::types::player::PlayerId,
engine::types::player::PlayerId,
) -> Option<engine::types::actions::GameAction>
+ Send
+ Sync,
>;
static ACTION_OVERRIDE: std::sync::OnceLock<ActionOverride> = std::sync::OnceLock::new();
/// Install the override. Returns false if one is already installed (it can be set once per process).
pub fn install_action_override(hook: ActionOverride) -> bool {
ACTION_OVERRIDE.set(hook).is_ok()
}
'''
edit("crates/phase-ai/src/auto_play.rs", [
("use crate::search::choose_action_with_session;\n", HOOK),
(" let action = match choose_action_with_session(state, semantic_owner, config, rng, session) {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None",
" // The override's pick must be one the engine will really APPLY, not only one it lists: a model chose both modes of a modal spell whose\n"
" // entwine cost it could not pay, the contract listed the option, apply refused it, and the whole game stalled (2026-10-27). Try it on a\n"
" // copy of the game first; anything the engine refuses is dropped and the built-in AI plays the move. (Stack-pass rechecks have their\n"
" // own verified path and are left alone.)\n"
" let farmhouse_choice = ACTION_OVERRIDE\n .get()\n .and_then(|hook| hook(&*state, &contract, semantic_owner, actor))\n"
" .filter(|candidate| {\n"
" verified_ai_stack_pass_player(&*state, candidate).is_some()\n"
" || apply_interaction(&mut (*state).clone(), actor, semantic_owner, candidate.clone()).is_ok()\n"
" });\n"
" let action = match farmhouse_choice\n .or_else(|| choose_action_with_session(state, semantic_owner, config, rng, session))\n {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None"),
])
edit("crates/phase-ai/src/bin/ai_commander.rs", [("\nfn main() {\n", "\npub fn main() {\n")])
dest = os.path.join(root, "crates", "farmhouse-llm-seat")
os.makedirs(os.path.join(dest, "src"), exist_ok=True)
shutil.copy(os.path.join(crate, "Cargo.toml"), os.path.join(dest, "Cargo.toml"))
shutil.copy(os.path.join(crate, "src", "main.rs"), os.path.join(dest, "src", "main.rs"))
print("added crates/farmhouse-llm-seat")
+237
View File
@@ -0,0 +1,237 @@
//! ai-commander with model-driven seats (FarmHouse).
//!
//! phase's headless runner plays four-player Commander games with the built-in AI on every seat. This binary is that same runner (its
//! source is included below, unchanged but for `fn main` being `pub`) plus a hook, installed before the games start, that asks a
//! language model or a decision model (Laya / Kev, over the System One protocol) to choose for the seats named in `PHASE_LLM_SEATS`.
//!
//! The model never authors an action. For each decision the engine issues its own option list (`AiDecisionContract`), phase-llm renders
//! it and builds the HTTP request, this file performs the HTTP call (the one thing phase-llm leaves to the host), and phase-llm turns the
//! reply into one of the engine's options. The pick is then checked against the contract a second time here, and again by
//! `run_ai_action`. ANY problem (forced move, timeout, an unreadable or stale answer, the call cap) returns `None` and phase's own AI
//! plays the move, so a model that is down, slow or wrong cannot stall or corrupt a game.
//!
//! Settings (environment): PHASE_LLM_SEATS "1,3" (seats 0-3; empty = none, the plain runner), PHASE_LLM_PROVIDER (OpenAiCompatible | Jev),
//! PHASE_LLM_BASE_URL, PHASE_LLM_MODEL, PHASE_LLM_API_KEY (leave empty for http:// endpoints), PHASE_LLM_MAX_TOKENS (256),
//! PHASE_LLM_TEMPERATURE (0.2), PHASE_LLM_TIMEOUT_S (20), PHASE_LLM_MAX_CALLS (total model calls for the whole process, default 300),
//! PHASE_LLM_DIFFICULTY (Medium: the strength hint in the prompt), PHASE_LLM_MAX_FAILS (4: model failures IN A ROW before every model seat
//! switches itself off for the rest of the process).
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use engine::ai_support::AiDecisionContract;
use engine::database::CardDatabase;
use engine::types::actions::GameAction;
use engine::types::game_state::GameState;
use engine::types::player::PlayerId;
use phase_ai::auto_play::install_action_override;
use phase_ai::config::AiDifficulty;
use phase_llm::{LlmEndpointConfig, LlmProvider, LlmReply};
// The unchanged runner, with `fn main` made `pub` by apply_phase_patches.py.
#[path = "../../phase-ai/src/bin/ai_commander.rs"]
#[allow(dead_code, unused_imports, unused_variables)]
mod commander;
struct Seats {
seats: HashSet<u8>,
endpoint: LlmEndpointConfig,
difficulty: AiDifficulty,
agent: ureq::Agent,
cards_path: PathBuf,
db: OnceLock<Option<CardDatabase>>,
max_calls: u64,
asked: [AtomicU64; 4],
used: [AtomicU64; 4],
fell_back: [AtomicU64; 4],
total: AtomicU64,
// Cost visibility and the circuit-breaker. A late-game prompt is thousands of tokens and a big-model server needs longer than the
// per-call limit to read it; every such call used to burn the whole limit (a 9-minute game, 25 questions, 1 answer used). After
// `max_fails` failures IN A ROW the model seats switch themselves off for the rest of the process and phase's AI plays on.
timeouts: AtomicU64,
consecutive_fails: AtomicU64,
max_fails: u64,
disabled: AtomicBool,
millis: AtomicU64,
answered: AtomicU64,
prompt_chars: AtomicU64,
}
impl Seats {
fn database(&self) -> Option<&CardDatabase> {
self.db
.get_or_init(|| CardDatabase::from_export(&self.cards_path.join("card-data.json")).ok())
.as_ref()
}
/// The model's pick for `actor`, or None to let phase's AI play. Never panics on a bad answer.
/// One failed model call (timeout, error status, unusable answer). Returns None so callers can `return self.failed(..)`.
fn failed(&self, timeout: bool) -> Option<GameAction> {
if timeout {
self.timeouts.fetch_add(1, Ordering::Relaxed);
}
let in_a_row = self.consecutive_fails.fetch_add(1, Ordering::Relaxed) + 1;
if in_a_row >= self.max_fails && !self.disabled.swap(true, Ordering::Relaxed) {
println!(
"LLM-SEAT-DISABLED after {in_a_row} consecutive failures ({} timeouts of {} questions): phase's own AI plays the rest",
self.timeouts.load(Ordering::Relaxed),
self.total.load(Ordering::Relaxed)
);
}
None
}
fn decide(&self, state: &GameState, contract: &AiDecisionContract, actor: PlayerId) -> Option<GameAction> {
if self.disabled.load(Ordering::Relaxed) {
return None;
}
let seat = (actor.0 as usize).min(3);
// A forced move (one legal option) is never put to a model: build_game_decision_prompt refuses it.
let request = phase_llm::build_game_decision_prompt(
state,
contract,
self.difficulty,
self.database(),
&Vec::new(),
)
.ok()?;
if self.total.fetch_add(1, Ordering::Relaxed) >= self.max_calls {
return None; // the call cap: every later decision is phase's AI
}
self.asked[seat].fetch_add(1, Ordering::Relaxed);
self.prompt_chars.fetch_add(request.prompt.char_count() as u64, Ordering::Relaxed);
let http = phase_llm::build_chat_request(&self.endpoint, &request.prompt).ok()?;
let mut call = self.agent.request(http.method, &http.url);
for header in &http.headers {
call = call.set(&header.name, &header.value);
}
let started = Instant::now();
let (status, body) = match call.send_string(&http.body) {
Ok(response) => {
let status = response.status();
match response.into_string() {
Ok(text) => (status, text),
Err(_) => return self.failed(true),
}
}
// A 4xx/5xx still carries a body phase-llm knows how to classify.
Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()),
Err(_) => return self.failed(true), // transport error: a timeout, or the endpoint is down
};
self.millis.fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed);
self.answered.fetch_add(1, Ordering::Relaxed);
let selection = match phase_llm::select_action_from_response(
state,
contract,
&request.fingerprint,
LlmReply { provider: self.endpoint.provider, status, body: &body },
) {
Ok(selection) => selection,
Err(_) => return self.failed(false),
};
if !contract.contains_action(state, &selection.action) {
return self.failed(false);
}
self.consecutive_fails.store(0, Ordering::Relaxed);
self.used[seat].fetch_add(1, Ordering::Relaxed);
Some(selection.action)
}
fn report(&self, seat: usize) {
let asked = self.asked[seat].load(Ordering::Relaxed);
if asked > 0 && asked % 25 == 0 {
let answered = self.answered.load(Ordering::Relaxed).max(1);
let questions = self.total.load(Ordering::Relaxed).max(1);
println!(
"LLM-SEAT seat={seat} asked={asked} used={} total_calls={} timeouts={} avg_ms={} avg_prompt_chars={}",
self.used[seat].load(Ordering::Relaxed),
self.total.load(Ordering::Relaxed),
self.timeouts.load(Ordering::Relaxed),
self.millis.load(Ordering::Relaxed) / answered,
self.prompt_chars.load(Ordering::Relaxed) / questions
);
}
}
}
fn env_or(name: &str, default: &str) -> String {
std::env::var(name).ok().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| default.to_string())
}
fn install_model_seats() {
let seats: HashSet<u8> = env_or("PHASE_LLM_SEATS", "")
.split(',')
.filter_map(|s| s.trim().parse::<u8>().ok())
.filter(|s| *s < 4)
.collect();
if seats.is_empty() {
println!("LLM seats: none (plain ai-commander)");
return;
}
let provider = LlmProvider::from_label(&env_or("PHASE_LLM_PROVIDER", "OpenAiCompatible"));
let endpoint = LlmEndpointConfig {
provider,
base_url: std::env::var("PHASE_LLM_BASE_URL").ok().filter(|v| !v.trim().is_empty()),
api_key: env_or("PHASE_LLM_API_KEY", ""),
model: env_or("PHASE_LLM_MODEL", "model"),
max_output_tokens: env_or("PHASE_LLM_MAX_TOKENS", "256").parse().ok(),
temperature: env_or("PHASE_LLM_TEMPERATURE", "0.2").parse().ok(),
};
let timeout = env_or("PHASE_LLM_TIMEOUT_S", "20").parse::<u64>().unwrap_or(20);
// The runner's first positional argument is the data folder (card-data.json lives there).
let cards_path = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| ".".to_string()));
let state = Seats {
seats: seats.clone(),
endpoint,
difficulty: AiDifficulty::from_label(&env_or("PHASE_LLM_DIFFICULTY", "Medium")),
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(timeout)).redirects(0).build(),
cards_path,
db: OnceLock::new(),
max_calls: env_or("PHASE_LLM_MAX_CALLS", "300").parse().unwrap_or(300),
asked: Default::default(),
used: Default::default(),
fell_back: Default::default(),
total: AtomicU64::new(0),
timeouts: AtomicU64::new(0),
consecutive_fails: AtomicU64::new(0),
max_fails: env_or("PHASE_LLM_MAX_FAILS", "4").parse().unwrap_or(4),
disabled: AtomicBool::new(false),
millis: AtomicU64::new(0),
answered: AtomicU64::new(0),
prompt_chars: AtomicU64::new(0),
};
println!(
"LLM seats: {:?} provider={} model={} max_calls={}",
{
let mut v: Vec<u8> = seats.iter().copied().collect();
v.sort();
v
},
provider.label(),
state.endpoint.model,
state.max_calls
);
let installed = install_action_override(Box::new(move |game, contract, _owner, actor| {
if !state.seats.contains(&actor.0) {
return None;
}
let seat = (actor.0 as usize).min(3);
let pick = state.decide(game, contract, actor);
if pick.is_none() {
state.fell_back[seat].fetch_add(1, Ordering::Relaxed);
}
state.report(seat);
pick
}));
if !installed {
eprintln!("LLM seats: an action override was already installed; model seats are off");
}
}
fn main() {
install_model_seats();
commander::main();
}
+501
View File
@@ -0,0 +1,501 @@
"""phase-sim: run rules-accurate four-player Commander games with phase-rs's headless `ai-commander` and report what happened.
Standard library only. One job runs at a time (a game is CPU-heavy); more wait in a queue. The service never touches the network.
GET /health {"ok": true, "engine": ..., "queued": n, "running": id|null}
POST /run {"decks": [4 x {"name", "commander": ["Card"], "main": [{"name","count"}, ...]}],
"games": 1..MAX_GAMES, "seed": int, "difficulty": "Medium", "seat_difficulty": {"2": "Hard"}, "action_cap": int}
-> {"job": id}
GET /job/<id> status queued|running|done|error, games finished, and when done: per-game rows, wins by seat and by deck,
win rate with a 95% interval, the cards the engine could not resolve, and the tail of the engine's output
Seat i is the i-th deck in the request (P0 = first). The engine treats every seat as AI; difficulty is per seat if given.
A game that stalls, hits the action cap, or panics is reported as such and is NEVER counted as a win or a draw.
Model seats (optional, needs the image's ai-commander-llm binary): "llm_seats": [1, 3] and "llm": {"kind": "chat" | "decision", "model": "...",
"max_calls": 300}. Those seats ask a model to pick among the ENGINE'S OWN legal options (phase-llm builds the prompt and checks the answer;
anything wrong or slow falls back to phase's AI). kind "chat" goes to PHASE_LLM_CHAT_URL (an OpenAI-style endpoint, e.g. Olla) through this
service's /llm relay, which switches the model's thinking off and caps its answer; kind "decision" goes to PHASE_LLM_DECISION_URL (a System One
server: Laya or Kev) through /jev/systemone. Both relays accept connections from this machine only.
"""
import json
import math
import os
import re
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import uuid
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("PHASE_AI_COMMANDER", "/opt/phase/ai-commander")
CARD_DATA = os.environ.get("PHASE_CARD_DATA", "/opt/phase/data/card-data.json")
ENGINE = os.environ.get("PHASE_ENGINE_VERSION", "phase-rs (version unknown)")
MAX_GAMES = int(os.environ.get("PHASE_SIM_MAX_GAMES", "50"))
JOB_TIMEOUT = int(os.environ.get("PHASE_SIM_TIMEOUT", "3600"))
PORT = int(os.environ.get("PORT", "8090"))
DIFFICULTIES = ("VeryEasy", "Easy", "Medium", "Hard", "VeryHard")
BIN_LLM = os.environ.get("PHASE_AI_COMMANDER_LLM", "/opt/phase/ai-commander-llm")
CHAT_URL = os.environ.get("PHASE_LLM_CHAT_URL", "").rstrip("/") # e.g. http://olla...:40114/olla/openai/v1
DECISION_URL = os.environ.get("PHASE_LLM_DECISION_URL", "") # e.g. http://laya...:8080/v1/systemone
DECISION_KEY = os.environ.get("PHASE_LLM_DECISION_KEY", "")
CHAT_REASONING = os.environ.get("PHASE_LLM_REASONING", "none")
CHAT_MAX_TOKENS = int(os.environ.get("PHASE_LLM_MAX_TOKENS_CAP", "256"))
MAX_LLM_CALLS = int(os.environ.get("PHASE_LLM_MAX_CALLS", "600"))
LOCAL_ONLY = ("127.0.0.1", "::1")
# A late-game prompt is thousands of tokens; reading it takes a 27B model on a V100 20-30 s. The engine waits MODEL_TIMEOUT per question and the
# relay a little less, and the engine switches the model seats off after PHASE_LLM_MAX_FAILS failures in a row, so a dead model costs a few minutes at most.
RELAY_TIMEOUT = float(os.environ.get("PHASE_LLM_RELAY_TIMEOUT", "55"))
MODEL_TIMEOUT = os.environ.get("PHASE_LLM_TIMEOUT_S", "60")
MAX_DECK_ENTRIES = 400
_jobs = {}
_queue = deque()
_cv = threading.Condition()
_running = {"id": None}
# ---------- request -> engine inputs ------------------------------------------------------------------------------------------
class BadRequest(ValueError):
pass
def validate(req):
decks = req.get("decks")
if not isinstance(decks, list) or len(decks) != 4:
raise BadRequest("decks must be a list of exactly 4 decks (one per seat)")
feed = []
for i, d in enumerate(decks):
if not isinstance(d, dict) or not str(d.get("name") or "").strip():
raise BadRequest("deck %d needs a name" % i)
cmd = d.get("commander")
if isinstance(cmd, str):
cmd = [cmd]
if not isinstance(cmd, list) or not cmd or not all(isinstance(c, str) and c.strip() for c in cmd):
raise BadRequest("deck %d needs commander: [\"Card Name\"]" % i)
main = d.get("main")
if main is None and isinstance(d.get("cards"), list):
counts = {}
for n in d["cards"]:
counts[str(n)] = counts.get(str(n), 0) + 1
main = [{"name": k, "count": v} for k, v in counts.items()]
if not isinstance(main, list) or not main or len(main) > MAX_DECK_ENTRIES:
raise BadRequest("deck %d needs a non-empty main list (at most %d entries)" % (i, MAX_DECK_ENTRIES))
rows = []
for e in main:
try:
name, count = str(e["name"]), int(e["count"])
except (KeyError, TypeError, ValueError):
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
if not name.strip() or not 1 <= count <= 99:
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
rows.append({"name": name, "count": count})
feed.append({"name": str(d["name"]), "commander": [c.strip() for c in cmd], "main": rows})
try:
games = int(req.get("games", 1))
seed = int(req.get("seed", 1))
except (TypeError, ValueError):
raise BadRequest("games and seed must be integers")
if not 1 <= games <= MAX_GAMES:
raise BadRequest("games must be 1 to %d" % MAX_GAMES)
difficulty = req.get("difficulty", "Medium")
if difficulty not in DIFFICULTIES:
raise BadRequest("difficulty must be one of %s" % ", ".join(DIFFICULTIES))
seat_diff = {}
for k, v in (req.get("seat_difficulty") or {}).items():
if str(k) not in ("0", "1", "2", "3") or v not in DIFFICULTIES:
raise BadRequest("seat_difficulty keys are seats 0-3, values one of %s" % ", ".join(DIFFICULTIES))
seat_diff[int(k)] = v
cap = req.get("action_cap")
if cap is not None:
try:
cap = int(cap)
except (TypeError, ValueError):
raise BadRequest("action_cap must be an integer")
if not 100 <= cap <= 200000:
raise BadRequest("action_cap must be 100 to 200000")
llm = None
seats = req.get("llm_seats") or []
if seats:
if not isinstance(seats, list) or not all(isinstance(x, int) and 0 <= x <= 3 for x in seats):
raise BadRequest("llm_seats must be a list of seats 0-3")
cfg = req.get("llm") or {}
kind = cfg.get("kind", "chat")
if kind not in ("chat", "decision"):
raise BadRequest("llm.kind must be chat or decision")
if (kind == "chat" and not CHAT_URL) or (kind == "decision" and not DECISION_URL):
raise BadRequest("this service has no %s model endpoint configured" % kind)
if not os.path.isfile(BIN_LLM) and not os.environ.get("PHASE_SIM_ALLOW_MISSING_LLM_BIN"):
raise BadRequest("this image has no model-seat engine (ai-commander-llm)")
model = str(cfg.get("model") or "").strip()
if kind == "chat" and not model:
raise BadRequest("llm.model is required for a chat model")
try:
max_calls = int(cfg.get("max_calls", 300))
except (TypeError, ValueError):
raise BadRequest("llm.max_calls must be an integer")
if not 1 <= max_calls <= MAX_LLM_CALLS:
raise BadRequest("llm.max_calls must be 1 to %d" % MAX_LLM_CALLS)
llm = {"seats": sorted(set(seats)), "kind": kind, "model": model or "decision-model", "max_calls": max_calls}
return {"feed": feed, "games": games, "seed": seed, "difficulty": difficulty, "seat_difficulty": seat_diff, "action_cap": cap, "llm": llm}
def engine_env(spec):
"""The environment for the engine process: model-seat settings, or None (inherit) for the plain runner."""
llm = spec.get("llm")
if not llm:
return None
env = dict(os.environ)
local = "http://127.0.0.1:%d" % PORT
env.update(PHASE_LLM_SEATS=",".join(str(x) for x in llm["seats"]), PHASE_LLM_MODEL=llm["model"], PHASE_LLM_API_KEY="",
PHASE_LLM_MAX_CALLS=str(llm["max_calls"]), PHASE_LLM_DIFFICULTY=spec["difficulty"], PHASE_LLM_TIMEOUT_S=MODEL_TIMEOUT)
if llm["kind"] == "chat":
env.update(PHASE_LLM_PROVIDER="OpenAiCompatible", PHASE_LLM_BASE_URL=local + "/llm/v1")
else:
env.update(PHASE_LLM_PROVIDER="Jev", PHASE_LLM_BASE_URL=local)
return env
def command(spec, root):
cmd = [BIN_LLM if spec.get("llm") else BIN, root, "--feed", "feed.json", "--games-file", os.path.join(root, "games.txt")]
for seat, level in sorted(spec["seat_difficulty"].items()):
cmd += ["--difficulty-p%d" % seat, level]
if spec["action_cap"]:
cmd += ["--action-cap", str(spec["action_cap"])]
return cmd
# ---------- engine output -> results ------------------------------------------------------------------------------------------
GAME_HEAD = re.compile(r"^--- GAME\b(.*)$")
WINNER = re.compile(r"Game ended cleanly\. Winner: (draw|P(\d))")
TURNS = re.compile(r"^Turns played: (\d+)")
ELIM = re.compile(r"^ELIMINATED: P(\d)\s+turn=(\d+)")
RESOLVED = re.compile(r"^\s+P(\d)\s+main=\s*(\d+)\s+commander=(\d+)")
LLM_STAT = re.compile(r"^LLM-SEAT seat=(\d) asked=(\d+) used=(\d+)(?: total_calls=(\d+))?(?: timeouts=(\d+))?(?: avg_ms=(\d+))?(?: avg_prompt_chars=(\d+))?")
LLM_OFF = re.compile(r"^LLM-SEAT-DISABLED after (\d+) consecutive failures")
SKIP = re.compile(r"^\s+SKIP (.+?): commander '(.+)' not in card db")
PRE = re.compile(r"^\s+(.+?)\s+\|\s+commander: (.+?)\s+\|\s+main: (\d+) cards")
def parse(output):
"""Per-game rows and the deck-resolution facts from the engine's stdout. Games that did not finish are kept, labelled, never counted."""
lines = output.splitlines()
pre, resolved, skipped, games, cur = [], {}, [], [], None
def finish():
if cur is not None:
games.append(cur)
def new_game(label=""):
return {"label": label, "status": "unfinished", "winner_seat": None, "turns": None, "eliminated": []}
result_line = re.compile(r"Game ended cleanly|^Turns played|^ELIMINATED:|^STALL:|ABORT: hit action cap|PANICKED")
for ln in lines:
if cur is None and not games: # the preamble: decks listed, then their resolved sizes
m = PRE.match(ln)
if m:
pre.append({"deck": m.group(1), "commander": m.group(2), "listed": int(m.group(3))})
continue
m = SKIP.match(ln)
if m:
skipped.append({"deck": m.group(1), "commander": m.group(2)})
continue
m = RESOLVED.match(ln)
if m and int(m.group(1)) not in resolved:
resolved[int(m.group(1))] = {"main": int(m.group(2)), "commanders": int(m.group(3))}
continue
if GAME_HEAD.match(ln):
finish()
cur = new_game(GAME_HEAD.match(ln).group(1).strip(" -"))
continue
if cur is None:
if not result_line.search(ln):
continue # banner and preamble lines are not a game
cur = new_game() # a single-game run prints no "--- GAME" header
m = WINNER.search(ln)
if m:
cur["status"] = "draw" if m.group(1) == "draw" else "won"
cur["winner_seat"] = None if m.group(2) is None else int(m.group(2))
continue
m = TURNS.match(ln)
if m:
cur["turns"] = int(m.group(1))
continue
m = ELIM.match(ln)
if m:
cur["eliminated"].append({"seat": int(m.group(1)), "turn": int(m.group(2))})
continue
if ln.startswith("STALL:"):
cur["status"] = "stalled"
cur.setdefault("detail", ln)
elif "ABORT: hit action cap" in ln:
cur["status"] = "action_cap"
elif re.search(r"GAME .* PANICKED", ln):
cur["status"] = "panicked"
cur["detail"] = ln[:300]
finish()
games = [g for g in games if g["status"] != "unfinished" or g["turns"] is not None or g["eliminated"] or g["label"]]
return {"pre": pre, "resolved": resolved, "skipped": skipped, "games": games}
def llm_summary(llm, lines):
"""The model seats' own counters (the engine prints one every 25th question), last value per seat, plus the breaker and the cost."""
per_seat, extra, disabled = {}, {}, None
for ln in lines:
m = LLM_STAT.match(ln)
if m:
per_seat[int(m.group(1))] = {"asked": int(m.group(2)), "used": int(m.group(3))}
for key, grp in (("timeouts", 5), ("avg_ms", 6), ("avg_prompt_chars", 7)):
if m.group(grp) is not None:
extra[key] = int(m.group(grp))
continue
m = LLM_OFF.match(ln)
if m:
disabled = int(m.group(1))
out = {"seats": llm["seats"], "kind": llm["kind"], "model": llm["model"], "max_calls": llm["max_calls"], "per_seat": per_seat,
"note": "counters are printed every 25th question, so the last few may be missing"}
out.update(extra)
if disabled is not None:
out["switched_off_after_failures_in_a_row"] = disabled
return out
def wilson(wins, n, z=1.96):
if n == 0:
return [0.0, 0.0]
p = wins / n
d = 1 + z * z / n
c = p + z * z / (2 * n)
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return [round(max(0.0, (c - h) / d), 4), round(min(1.0, (c + h) / d), 4)]
def summarize(spec, parsed, seeds):
decks = [d["name"] for d in spec["feed"]]
finished = [g for g in parsed["games"] if g["status"] in ("won", "draw")]
won = [g for g in finished if g["status"] == "won"]
by_seat = [sum(1 for g in won if g["winner_seat"] == i) for i in range(4)]
n = len(finished)
warnings = []
for s in parsed["skipped"]:
warnings.append("deck %r was skipped: commander %r is not in the engine's card database" % (s["deck"], s["commander"]))
for i, pre in enumerate(parsed["pre"][:4]):
got = (parsed["resolved"].get(i) or {}).get("main")
if got is not None and got < pre["listed"]:
warnings.append("seat %d (%s): %d of %d cards were not recognised by the engine and were left out" % (
i, pre["deck"], pre["listed"] - got, pre["listed"]))
bad = [g for g in parsed["games"] if g["status"] not in ("won", "draw")]
if bad:
warnings.append("%d game(s) did not finish (%s): not counted as wins or draws" % (
len(bad), ", ".join(sorted({g["status"] for g in bad}))))
for i, g in enumerate(parsed["games"]):
if i < len(seeds):
g["seed"] = seeds[i]
return {"games_requested": spec["games"], "games_finished": n, "games_not_finished": len(bad), "draws": n - len(won),
"wins_by_seat": by_seat,
"decks": [{"seat": i, "name": decks[i], "wins": by_seat[i], "win_rate": round(by_seat[i] / n, 4) if n else None,
"win_rate_95": wilson(by_seat[i], n)} for i in range(4)],
"avg_turns": round(sum(g["turns"] or 0 for g in finished) / n, 1) if n else None,
"warnings": warnings, "games": parsed["games"]}
# ---------- jobs ----------------------------------------------------------------------------------------------------------------
def run_job(job):
spec = job["spec"]
root = tempfile.mkdtemp(prefix="phase-sim-")
out_lines = []
try:
os.symlink(CARD_DATA, os.path.join(root, "card-data.json"))
with open(os.path.join(root, "feed.json"), "w", encoding="utf-8") as f:
json.dump({"decks": spec["feed"]}, f)
seeds = [spec["seed"] + i for i in range(spec["games"])]
with open(os.path.join(root, "games.txt"), "w", encoding="utf-8") as f:
f.write("".join("%d,%s\n" % (s, spec["difficulty"]) for s in seeds))
job["status"], job["started"] = "running", time.time()
proc = subprocess.Popen(command(spec, root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", cwd=root,
env=engine_env(spec))
timer = threading.Timer(JOB_TIMEOUT, proc.kill)
timer.start()
try:
for ln in proc.stdout:
out_lines.append(ln.rstrip("\n"))
if GAME_HEAD.match(ln):
job["games_started"] += 1
if WINNER.search(ln) or "GAME" in ln and "PANICKED" in ln or ln.startswith("STALL:") or "ABORT: hit action cap" in ln:
job["games_done"] += 1
code = proc.wait()
finally:
timer.cancel()
text = "\n".join(out_lines)
parsed = parse(text)
job["result"] = summarize(spec, parsed, seeds)
if spec.get("llm"):
job["result"]["llm"] = llm_summary(spec["llm"], out_lines)
if "switched_off_after_failures_in_a_row" in job["result"]["llm"]:
job["result"]["warnings"].append(
"the model seats switched themselves off after %d failures in a row (usually timeouts: late-game prompts are thousands of tokens "
"and the model server could not read them in time); phase's own AI played the rest of the run, so the later moves are NOT the model's"
% job["result"]["llm"]["switched_off_after_failures_in_a_row"])
job["result"]["warnings"].append(
"model seats %s: the model is asked about each non-forced decision, up to %d calls for the whole run; an answer the engine "
"cannot use, a timeout, or any decision after the cap is played by phase's own AI instead" % (spec["llm"]["seats"], spec["llm"]["max_calls"]))
job["engine_exit_code"] = code
job["output_tail"] = out_lines[-40:]
job["status"] = "done" if parsed["games"] else "error"
if not parsed["games"]:
job["error"] = "the engine produced no game results (exit code %s); see output_tail" % code
except Exception as exc: # the job fails, the service does not
job["status"], job["error"] = "error", "%s: %s" % (type(exc).__name__, exc)
job["output_tail"] = out_lines[-40:]
finally:
job["finished"] = time.time()
try:
for name in os.listdir(root):
os.unlink(os.path.join(root, name))
os.rmdir(root)
except OSError:
pass
def worker():
while True:
with _cv:
while not _queue:
_cv.wait()
job = _queue.popleft()
_running["id"] = job["id"]
run_job(job)
with _cv:
_running["id"] = None
def submit(req):
spec = validate(req)
job = {"id": uuid.uuid4().hex[:12], "status": "queued", "created": time.time(), "spec": spec, "games_started": 0, "games_done": 0}
with _cv:
_jobs[job["id"]] = job
_queue.append(job)
for old in sorted(_jobs.values(), key=lambda j: j["created"])[:-50]: # remember the last 50
if old["status"] in ("done", "error"):
_jobs.pop(old["id"], None)
_cv.notify()
return job["id"]
def public(job):
out = {k: job[k] for k in ("id", "status", "games_started", "games_done") if k in job}
out["games_requested"] = job["spec"]["games"]
for k in ("result", "error", "output_tail", "engine_exit_code"):
if k in job:
out[k] = job[k]
if "started" in job:
out["seconds"] = round((job.get("finished") or time.time()) - job["started"], 1)
return out
def forward(url, data, headers):
req = urllib.request.Request(url, data=data, headers=dict({"content-type": "application/json"}, **headers))
try:
with urllib.request.urlopen(req, timeout=RELAY_TIMEOUT) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
return exc.code, json.loads(exc.read() or b"{}")
except ValueError:
return exc.code, {"error": "upstream error"}
except (urllib.error.URLError, OSError, ValueError):
return 502, {"error": "the model endpoint is not reachable"}
def relay_chat(body, remote):
"""POST /llm/v1/chat/completions -> the OpenAI-style endpoint, with the model's thinking off and its answer capped."""
if remote not in LOCAL_ONLY or not CHAT_URL:
return 403, {"error": "not available"}
try:
req = json.loads(body)
req["max_tokens"] = min(int(req.get("max_tokens") or CHAT_MAX_TOKENS), CHAT_MAX_TOKENS)
req["stream"] = False
if CHAT_REASONING:
req["reasoning_effort"] = CHAT_REASONING
data = json.dumps(req).encode()
except (ValueError, TypeError):
return 400, {"error": "bad chat request"}
return forward(CHAT_URL + "/chat/completions", data, {})
def relay_decision(body, remote):
"""POST /jev/systemone: phase's Jev envelope {"apiKey", "request": {...}} -> a System One server (Laya / Kev); the key comes from the environment."""
if remote not in LOCAL_ONLY or not DECISION_URL:
return 403, {"error": "not available"}
try:
data = json.dumps(json.loads(body)["request"]).encode()
except (ValueError, KeyError, TypeError):
return 400, {"error": "bad decision request"}
return forward(DECISION_URL, data, {"authorization": "Bearer " + DECISION_KEY} if DECISION_KEY else {})
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "phase-sim/1"
def log_message(self, fmt, *args):
if os.environ.get("PHASE_SIM_LOG") == "1":
sys.stderr.write("%s\n" % (fmt % args))
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
with _cv:
return self._send(200, {"ok": os.path.isfile(BIN) and os.path.isfile(CARD_DATA), "engine": ENGINE,
"queued": len(_queue), "running": _running["id"], "max_games": MAX_GAMES})
m = re.match(r"^/job/([0-9a-f]+)$", self.path)
if m:
with _cv:
job = _jobs.get(m.group(1))
return self._send(200, public(job)) if job else self._send(404, {"error": "no such job"})
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path in ("/llm/v1/chat/completions", "/jev/systemone"):
n = int(self.headers.get("Content-Length") or 0)
if n > 4_000_000:
return self._send(413, {"error": "request too large"})
fn = relay_chat if self.path.startswith("/llm") else relay_decision
code, obj = fn(self.rfile.read(n), self.client_address[0])
return self._send(code, obj)
if self.path != "/run":
return self._send(404, {"error": "not found"})
try:
n = int(self.headers.get("Content-Length") or 0)
if n > 2_000_000:
return self._send(413, {"error": "request too large"})
req = json.loads(self.rfile.read(n) or b"{}")
if not isinstance(req, dict):
raise BadRequest("the body must be a JSON object")
self._send(202, {"job": submit(req)})
except (BadRequest, json.JSONDecodeError) as exc:
self._send(400, {"error": str(exc)})
def main():
threading.Thread(target=worker, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""Smoke test, run INSIDE the image (CI and by hand): four real decks from phase's own sample feed play real games through the service.
python /opt/phase/phase_smoke.py [games] [difficulty] plain engine
python /opt/phase/phase_smoke.py --llm model seats against a stub model inside this process
Exits non-zero unless at least one game finished with a winner or a draw. Prints what the engine reported, including any cards it did
not recognise, so a bad data/engine pairing shows up here and not in a result someone trusts.
--llm starts a stub OpenAI-style model on 127.0.0.1:9099 (the service must have been started with PHASE_LLM_CHAT_URL=http://127.0.0.1:9099/v1),
puts seats 1 and 3 on it, and passes when the engine's own counters show the model was asked AND its answers were used by the engine: that
proves the Rust hook, the relay and phase's answer check end to end without any outside service. The stub always answers option 0, so the
game itself may not finish (it can hit the action cap); that is not what is being tested.
"""
import json
import sys
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
BASE = "http://127.0.0.1:8090"
LLM = "--llm" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
games = int(args[0]) if args else (1 if LLM else 2)
difficulty = args[1] if len(args) > 1 else "VeryEasy"
ASKED = []
class StubModel(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
ASKED.append(body)
out = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", "content": "{\"choice\": 0, \"reason\": \"stub\"}"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
if LLM:
threading.Thread(target=HTTPServer(("127.0.0.1", 9099), StubModel).serve_forever, daemon=True).start()
def call(path, body=None):
req = urllib.request.Request(BASE + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
for _ in range(60):
try:
h = call("/health")
break
except Exception:
time.sleep(1)
else:
sys.exit("the service did not come up")
print("health:", h)
assert h["ok"], "binary or card data missing"
feed = json.load(open("/opt/phase/sample-feed.json", encoding="utf-8"))["decks"]
decks = []
for d in feed:
cmd = d.get("commander") or [d["name"]]
decks.append({"name": d["name"], "commander": cmd, "main": d["main"]})
if len(decks) == 4:
break
request = {"decks": decks, "games": games, "seed": 7, "difficulty": difficulty, "action_cap": 60000}
if LLM:
request.update(action_cap=6000, llm_seats=[1, 3], llm={"kind": "chat", "model": "stub-model", "max_calls": 60})
job = call("/run", request)["job"]
t0 = time.time()
while True:
j = call("/job/" + job)
if j["status"] in ("done", "error"):
break
if time.time() - t0 > 1500:
sys.exit("timed out; last: %s" % json.dumps(j)[:300])
time.sleep(3)
print("job: %s in %.0f s" % (j["status"], time.time() - t0))
res = j.get("result") or {}
print(json.dumps({k: res.get(k) for k in ("games_requested", "games_finished", "games_not_finished", "draws", "wins_by_seat", "avg_turns", "warnings")}, indent=1))
for g in res.get("games", []):
print(" game seed=%s %s winner=%s turns=%s eliminated=%s" % (g.get("seed"), g["status"], g.get("winner_seat"), g.get("turns"), g.get("eliminated")))
if LLM:
llm = res.get("llm") or {}
print("model seats:", json.dumps(llm), "| requests the stub model received:", len(ASKED))
print("\n".join((j.get("output_tail") or [])[-15:]))
seats = llm.get("per_seat") or {}
asked = sum(v["asked"] for v in seats.values())
used = sum(v["used"] for v in seats.values())
reasoned = [a for a in ASKED if a.get("reasoning_effort") == "none"]
if not ASKED or not asked or not used:
sys.exit("the model seats were not exercised: stub got %d requests, engine counters asked=%d used=%d" % (len(ASKED), asked, used))
if len(reasoned) != len(ASKED):
sys.exit("the relay did not switch the model's thinking off on every request")
print("PHASE LLM SMOKE OK (asked %d, engine used %d, stub saw %d requests, all with thinking off)" % (asked, used, len(ASKED)))
sys.exit(0)
if j["status"] != "done" or res.get("games_finished", 0) < 1:
print("\n".join(j.get("output_tail") or []))
sys.exit("no game finished")
print("PHASE SMOKE OK")
+272
View File
@@ -0,0 +1,272 @@
"""Run: python -I -X utf8 test_phase_sim.py (offline: a stub engine prints output in the format phase's ai-commander prints)"""
import json
import os
import sys
import tempfile
import threading
import time
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import phase_sim as P # noqa: E402
FAILS = 0
def ck(name, cond, detail=""):
global FAILS
print(" %s %s%s" % ("ok " if cond else "FAIL", name, (" -- " + str(detail)[:260]) if detail and not cond else ""))
if not cond:
FAILS += 1
def deck(name, cmd, n=3):
return {"name": name, "commander": [cmd], "main": [{"name": "Sol Ring", "count": 1}, {"name": "Forest", "count": n}]}
GOOD = {"decks": [deck("A", "Cmd A"), deck("B", "Cmd B"), deck("C", "Cmd C"), deck("D", "Cmd D")], "games": 3, "seed": 10}
# --- validation
def bad(mut, why):
req = json.loads(json.dumps(GOOD))
mut(req)
try:
P.validate(req)
ck("rejects " + why, False)
except P.BadRequest as exc:
ck("rejects " + why, True, exc)
ck("a good request validates", P.validate(GOOD)["games"] == 3)
bad(lambda r: r["decks"].pop(), "three decks")
bad(lambda r: r["decks"][0].pop("commander"), "a deck with no commander")
bad(lambda r: r["decks"][1].update(main=[]), "an empty main list")
bad(lambda r: r["decks"][2]["main"].append({"name": "X", "count": 0}), "a zero count")
bad(lambda r: r.update(games=P.MAX_GAMES + 1), "too many games")
bad(lambda r: r.update(difficulty="Impossible"), "an unknown difficulty")
bad(lambda r: r.update(seat_difficulty={"7": "Easy"}), "a seat outside 0-3")
bad(lambda r: r.update(action_cap=5), "a tiny action cap")
v = P.validate(dict(GOOD, decks=[dict(deck("A", "Cmd A"), commander="Cmd A", main=None, cards=["Forest", "Forest", "Sol Ring"])] + GOOD["decks"][1:]))
ck("a plain card list becomes counted rows and a commander string becomes a list",
v["feed"][0]["commander"] == ["Cmd A"] and {"name": "Forest", "count": 2} in v["feed"][0]["main"], v["feed"][0])
# --- parsing the engine's output
OUT = """=== 4-player Commander AI test ===
Feed: feed.json
Batch mode: 5 games from games.txt
A | commander: Cmd A | main: 99 cards
B | commander: Cmd B | main: 99 cards
SKIP C: commander 'Cmd C' not in card db
D | commander: Cmd D | main: 99 cards
Resolved deck sizes (post name-resolution, 0-indexed by seat):
P0 main= 99 commander=1
P1 main= 97 commander=1
P2 main= 99 commander=1
P3 main= 99 commander=1
ExecutionMode: measurement
--- GAME seed=10 difficulty=Medium ---
Game started. 4 players.
ELIMINATED: P1 turn=9 actions= 400 elapsed=3.1s
ELIMINATED: P3 turn=12 actions= 600 elapsed=5.0s
=== RESULT ===
Elapsed: 8.0s
Turns played: 14
Game ended cleanly. Winner: P2
--- GAME seed=11 difficulty=Medium ---
Game started. 4 players.
=== RESULT ===
Turns played: 40
Game ended cleanly. Winner: draw
--- GAME seed=12 difficulty=Medium ---
=== RESULT ===
Turns played: 7
STALL: waiting_for=ChooseTarget turn=7 active=P0 priority=P0 pending_cast=none
Game did NOT reach GameOver. waiting_for = ChooseTarget
--- GAME seed=13 difficulty=Medium ---
ABORT: hit action cap=50000
=== RESULT ===
Turns played: 31
Game did NOT reach GameOver. waiting_for = Priority
--- GAME seed=14 difficulty=Medium ---
GAME 14 PANICKED: index out of bounds
"""
parsed = P.parse(OUT)
st = [g["status"] for g in parsed["games"]]
ck("five games found with their outcomes in order", st == ["won", "draw", "stalled", "action_cap", "panicked"], st)
g0 = parsed["games"][0]
ck("winner seat, turns and elimination order are read", g0["winner_seat"] == 2 and g0["turns"] == 14
and [e["seat"] for e in g0["eliminated"]] == [1, 3], g0)
ck("a draw has no winner seat", parsed["games"][1]["winner_seat"] is None)
ck("pre-resolution and resolved deck sizes and the skipped deck are read", len(parsed["pre"]) == 3 and parsed["resolved"][1]["main"] == 97
and parsed["skipped"] == [{"deck": "C", "commander": "Cmd C"}], (parsed["pre"], parsed["resolved"], parsed["skipped"]))
spec = P.validate(dict(GOOD, games=5))
res = P.summarize(spec, parsed, [10, 11, 12, 13, 14])
ck("only finished games are counted", res["games_finished"] == 2 and res["games_not_finished"] == 3 and res["draws"] == 1, res)
ck("wins by seat and per-deck win rate", res["wins_by_seat"] == [0, 0, 1, 0] and res["decks"][2]["win_rate"] == 0.5 and res["decks"][0]["win_rate"] == 0.0)
lo, hi = res["decks"][2]["win_rate_95"]
ck("a 95% interval wraps the rate and stays in 0..1", 0 <= lo < 0.5 < hi <= 1)
w = " | ".join(res["warnings"])
ck("warnings: a skipped deck, unrecognised cards, and unfinished games (never counted)",
"was skipped" in w and "2 of 99" in w and "not counted as wins or draws" in w and "stalled" in w, w)
ck("each game carries its seed", [g["seed"] for g in res["games"]] == [10, 11, 12, 13, 14])
ck("interval of nothing is zeros, of a sweep is high", P.wilson(0, 0) == [0.0, 0.0] and P.wilson(10, 10)[0] > 0.7)
# --- a whole job through the real worker path with a stub engine
STUB = os.path.join(tempfile.mkdtemp(), "stub_engine.py")
with open(STUB, "w", encoding="utf-8") as f:
f.write('''import sys, os
root = sys.argv[1]
seeds = [l.split(",")[0] for l in open(os.path.join(root, "games.txt")) if l.strip()]
feed = __import__("json").load(open(os.path.join(root, "feed.json")))
assert os.path.exists(os.path.join(root, "card-data.json")) and len(feed["decks"]) == 4
for d in feed["decks"]:
print(" %s | commander: %s | main: %d cards" % (d["name"], d["commander"][0], sum(e["count"] for e in d["main"])))
print("Resolved deck sizes (post name-resolution, 0-indexed by seat):")
for i, d in enumerate(feed["decks"]):
print(" P%d main=%3d commander=1" % (i, sum(e["count"] for e in d["main"])))
for k, s in enumerate(seeds):
print("--- GAME seed=%s ---" % s)
print("Turns played: %d" % (10 + k))
print("Game ended cleanly. Winner: P%d" % (k % 4))
''')
CD = os.path.join(os.path.dirname(STUB), "card-data.json")
open(CD, "w").write("{}")
P.CARD_DATA = CD
ORIG_COMMAND = P.command
P.command = lambda spec, root: [sys.executable, STUB, root]
job_id = P.submit(dict(GOOD, games=4))
threading.Thread(target=P.worker, daemon=True).start()
for _ in range(100):
j = P._jobs[job_id]
if j["status"] in ("done", "error"):
break
time.sleep(0.1)
pub = P.public(P._jobs[job_id])
ck("a queued job runs to done with four finished games, one win each", pub["status"] == "done" and pub["result"]["games_finished"] == 4
and pub["result"]["wins_by_seat"] == [1, 1, 1, 1] and pub["games_done"] == 4, pub)
ck("no warnings when every card resolved", pub["result"]["warnings"] == [], pub["result"]["warnings"])
ck("the temp folder is cleaned up", not [d for d in os.listdir(tempfile.gettempdir()) if d.startswith("phase-sim-")])
# --- the HTTP surface
srv = P.ThreadingHTTPServer(("127.0.0.1", 0), P.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = "http://127.0.0.1:%d" % srv.server_port
def call(path, body=None):
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
try:
r = urllib.request.urlopen(req, timeout=10)
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
code, h = call("/health")
ck("/health answers (ok is false only because the real binary is not here)", code == 200 and "engine" in h and h["max_games"] == P.MAX_GAMES, h)
code, r = call("/run", dict(GOOD, games=1))
ck("POST /run is accepted with a job id", code == 202 and len(r["job"]) == 12, r)
for _ in range(100):
code, j = call("/job/" + r["job"])
if j["status"] in ("done", "error"):
break
time.sleep(0.1)
ck("GET /job/<id> returns the result", code == 200 and j["status"] == "done" and j["result"]["games_finished"] == 1, j)
ck("a bad request is a 400 with a reason", call("/run", {"decks": []})[0] == 400)
ck("an unknown job is a 404", call("/job/deadbeef")[0] == 404)
ck("an unknown path is a 404", call("/nope")[0] == 404)
# --- model seats: validation, environment, relays
from http.server import BaseHTTPRequestHandler, HTTPServer
UP = []
class Upstream(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
UP.append((self.path, body, self.headers.get("authorization")))
out = json.dumps({"echo": True, "path": self.path}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
up = HTTPServer(("127.0.0.1", 0), Upstream)
threading.Thread(target=up.serve_forever, daemon=True).start()
UPBASE = "http://127.0.0.1:%d" % up.server_port
os.environ["PHASE_SIM_ALLOW_MISSING_LLM_BIN"] = "1"
LLMREQ = dict(GOOD, llm_seats=[1, 3], llm={"kind": "chat", "model": "twin-turbo", "max_calls": 120})
P.CHAT_URL, P.DECISION_URL = "", ""
try:
P.validate(LLMREQ)
ck("model seats are refused when no model endpoint is configured", False)
except P.BadRequest as exc:
ck("model seats are refused when no model endpoint is configured", "no chat model endpoint" in str(exc), exc)
P.CHAT_URL, P.DECISION_URL, P.DECISION_KEY = UPBASE + "/v1", UPBASE + "/v1/systemone", "sekret"
v = P.validate(LLMREQ)
ck("a chat model seat request validates", v["llm"] == {"seats": [1, 3], "kind": "chat", "model": "twin-turbo", "max_calls": 120}, v["llm"])
ck("a request with no model seats has no llm settings", P.validate(GOOD)["llm"] is None)
for mut, why in ((lambda r: r.update(llm_seats=[4]), "a seat outside 0-3"),
(lambda r: r["llm"].pop("model"), "a chat model with no name"),
(lambda r: r["llm"].update(max_calls=P.MAX_LLM_CALLS + 1), "too many model calls"),
(lambda r: r["llm"].update(kind="telepathy"), "an unknown kind")):
req = json.loads(json.dumps(LLMREQ))
mut(req)
try:
P.validate(req)
ck("rejects " + why, False)
except P.BadRequest:
ck("rejects " + why, True)
dv = P.validate(dict(GOOD, llm_seats=[0], llm={"kind": "decision"}))
ck("a decision-model seat needs no model name", dv["llm"]["kind"] == "decision" and dv["llm"]["model"] == "decision-model")
env = P.engine_env(v)
ck("chat seats: the engine is pointed at this service's /llm relay as an OpenAI-compatible endpoint with no key",
env["PHASE_LLM_SEATS"] == "1,3" and env["PHASE_LLM_PROVIDER"] == "OpenAiCompatible" and env["PHASE_LLM_BASE_URL"].endswith("/llm/v1")
and env["PHASE_LLM_MODEL"] == "twin-turbo" and env["PHASE_LLM_API_KEY"] == "" and env["PHASE_LLM_MAX_CALLS"] == "120", env)
denv = P.engine_env(dv)
ck("decision seats: provider Jev, base is this service (the engine adds /jev/systemone)", denv["PHASE_LLM_PROVIDER"] == "Jev"
and denv["PHASE_LLM_BASE_URL"] == "http://127.0.0.1:%d" % P.PORT)
ck("no model seats: no special environment, the plain engine", P.engine_env(P.validate(GOOD)) is None
and ORIG_COMMAND(P.validate(GOOD), "/r")[0] == P.BIN and ORIG_COMMAND(v, "/r")[0] == P.BIN_LLM)
code, out = P.relay_chat(json.dumps({"model": "twin-turbo", "messages": [{"role": "user", "content": "x"}], "max_tokens": 4000, "stream": True}).encode(), "127.0.0.1")
path, body, auth = UP[-1]
ck("the chat relay forwards to /chat/completions with thinking off, the answer capped and streaming off",
code == 200 and path == "/v1/chat/completions" and body["reasoning_effort"] == "none" and body["max_tokens"] == P.CHAT_MAX_TOKENS
and body["stream"] is False and body["model"] == "twin-turbo", (code, path, body))
ck("a request without max_tokens gets the cap", (P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1"), UP[-1][1]["max_tokens"] == P.CHAT_MAX_TOKENS)[1])
ck("the relays refuse anyone but this machine", P.relay_chat(b"{}", "10.1.2.3")[0] == 403 and P.relay_decision(b"{}", "10.1.2.3")[0] == 403)
ck("a malformed chat body is a 400, not an exception", P.relay_chat(b"not json", "127.0.0.1")[0] == 400)
envelope = {"apiKey": "ignored-by-us", "request": {"state": {"body": "t"}, "questions": {"pick": {"type": "choice"}}}}
code, out = P.relay_decision(json.dumps(envelope).encode(), "127.0.0.1")
path, body, auth = UP[-1]
ck("the decision relay unwraps phase's envelope and sends only the request, with OUR key", code == 200 and path == "/v1/systemone"
and body == envelope["request"] and auth == "Bearer sekret", (code, path, body, auth))
ck("a decision body with no request is a 400", P.relay_decision(b'{"apiKey": "k"}', "127.0.0.1")[0] == 400)
P.CHAT_URL = "http://127.0.0.1:1/v1"
ck("an unreachable model endpoint is a 502", P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1")[0] == 502)
P.CHAT_URL = UPBASE + "/v1"
summ = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=1 asked=25 used=20 total_calls=25", "noise", "LLM-SEAT seat=1 asked=50 used=41 total_calls=50"])
ck("the model seats' counters are read from the engine's output (last value wins)", summ["per_seat"] == {1: {"asked": 50, "used": 41}}, summ)
summ2 = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=1 asked=25 used=1 total_calls=25 timeouts=20 avg_ms=1800 avg_prompt_chars=31000",
"LLM-SEAT-DISABLED after 4 consecutive failures (20 timeouts of 25 questions): phase's own AI plays the rest"])
ck("cost counters and the circuit-breaker are read from the engine's output", summ2["timeouts"] == 20 and summ2["avg_ms"] == 1800
and summ2["avg_prompt_chars"] == 31000 and summ2["switched_off_after_failures_in_a_row"] == 4 and summ2["per_seat"] == {1: {"asked": 25, "used": 1}}, summ2)
ck("old three-field counter lines still parse", P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=2 asked=25 used=24 total_calls=25"])["per_seat"] == {2: {"asked": 25, "used": 24}})
code, r = call("/llm/v1/chat/completions", {"model": "m", "messages": []})
ck("over HTTP from this machine the chat relay answers", code == 200 and r["echo"] is True, (code, r))
print("\nFAILURES: %d" % FAILS)
sys.exit(1 if FAILS else 0)
+42
View File
@@ -0,0 +1,42 @@
with open('ml/eval/forge_evaluator.py', 'rb') as f:
data = f.read()
# Fix 1: absolute JAR path in subprocess call
data = data.replace(
b'"--forge-jar", self.cfg.forge_jar,',
b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),'
)
# Fix 2: resolve forge_work_dir to absolute path too
data = data.replace(
b'"--forge-dir", self.cfg.forge_work_dir,',
b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),'
)
# Fix 3: change default forge_work_dir
data = data.replace(
b'forge_work_dir: str = "forge"',
b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('ml/eval/forge_evaluator.py', 'wb') as f:
f.write(data)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('forge_evaluator.py - Syntax OK')
# Fix eval_policy.py too
with open('scripts/eval_policy.py', 'rb') as f:
data = f.read()
data = data.replace(
b'"--forge-dir", default="forge"',
b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('scripts/eval_policy.py', 'wb') as f:
f.write(data)
py_compile.compile('scripts/eval_policy.py', doraise=True)
print('eval_policy.py - Syntax OK')
+150 -72
View File
@@ -3,9 +3,13 @@
Commander AI Lab — FastAPI Backend
Thin entry point: registers routers and starts uvicorn.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
try:
@@ -22,7 +26,7 @@ except ImportError:
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
import sys; sys.exit(1)
from models.state import CFG, COMMANDER_META, load_commander_meta
from models.state import CFG, COMMANDER_META, load_commander_meta, validate_forge_config
from services.database import init_collection_db
from services.logging import setup_logging
from services.precon_service import PRECON_INDEX, download_precon_database
@@ -32,21 +36,87 @@ from routes.precon import router as precon_router
from routes.import_routes import router as import_router
from routes.lab import router as lab_router
from routes.scanner import router as scanner_router
from routes.rag import router as rag_router
from routes.deepseek import router as deepseek_router
from routes.farmhouse_phase import router as farmhouse_phase_router # FARMHOUSE_PHASE_IMPORT
from routes.deckgen import router as deckgen_router
from routes.coach import router as coach_router, init_coach_service
from routes.ml import router as ml_router
from routes.ws_game import router as ws_game_router
from routes.game import router as game_router
log = logging.getLogger("commander_ai_lab.api")
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
# ── CORS origins (env-configurable) ────────────────────────────
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# ── App ────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
# Module-level handle so lifespan can call .load() after routes are registered.
_POLICY_SVC = None
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan(application: FastAPI):
# ──── STARTUP ────
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
init_collection_db()
validate_forge_config()
init_coach_service() # FARMHOUSE_COACH_INIT_V1
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
loop = asyncio.get_running_loop()
# RAG Phase 1: kick off Scryfall bulk download in background thread.
from services.scryfall_bulk import ensure_bulk_db
loop.run_in_executor(None, ensure_bulk_db)
# RAG Phase 2: kick off ChromaDB vector store build in background thread.
from services.rag_store import ensure_rag_store
loop.run_in_executor(None, ensure_rag_store)
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
async def _rag_staleness_monitor():
"""Periodically check if the RAG index needs rebuilding."""
while True:
await asyncio.sleep(6 * 3600) # check every 6 hours
try:
from services.rag_store import check_staleness, build_index
info = check_staleness()
if info.get("stale") and os.environ.get("RAG_AUTOBUILD", "0") == "1": # FARMHOUSE_RAG_MONITOR_V1
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
loop.run_in_executor(None, lambda: build_index(force=True))
else:
log.debug("RAG staleness monitor: index is current (%.1f days).", info.get("age_days", 0))
except Exception as exc:
log.warning("RAG staleness monitor failed: %s", exc)
asyncio.ensure_future(_rag_staleness_monitor())
# Policy model load — routes were already registered at module scope (before
# any app.mount() calls) so /api/policy/* is reachable regardless of the
# StaticFiles root mount. Only the heavyweight model load happens here.
try:
if _POLICY_SVC is not None:
if _POLICY_SVC.load():
log.info("Policy model loaded successfully")
else:
log.warning("Policy model not loaded — /api/policy/health will report degraded")
except Exception as e:
log.warning("Policy model load failed (non-fatal): %s", e)
yield # ← server is running
# ──── SHUTDOWN ────
from services.async_db import async_close_db
await async_close_db()
# ── App ─────────────────────────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
@@ -60,42 +130,63 @@ app.include_router(deckbuilder_router)
app.include_router(precon_router)
app.include_router(import_router)
app.include_router(lab_router)
app.include_router(farmhouse_phase_router)
app.include_router(scanner_router)
app.include_router(rag_router)
app.include_router(deepseek_router)
app.include_router(deckgen_router)
app.include_router(coach_router)
app.include_router(ws_game_router)
app.include_router(ml_router)
app.include_router(game_router) # live game session routes
# ── Policy routes — MUST be registered here, before any app.mount() calls ────────────────────
# StaticFiles mounted at "/" acts as a sub-application and returns its own 404 for
# paths it doesn't serve, rather than falling through to later-registered routes.
# Any include_router() called after app.mount("/", ...) will be unreachable.
# Registering here (module scope) guarantees the /api/policy/* endpoints exist in
# the route list before the root static mount is added below.
try:
from ml.serving.policy_server import PolicyInferenceService
from routes.policy import register_policy_routes
_POLICY_SVC = PolicyInferenceService()
register_policy_routes(app, _POLICY_SVC)
log.info("Policy routes registered (/api/policy/*)")
except ImportError:
log.info("Policy service not available (ml.serving not installed)")
except Exception as e:
log.warning("Policy route registration failed (non-fatal): %s", e)
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
# shadows any routes added after it.
@app.get("/api/health")
async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
from pathlib import Path as _Path
_legacy_ui_dir = _Path(__file__).parent / "ui"
_lan_client_dir = _Path(__file__).parent / "lan-client"
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
_user_decks_dir = _Path(__file__).parent / "user-decks"
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
@app.on_event("startup")
async def _on_startup():
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
# Ensure precon_dir is resolved even when started via uvicorn directly
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
init_collection_db()
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
# Mount LAN client at /lan (served before the root catch-all)
if _lan_client_dir.exists():
app.mount("/lan", StaticFiles(directory=str(_lan_client_dir), html=True), name="lan-client")
# Mount precon-decks/ so .dck files are fetchable at /precon-decks/{file}.dck
if _precon_decks_dir.exists():
app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks")
@app.on_event("shutdown")
async def _on_shutdown():
"""Close the async SQLite connection pool."""
from services.async_db import async_close_db
await async_close_db()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck
if _user_decks_dir.exists():
app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks")
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
@@ -103,6 +194,7 @@ elif _spa_dir.exists():
_spa_assets = _spa_dir / "assets"
if _spa_assets.exists():
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
@app.get("/{full_path:path}")
async def _spa_catchall(full_path: str):
from fastapi.responses import FileResponse
@@ -111,20 +203,24 @@ elif _spa_dir.exists():
return FileResponse(str(requested_file))
return FileResponse(str(_spa_dir / "index.html"))
# ── Startup ────────────────────────────────────────────────────
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
p.add_argument("--verbose", "-v", action="store_true")
return p.parse_args()
def _resolve_forge_decks_dir() -> str:
import sys
if sys.platform == "win32":
@@ -142,52 +238,37 @@ def _resolve_forge_decks_dir() -> str:
return str(candidate)
return ""
def _resolve_forge_dir() -> str:
"""Auto-detect the Forge installation directory.
The Forge dir is the root of a Forge install — it typically contains
a 'res' subdirectory with card data. We derive it from the JAR
location (parent of 'target/') or look in common install paths.
"""
"""Auto-detect the Forge installation directory."""
import sys
# 1. If forge_jar is already resolved, derive forge_dir from it.
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
# so forge_dir = JAR's grandparent's parent.
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
jar_path = Path(CFG.forge_jar)
# target/ -> forge-gui-desktop/ -> <forge-root>
candidate = jar_path.parent.parent.parent
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Also try just one level up from target/
candidate = jar_path.parent.parent
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
return str(forge_gui_sibling)
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Fall back to the directory containing the JAR itself
candidate = jar_path.parent
if candidate.is_dir():
return str(candidate)
# 2. Check common sibling/child directories relative to project root
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "forge-repo",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "Forge-Repo",
project_root.parent / "Forge-Repo" / "forge-gui",
project_root.parent / "forge-repo", # legacy fallback
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
]:
if candidate.is_dir():
# Prefer directory that has 'res' subfolder
if (candidate / "res").is_dir():
return str(candidate)
# 3. Platform-specific user data directories
if sys.platform == "win32":
appdata = os.environ.get("APPDATA", "")
if appdata:
@@ -195,32 +276,26 @@ def _resolve_forge_dir() -> str:
if candidate.is_dir():
return str(candidate)
home = Path.home()
for candidate in [
home / ".forge",
home / "Forge",
]:
for candidate in [home / ".forge", home / "Forge"]:
if candidate.is_dir():
return str(candidate)
return ""
def _resolve_forge_jar() -> str:
"""Auto-detect the Forge GUI desktop JAR in common locations."""
project_root = Path(__file__).parent
# Candidate directories where the Forge JAR might live
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
forge_dir_env = os.environ.get("FORGE_DIR", "")
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
if forge_dir_env:
fd = Path(forge_dir_env)
candidates.append(fd.parent / "forge-gui-desktop" / "target")
candidates.append(fd / "target")
for target_dir in candidates:
if target_dir.is_dir():
for pattern in [
@@ -234,6 +309,7 @@ def _resolve_forge_jar() -> str:
return str(jars[0])
return ""
def _resolve_lab_jar() -> str:
target_dir = Path(__file__).parent / "target"
if target_dir.exists():
@@ -248,36 +324,25 @@ def _resolve_lab_jar() -> str:
return str(jars[0])
return ""
def _resolve_precon_dir() -> str:
"""Auto-detect the precon-decks directory.
The precon-decks dir lives in the project root and contains downloaded
.dck files plus precon-index.json. We check relative to this file
(lab_api.py) first, then common sibling paths.
"""
"""Auto-detect the precon-decks directory."""
project_root = Path(__file__).parent
# 1. Standard location: <project-root>/precon-decks
candidate = project_root / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 2. Check parent directory (in case running from a subdirectory)
candidate = project_root.parent / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 3. Derive from forge_dir if available
if CFG.forge_dir:
candidate = Path(CFG.forge_dir) / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 4. Fall back: create the directory in the project root so downloads work
fallback = project_root / "precon-decks"
fallback.mkdir(parents=True, exist_ok=True)
return str(fallback)
def main():
args = _parse_args()
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
@@ -291,6 +356,15 @@ def main():
CFG.ximilar_api_key = args.ximilar_key
CFG.anthropic_api_key = args.anthropic_key
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
# but resolve it to an absolute path relative to this file so it works
# regardless of what directory the server is launched from.
if args.results_dir:
CFG.results_dir = args.results_dir
else:
# Default: results/ next to lab_api.py (always absolute)
CFG.results_dir = str(Path(__file__).parent / "results")
from services.forge_runner import get_java17
j17 = get_java17()
@@ -311,8 +385,12 @@ def main():
init_collection_db()
init_coach_service()
# RAG Phase 1 + 2 are handled by _lifespan when uvicorn starts.
# No need to call them here — doing so causes a double-run.
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Collection</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="collection.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
<div id="collection-container"></div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="collection.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Collection.init();
});
</script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+495
View File
@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Deck Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckbuilder.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
<div id="deck-builder-page">
<!-- TOP BAR -->
<header class="deck-topbar">
<div class="deck-topbar-commander">
<div class="deck-commander-portrait" id="deck-commander-portrait">
<span class="deck-commander-portrait-placeholder">⚔</span>
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
</div>
<div class="deck-name-block">
<input
type="text"
class="deck-name-input"
id="deck-name-input"
placeholder="Untitled Deck"
value=""
/>
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
</div>
<div class="deck-color-chips" id="deck-color-chips"></div>
</div>
<div class="deck-topbar-center">
<div class="deck-count-display">
<span class="deck-count-num" id="deck-total-count">0</span>
<span class="deck-count-sep">/</span>
<span class="deck-count-target">100</span>
<span class="deck-count-label">cards</span>
</div>
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
<div class="deck-selector-wrap">
<select class="deck-selector" id="deck-selector">
<option value="">— Select Deck —</option>
</select>
</div>
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
</div>
<div class="deck-topbar-actions">
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
<span>⬆</span> Import
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
<span>⬇</span> Export .dck
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
<span>&#x1F4E4;</span> Export to Sim
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
<span>&#x1F3AE;</span> Use in Game
</button>
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
<span class="deck-btn-icon">▶</span> Run Simulation
</button>
</div>
</header>
<!-- MAIN 3-COLUMN LAYOUT -->
<main class="deck-main">
<!-- LEFT COLUMN: Collection Search -->
<aside class="deck-col-left">
<div class="deck-panel deck-panel-search">
<div class="deck-panel-header">
<span class="deck-panel-title">Collection Search</span>
<span class="deck-search-count" id="deck-search-count">0 results</span>
</div>
<div class="deck-search-bar-wrap">
<input
type="search"
class="deck-search-bar"
id="deck-search-bar"
placeholder="Search cards..."
autocomplete="off"
/>
<span class="deck-search-icon">🔍</span>
</div>
<div class="deck-filter-row">
<select class="deck-filter-select" id="deck-type-filter">
<option value="">All Types</option>
<option value="Land">Land</option>
<option value="Creature">Creature</option>
<option value="Instant">Instant</option>
<option value="Sorcery">Sorcery</option>
<option value="Artifact">Artifact</option>
<option value="Enchantment">Enchantment</option>
<option value="Planeswalker">Planeswalker</option>
</select>
<div class="deck-cmc-filter">
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
<span class="deck-cmc-sep">–</span>
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
</div>
</div>
<div class="deck-color-filter-row" id="deck-color-filter">
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
</div>
<!-- Results list -->
<div class="deck-search-results" id="deck-search-results">
<div class="deck-empty-state">
<div class="deck-empty-icon">🃏</div>
<div class="deck-empty-text">Search your collection to add cards</div>
</div>
</div>
<div class="deck-search-footer">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
Add All Matching (Owned)
</button>
</div>
</div>
</aside>
<!-- CENTER COLUMN: Deck Card List -->
<section class="deck-col-center">
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
<div class="deck-panel-header deck-card-list-header">
<span class="deck-panel-title">Deck Contents</span>
<div class="deck-card-list-actions">
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
</div>
</div>
<!-- Card groups by type -->
<div class="deck-card-groups" id="deck-card-groups">
<div class="deck-empty-state deck-empty-state-lg">
<div class="deck-empty-icon">⚔</div>
<div class="deck-empty-text">Create or select a deck to start building</div>
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
</div>
</div>
<!-- Quick add by name -->
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
<div class="deck-quickadd-wrap">
<input
type="text"
class="deck-quickadd-input"
id="deck-quickadd-input"
placeholder="Add card by name..."
autocomplete="off"
/>
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
</div>
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
</div>
<!-- Deck stats footer -->
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
<span class="deck-stat-pill-label">total</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
<span class="deck-stat-pill-label">owned</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
<span class="deck-stat-pill-label">avg CMC</span>
</div>
</div>
</div>
</section>
<!-- RIGHT COLUMN: EDH Advisor -->
<aside class="deck-col-right">
<!-- Ratio Dashboard -->
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
<span class="deck-panel-title">📊 Ratio Dashboard</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ratio-body">
<div class="deck-ratio-bars" id="deck-ratio-bars">
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
</div>
</div>
</div>
<!-- Fix My Ratios -->
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
<span class="deck-panel-title">🔧 Fix My Ratios</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-fix-body">
<div class="deck-shortfalls" id="deck-shortfalls">
<div class="deck-ratio-placeholder">No analysis available</div>
</div>
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
⚡ Auto-suggest from Collection
</button>
</div>
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
</div>
</div>
<!-- EDHREC Recommendations -->
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
<span class="deck-panel-title">✨ EDHREC Recs</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-edhrec-body">
<div class="deck-edhrec-controls">
<label class="deck-toggle-label">
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Owned Only
</label>
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
↺ Refresh
</button>
</div>
<div class="deck-edhrec-list" id="deck-edhrec-list">
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
</div>
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
Add All Owned (respect ratios)
</button>
</div>
</div>
</div>
<!-- AI Research (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
<span class="deck-panel-title">🧠 AI Research</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-research-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Analysis Goal</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
placeholder="Identify weaknesses and suggest upgrades" />
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
placeholder="Dockside Extortionist, Rhystic Study" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
🔍 Analyze Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Analyzing with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
</div>
</div>
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
<span class="deck-panel-title">✨ AI Generate Deck</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-generate-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Commander</label>
<div class="deck-ai-commander-wrap">
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
placeholder="Search for a commander..." autocomplete="off" />
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
</div>
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
placeholder="Rhystic Study, Cyclonic Rift" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
✨ Generate Full Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Generating deck with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
</div>
</div>
</aside>
</main>
<!-- CARD PREVIEW TOOLTIP -->
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
</div>
<!-- TOAST CONTAINER -->
<div class="deck-toast-container" id="deck-toast-container"></div>
<!-- CONFIRM MODAL -->
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
</div>
<div class="deck-modal-body">
<p class="deck-confirm-message" id="deck-confirm-message"></p>
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- NEW DECK MODAL -->
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Create New Deck</h3>
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-form-field">
<label class="deck-form-label">Deck Name</label>
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
</div>
<div class="deck-form-field">
<label class="deck-form-label">Commander (optional)</label>
<div class="deck-commander-search-wrap">
<input
type="text"
class="deck-form-input"
id="deck-new-commander-search"
placeholder="Search Scryfall for commander..."
autocomplete="off"
/>
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
</div>
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
</div>
</div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
</div>
</div>
</div>
<!-- IMPORT MODAL -->
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
<div class="deck-modal deck-modal-wide">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Import Decklist</h3>
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-import-tabs">
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
<button class="deck-import-tab" data-tab="file">Upload File</button>
</div>
<div id="deck-import-paste-panel" class="deck-import-panel">
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
Supported formats:
[Commander]
1 Korvold, Fae-Cursed King
[Deck]
1 Sol Ring
1 Cultivate
37 Forest
Or plain list:
Sol Ring
Cultivate
Forest"></textarea>
</div>
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
<div class="deck-import-dropzone" id="deck-import-dropzone">
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
</div>
<div class="deck-import-file-info" id="deck-import-file-name"></div>
</div>
<div class="deck-import-options">
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="new" checked /> Create new deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="add" /> Add to current deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
</label>
</div>
<div id="deck-import-new-name-row" class="deck-import-name-row">
<label>Deck name (optional):</label>
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
</div>
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
<div class="deck-import-progress-track">
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
</div>
</div>
<div id="deck-import-result" style="display:none"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
</div>
</div>
</div>
</div><!-- /#deck-builder-page -->
<script>
// Set API base URL — when served from a different host, override this value
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckbuilder.js"></script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Auto Deck Generator V3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckgenerator.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
<div id="deckgen-page">
<!-- MAIN LAYOUT -->
<main class="dg-main">
<!-- LEFT: Configuration -->
<section class="dg-col-config">
<!-- Commander Search -->
<div class="dg-panel dg-panel-commander">
<div class="dg-panel-header">
<span class="dg-panel-title">Commander</span>
</div>
<div class="dg-panel-body">
<div class="dg-commander-search-wrap">
<input
type="text"
class="dg-input"
id="dg-commander-input"
placeholder="Search for a legendary creature..."
autocomplete="off"
/>
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
</div>
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
<div class="dg-commander-card">
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">&times;</button>
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
<div class="dg-commander-info">
<div class="dg-commander-name" id="dg-commander-name"></div>
<div class="dg-commander-type" id="dg-commander-type"></div>
<div class="dg-commander-colors" id="dg-commander-colors"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Strategy & Bracket -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Strategy &amp; Power</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<div class="dg-bracket-btns" id="dg-bracket-btns">
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
</div>
</div>
</div>
</div>
<!-- Deck Name -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Deck Name</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-deck-name"
placeholder="Auto-generated from commander name"
/>
</div>
</div>
<!-- Budget -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Budget</span>
</div>
<div class="dg-panel-body">
<div class="dg-budget-row">
<input
type="number"
class="dg-input dg-input-narrow"
id="dg-budget"
placeholder="No limit"
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
</div>
</div>
</div>
<!-- Options -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Options</span>
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
</div>
</div>
</div>
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">&#9889;</span> Generate Deck
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
</section>
<!-- RIGHT: Preview Results -->
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
</div>
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<div class="dg-results-header">
<div id="dg-strategy-bar"></div>
<div id="dg-stats-bar" class="dg-stats-bar"></div>
<div id="dg-sub-summary" style="display:none"></div>
<div class="dg-result-actions">
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; Save to Deck Builder</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">&#128722; Shopping</button>
</div>
</div>
<div id="dg-card-groups" class="dg-card-groups"></div>
</div>
</section>
</main>
<!-- TOAST CONTAINER -->
<div class="dg-toast-container" id="dg-toast-container"></div>
</div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckgenerator.js"></script>
</body>
</html>
+795
View File
@@ -0,0 +1,795 @@
/**
* Commander AI Lab — Auto Deck Generator V3 UI
* ═══════════════════════════════════════════════
*
* V3 Endpoints:
* GET /api/deck/v3/status — check V3 generator status
* GET /api/deck-generator/commander-search — autocomplete commanders
* POST /api/deck/v3/generate — generate deck (local AI structured output)
* POST /api/deck/v3/commit — generate + save to Deck Builder
* POST /api/deck/v3/export/csv — export as CSV
* POST /api/deck/v3/export/dck — export as Forge .dck
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
* POST /api/deck/v3/export/shopping — shopping list of missing cards
*/
const DeckGenerator = (() => {
'use strict';
const API_BASE = window.LAB_API_BASE || window.location.origin;
const DEBOUNCE_MS = 350;
const COLOR_MAP = {
W: { label: 'W', cls: 'dg-pip-W' },
U: { label: 'U', cls: 'dg-pip-U' },
B: { label: 'B', cls: 'dg-pip-B' },
R: { label: 'R', cls: 'dg-pip-R' },
G: { label: 'G', cls: 'dg-pip-G' },
C: { label: 'C', cls: 'dg-pip-C' },
};
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
const TYPE_ICONS = {
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
'Other': '🃏',
};
const STATUS_BADGES = {
owned: { label: 'Owned', cls: 'dg-badge-owned' },
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
missing: { label: 'Missing', cls: 'dg-badge-missing' },
};
// ── State ──────────────────────────────────────────────
let state = {
commander: null,
targetBracket: 3,
previewResult: null,
lastRequestBody: null,
isLoading: false,
v3Ready: false,
};
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
async function init() {
await checkV3Status();
bindEvents();
checkQueryParams();
}
async function checkV3Status() {
try {
const resp = await fetch(API_BASE + '/api/deck/v3/status');
const data = await resp.json();
state.v3Ready = data.initialized;
if (!state.v3Ready) {
// Warn but do not block — local/Ollama mode may still work
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
}
}
function bindEvents() {
// Commander search
const input = $('dg-commander-input');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = input.value.trim();
if (q.length < 2) { hideDropdown(); return; }
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
});
input.addEventListener('focus', () => {
const q = input.value.trim();
if (q.length >= 2) searchCommander(q);
});
document.addEventListener('click', (e) => {
const wrap = document.querySelector('.dg-commander-search-wrap');
if (wrap && !wrap.contains(e.target)) hideDropdown();
});
$('dg-commander-clear').addEventListener('click', clearCommander);
// Bracket buttons
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
btn.classList.add('dg-bracket-active');
state.targetBracket = parseInt(btn.dataset.bracket, 10);
});
});
// Generate & save
$('dg-preview-btn').addEventListener('click', generateDeck);
$('dg-save-btn').addEventListener('click', commitDeck);
$('dg-regenerate-btn').addEventListener('click', generateDeck);
// Export buttons
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
const params = new URLSearchParams(window.location.search);
const name = params.get('commander');
if (name) {
$('dg-commander-input').value = name;
searchCommander(name);
}
}
// ── Build Request Body ──────────────────────────────────
function buildRequestBody() {
const budget = parseFloat($('dg-budget').value) || null;
return {
commander_name: state.commander.name,
strategy: $('dg-strategy').value.trim(),
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: omitCards.slice(),
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
deck_name: $('dg-deck-name').value.trim() || '',
};
}
// ── Commander Search ────────────────────────────────────
function positionDropdown() {
const input = $('dg-commander-input');
const dropdown = $('dg-commander-dropdown');
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
}
async function searchCommander(q) {
const dropdown = $('dg-commander-dropdown');
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
dropdown.classList.add('open');
positionDropdown();
try {
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
const data = await resp.json();
renderDropdown(data.results || []);
} catch (e) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
}
}
function renderDropdown(results) {
const dropdown = $('dg-commander-dropdown');
if (results.length === 0) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
return;
}
dropdown.innerHTML = results.map((r, idx) => {
const colors = (r.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const badge = r.in_collection
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
const imgTag = imgSrc
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
+ imgTag
+ '<div class="dg-dropdown-item-info">'
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
+ '</div>'
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
+ badge
+ '</div>';
}).join('');
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
el.addEventListener('click', () => {
selectCommander(results[parseInt(el.dataset.idx, 10)]);
hideDropdown();
});
});
}
function selectCommander(cmdr) {
state.commander = cmdr;
$('dg-commander-input').style.display = 'none';
$('dg-commander-selected').style.display = 'block';
$('dg-commander-name').textContent = cmdr.name;
$('dg-commander-type').textContent = cmdr.type_line || '';
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const img = $('dg-commander-img');
if (cmdr.image_url) {
var imgUrl = cmdr.image_url;
if (imgUrl.indexOf('version=small') !== -1) {
imgUrl = imgUrl.replace('version=small', 'version=normal');
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
imgUrl += '&version=normal';
} else if (imgUrl.indexOf('version=') === -1) {
imgUrl += '?version=normal';
}
img.src = imgUrl;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
state.commander = null;
$('dg-commander-input').style.display = '';
$('dg-commander-input').value = '';
$('dg-commander-selected').style.display = 'none';
$('dg-preview-btn').disabled = true;
}
function hideDropdown() {
$('dg-commander-dropdown').classList.remove('open');
}
// -- Conversation Log Helper --
function logMessage(msg, type = 'info') {
const log = $('dg-conversation-log');
if (!log) return;
const entry = document.createElement('div');
entry.className = 'dg-log-entry dg-log-' + type;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
const log = $('dg-conversation-log');
if (!log) return;
log.innerHTML = '';
}
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to Claude AI...', 'info');
if (!state.commander) { toast('Select a commander first', 'error'); return; }
// Non-blocking warning only — local Ollama mode does not require v3Ready
if (!state.v3Ready) {
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
}
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
state.lastRequestBody = body;
try {
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Generation failed');
}
state.previewResult = await resp.json();
logMessage('AI response received! Processing deck data...', 'success');
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
}
}
function setLoading(loading, text) {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'block';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
// ── Render Results ──────────────────────────────────────
function renderResults(result) {
$('dg-loading').style.display = 'none';
$('dg-results').style.display = 'block';
$('dg-preview-btn').disabled = false;
renderStrategyBar(result);
renderStatsBar(result);
renderSubstitutionSummary(result);
renderCardGroups(result);
renderUseInGameButton(result);
}
function renderUseInGameButton(result) {
// Only show when running inside the LAN client iframe
if (window.parent === window) return;
var existing = document.getElementById('dg-export-game-btn');
if (existing) existing.remove();
var btn = document.createElement('button');
btn.id = 'dg-export-game-btn';
btn.className = 'dg-btn dg-btn-preview';
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
btn.innerHTML = '<span class="dg-btn-icon">&#x1F3AE;</span> Use in Game';
btn.addEventListener('click', function() {
var cards = (result.cards || []).map(function(c) {
return { name: c.name, quantity: c.count || 1, is_commander: false };
});
if (result.commander) {
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
}
window.parent.postMessage({
type: 'DECK_BUILDER_EXPORT',
deck: cards,
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
commander: result.commander ? result.commander.name : '',
}, '*');
toast('Deck sent to game setup!', 'success');
});
$('dg-results').appendChild(btn);
}
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
if (!bar) return;
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
bar.innerHTML = '<div class="dg-strategy-info">'
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
+ '</div>'
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
+ (bracket.game_changers && bracket.game_changers.length
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
: '');
}
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
if (!container) return;
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
chipHtml('Lands', stats.land_count || 0, ''),
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
];
container.innerHTML = chips.join('');
}
function chipHtml(label, num, cls) {
return '<div class="dg-stat-chip ' + cls + '">'
+ '<span class="dg-stat-num">' + num + '</span>'
+ '<span class="dg-stat-label">' + label + '</span>'
+ '</div>';
}
function renderSubstitutionSummary(result) {
const el = $('dg-sub-summary');
const sub = result.substitution_stats;
if (!sub) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '<div class="dg-sub-bar">'
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
+ '</div>';
}
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
const groups = {};
for (const card of cards) {
const cat = card.category || 'Other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(card);
}
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
for (const t of Object.keys(groups)) {
if (!sortedTypes.includes(t)) sortedTypes.push(t);
}
let html = '';
if (commander) {
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header">'
+ '<span class="dg-group-title">⚔ Commander</span>'
+ '<span class="dg-group-count">1</span>'
+ '</div>'
+ '<div class="dg-group-body">'
+ '<div class="dg-card-row dg-card-row-commander">'
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
+ '</div>'
+ '</div></div>';
}
for (const type of sortedTypes) {
const groupCards = groups[type];
if (!groupCards || groupCards.length === 0) continue;
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const icon = TYPE_ICONS[type] || '🃏';
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header" data-type="' + type + '">'
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
+ '</div>'
+ '<div class="dg-group-body" data-group="' + type + '">';
for (const card of groupCards) {
html += renderCardRow(card);
}
html += '</div></div>';
}
container.innerHTML = html;
container.querySelectorAll('.dg-group-header').forEach(header => {
header.addEventListener('click', () => {
const type = header.dataset.type;
if (!type) return;
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
if (body) body.classList.toggle('collapsed');
});
});
}
function renderCardRow(card) {
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
const roleTags = (card.role_tags || []).slice(0, 3)
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
.join('');
let subHtml = '';
if (card.status === 'substituted' && card.selected_substitute) {
subHtml = '<div class="dg-card-sub-line">'
+ '<span class="dg-sub-arrow">→</span> '
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
+ '</div>';
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
+ '<span class="dg-sub-label">Alternatives:</span> '
+ card.alternatives.slice(0, 3).map(a =>
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
+ '</span>'
).join('')
+ '</div>';
}
const price = card.estimated_price_usd
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
: '';
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
+ '<div class="dg-card-row-main">'
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
+ statusBadge
+ price
+ '</div>'
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
+ subHtml
+ '</div>';
}
// ── Commit Deck ─────────────────────────────────────────
async function commitDeck() {
if (!state.commander) { toast('No commander selected', 'error'); return; }
$('dg-save-btn').disabled = true;
$('dg-save-btn').textContent = 'Saving...';
try {
const body = state.lastRequestBody || buildRequestBody();
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Save failed');
}
const result = await resp.json();
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
setTimeout(() => {
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
}, 1200);
} catch (e) {
toast(e.message || 'Save failed', 'error');
} finally {
$('dg-save-btn').disabled = false;
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
}
}
// ── Exports ─────────────────────────────────────────────
async function exportDeck(format) {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
a.click();
URL.revokeObjectURL(url);
toast('Exported ' + format.toUpperCase(), 'success');
} catch (e) {
toast(e.message || 'Export failed', 'error');
}
}
async function exportMoxfield() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.content) {
await navigator.clipboard.writeText(data.content);
toast('Moxfield deck copied to clipboard', 'success');
}
} catch (e) {
toast('Moxfield export failed', 'error');
}
}
async function exportShopping() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
const list = data.shopping_list || [];
if (list.length === 0) {
toast('All cards are owned — nothing to buy', 'success');
return;
}
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
for (const item of list) {
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
}
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
await navigator.clipboard.writeText(lines.join('\n'));
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
} catch (e) {
toast('Shopping list failed', 'error');
}
}
// ── Toast ───────────────────────────────────────────────
function toast(msg, type) {
type = type || 'info';
const container = $('dg-toast-container');
const el = document.createElement('div');
el.className = 'dg-toast dg-toast-' + type;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Helpers ─────────────────────────────────────────────
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return { init };
})();
document.addEventListener('DOMContentLoaded', () => {
DeckGenerator.init();
});
+25735
View File
File diff suppressed because it is too large. Load diff
+955
View File
@@ -0,0 +1,955 @@
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Global Design System
Dark & Polished · Premium Dashboard Aesthetic
═══════════════════════════════════════════════════════════ */
/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ──────────────────────────────────────── */
:root {
/* Surfaces — layered dark with subtle warmth */
--bg: #0c0e14;
--surface-0: #12141c;
--surface-1: #171a24;
--surface-2: #1c1f2b;
--surface-3: #222533;
--surface-hover: #252839;
/* Borders */
--border: #2a2d3a;
--border-subtle: #1e2130;
--border-focus: #4a6fa5;
/* Text */
--text: #e1e4eb;
--text-secondary:#8b90a0;
--text-tertiary: #5c6070;
--text-inverse: #0c0e14;
/* Accent — cool blue with subtle depth */
--accent: #5b9ef0;
--accent-hover: #7ab4ff;
--accent-dim: rgba(91, 158, 240, 0.12);
--accent-glow: rgba(91, 158, 240, 0.25);
/* Semantic */
--success: #4ade80;
--success-dim: rgba(74, 222, 128, 0.12);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--gold: #fcd34d;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
--shadow-glow: 0 0 20px var(--accent-dim);
/* Transitions */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 180ms;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
--sp-16: 64px;
/* Layout */
--content-max: 1280px;
--sidebar-width: 280px;
--nav-height: 56px;
}
/* ── Base ───────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
h1, h2, h3, h4, h5, h6 {
color: #fff;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 24px; }
h2 { font-size: 18px; }
h3 { font-size: 16px; }
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
a:hover { color: var(--accent-hover); }
img, svg { display: block; max-width: 100%; }
button { cursor: pointer; font: inherit; }
input, select, textarea { font: inherit; color: inherit; }
table { border-collapse: collapse; width: 100%; }
::selection {
background: var(--accent-dim);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* ── Navigation ─────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: var(--nav-height);
padding: 0 var(--sp-6);
background: rgba(12, 14, 20, 0.85);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
}
.nav-brand svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.nav-links {
display: flex;
align-items: center;
gap: var(--sp-1);
overflow-x: auto;
scrollbar-width: none;
}
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--duration) var(--ease);
text-decoration: none;
}
.nav-links a:hover {
color: var(--text);
background: var(--surface-2);
}
.nav-links a.active {
color: #fff;
background: var(--accent-dim);
}
.nav-links a .nav-icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.nav-links a.active .nav-icon,
.nav-links a:hover .nav-icon {
opacity: 1;
}
/* ── Page Layout ────────────────────────────────────────── */
.page {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-8) var(--sp-6);
}
.page-header {
margin-bottom: var(--sp-8);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
}
/* ── Cards / Panels ─────────────────────────────────────── */
.panel {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-6);
margin-bottom: var(--sp-6);
transition: border-color var(--duration) var(--ease);
}
.panel:hover {
border-color: var(--border-focus);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Stat Cards ─────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-6);
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-5);
transition: all var(--duration) var(--ease);
}
.stat-card:hover {
border-color: var(--border-focus);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: var(--sp-1);
}
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all var(--duration) var(--ease);
white-space: nowrap;
line-height: 1.4;
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 14px var(--accent-glow);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--text-tertiary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px 12px;
}
.btn-ghost:hover {
color: var(--text);
background: var(--surface-2);
}
.btn-success { background: var(--success); color: var(--text-inverse); }
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
.btn-purple { background: var(--purple); color: var(--text-inverse); }
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-lg { padding: 12px 24px; font-size: 15px; }
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
/* ── Forms ──────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 9px 14px;
font-size: 14px;
color: var(--text);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all var(--duration) var(--ease);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
outline: none;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--text-tertiary);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-4);
}
/* ── Progress Bar ───────────────────────────────────────── */
.progress-container {
margin-top: var(--sp-4);
padding: var(--sp-4);
background: var(--surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.progress-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.progress-track {
height: 6px;
background: var(--surface-3);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
border-radius: 3px;
transition: width 0.3s var(--ease);
}
.progress-detail {
margin-top: var(--sp-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
line-height: 1.6;
}
/* ── Phase Badge ────────────────────────────────────────── */
.badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
.badge-idle::before { background: var(--text-tertiary); }
.badge-running { background: var(--accent-dim); color: var(--accent); }
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-success::before { background: var(--success); }
.badge-error { background: var(--error-dim); color: var(--error); }
.badge-error::before { background: var(--error); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-warning::before { background: var(--warning); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Empty State ────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-12) var(--sp-6);
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--sp-4);
opacity: 0.3;
}
.empty-state p {
font-size: 14px;
margin-bottom: var(--sp-3);
}
/* ── Tables ─────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
background: var(--surface-0);
}
.data-table tbody td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.data-table tbody tr:hover {
background: var(--surface-2);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* ── Tags ───────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-blue { background: var(--accent-dim); color: var(--accent); }
.tag-green { background: var(--success-dim); color: var(--success); }
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
.tag-red { background: var(--error-dim); color: var(--error); }
.tag-purple { background: var(--purple-dim); color: var(--purple); }
/* ── Toast / Notification ───────────────────────────────── */
.toast {
position: fixed;
bottom: var(--sp-6);
right: var(--sp-6);
padding: var(--sp-3) var(--sp-5);
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 13px;
color: var(--text);
z-index: 1000;
animation: toast-in 0.3s var(--ease);
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--error); }
.toast-warning { border-left: 3px solid var(--warning); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading Spinner ────────────────────────────────────── */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Divider ────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--border);
margin: var(--sp-5) 0;
}
/* ── Utility Classes ────────────────────────────────────── */
.hidden { display: none !important; }
.text-accent { color: var(--accent); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-error { color: var(--error); }
.text-muted { color: var(--text-secondary); }
.text-faint { color: var(--text-tertiary); }
.text-mono { font-family: var(--font-mono); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-6 { margin-bottom: var(--sp-6); }
.gap-2 { gap: var(--sp-2); }
.gap-4 { gap: var(--sp-4); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.grid { display: grid; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: var(--sp-5) var(--sp-4);
}
.nav {
padding: 0 var(--sp-4);
}
.nav-brand {
margin-right: var(--sp-4);
font-size: 13px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.stat-grid {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════
PREMIUM UPGRADE LAYER
═══════════════════════════════════════════════════════════ */
/* ── Ambient background glow (body) ─────────────────────── */
body {
background-image:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
background-attachment: fixed;
}
/* ── Page title: subtle gradient text ───────────────────── */
.page-header h1 {
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Nav: stronger glass + accent underline ─────────────── */
.nav {
background: rgba(9,11,16,0.80);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
border-bottom-color: rgba(255,255,255,0.06);
}
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
/* ── Panels: gradient border on hover ───────────────────── */
.panel {
position: relative;
transition: border-color var(--duration) var(--ease),
box-shadow var(--duration) var(--ease);
}
.panel::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration) var(--ease);
}
.panel:hover::before { opacity: 1; }
.panel:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
}
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
.stat-card {
position: relative;
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
pointer-events: none;
}
.stat-card:hover {
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
transform: translateY(-2px);
}
/* ── Buttons: gradient fills + inner highlight ───────────── */
.btn { position: relative; overflow: hidden; }
.btn::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
border-radius: inherit;
pointer-events: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #4a8de0);
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
}
.btn-success {
background: linear-gradient(135deg, var(--success), #38c46c);
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
}
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
.btn-purple {
background: linear-gradient(135deg, var(--purple), #9171f0);
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
}
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
/* ── Tags: subtle colored border ────────────────────────── */
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
/* ── Progress fill: glow ─────────────────────────────────── */
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
/* ── Badge running: glow dot ─────────────────────────────── */
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
/* ── Toast: glassmorphism + colored glow ─────────────────── */
.toast { backdrop-filter: blur(12px); }
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
/* ── Divider: fade-out gradient ──────────────────────────── */
.divider {
background: linear-gradient(90deg, transparent, var(--border), transparent);
}
/* ── Inputs: inset shadow for depth ─────────────────────── */
.form-input, .form-select, .form-textarea {
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
}
/* ══════════════════════════════════════════════════════════════
Tooltip System (.tip-icon + data-tip)
Shared across all pages — do not duplicate in page-specific CSS
══════════════════════════════════════════════════════════════ */
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
font-style: normal;
cursor: default;
position: relative;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tip-icon:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tip-icon::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-0, #1a1a2e);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #333);
border-radius: var(--radius-md, 6px);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
width: 240px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
text-align: left;
text-transform: none;
letter-spacing: 0;
}
.tip-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border, #333);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
}
.tip-icon.tip-below::after {
bottom: auto;
top: calc(100% + 8px);
}
.tip-icon.tip-below::before {
bottom: auto;
top: calc(100% + 3px);
border-top-color: transparent;
border-bottom-color: var(--border, #333);
}
+944
View File
@@ -0,0 +1,944 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MTG Commander Battlefield</title>
<link rel="stylesheet" href="style.css?v=20260403_0300">
</head>
<body>
<!-- ====================================================
SETUP SCREEN
==================================================== -->
<div id="setup-screen">
<!-- Populated by ui.js initSetupScreen() -->
</div>
<!-- ====================================================
TABLE SURFACE — 4-Quadrant Battlefield
==================================================== -->
<div class="table-surface" id="game-battlefield" style="display:none;">
<!-- TOP BAR: Phase tracker + controls -->
<div id="top-bar">
<span class="top-bar-logo">Commander</span>
<div class="turn-counter-badge" id="turn-counter">Turn <span>1</span></div>
<div class="active-player-indicator" id="active-player-indicator">
<span class="active-player-dot" id="active-dot" style="background:#c0392b;"></span>
Player 1's Turn
</div>
<!-- Phase track -->
<div class="phase-track-wrapper">
<div class="phase-track" id="phase-track"></div>
</div>
<!-- Phase/turn action buttons -->
<div class="top-bar-actions">
<button class="btn-phase" onclick="nextPhase()" title="Advance to next phase (N)">Next Phase</button>
<button class="btn-phase btn-next-turn" onclick="nextTurn()" title="End turn, advance to next player (T)">Next Turn</button>
</div>
<!-- Utility buttons -->
<div class="top-bar-utils">
<button class="btn-util" id="btn-undo" onclick="undo()" title="Undo (Ctrl+Z)" disabled>&#x21A9;</button>
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>&#x21AA;</button>
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">&#x2747;</button>
<button class="btn-util" onclick="openDiceModal()" title="Dice &amp; Coins (G)">&#x1F3B2;</button>
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</button>
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">&#x1F0CF;</button>
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">&#x2715;</button>
</div>
<!-- Storm counter -->
<span id="storm-badge" style="display:none;font-size:10px;color:var(--mtg-gold);background:rgba(200,168,67,0.15);border:1px solid rgba(200,168,67,0.3);border-radius:4px;padding:2px 7px;white-space:nowrap;">Storm: 0</span>
</div>
<!-- DIVIDER LINES -->
<div class="divider-h"></div>
<div class="divider-v"></div>
<!-- CORNER ACCENTS -->
<div class="corner-accent tl"></div>
<div class="corner-accent tr"></div>
<div class="corner-accent bl"></div>
<div class="corner-accent br"></div>
<!-- ================================================
4-QUADRANT BATTLEFIELD
================================================ -->
<div class="battlefield">
<!-- PLAYER 1: Top-Left (Crimson) -->
<div class="player-area p1" id="player-area-1">
<span class="player-label" id="player-label-1">Player 1</span>
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
<div class="zone-row" id="zone-row-1">
<div class="zone library" id="lib-zone-1" data-player="1" data-zone="library"
onclick="openZoneModal(0,'library')" data-tooltip="Library">
<span class="card-count" id="lib-count-1">0</span>
<span class="zone-icon">&#x1F4DA;</span>
<span class="zone-name">Library</span>
</div>
<div class="zone exile" id="exile-zone-1" data-player="1" data-zone="exile"
onclick="openZoneModal(0,'exile')" data-tooltip="Exile">
<span class="card-count" id="exile-count-1" style="display:none;">0</span>
<span class="zone-icon">&#x2B21;</span>
<span class="zone-name">Exile</span>
</div>
<div class="zone graveyard" id="gv-zone-1" data-player="1" data-zone="graveyard"
onclick="openZoneModal(0,'graveyard')" data-tooltip="Graveyard">
<span class="card-count" id="gv-count-1" style="display:none;">0</span>
<span class="zone-icon">&#x2620;</span>
<span class="zone-name">Grave</span>
</div>
<div class="zone hand" id="hand-zone-1" data-player="1" data-zone="hand"
onclick="openZoneModal(0,'hand')" data-tooltip="Hand">
<span class="zone-icon">&#x1F0CF;</span>
<span class="zone-name">Hand (<span id="hand-count-1">0</span>)</span>
</div>
</div>
<!-- Commander Row -->
<div class="commander-row" id="cmd-row-1">
<div class="zone commander" id="cmd-zone-1" data-player="1" data-zone="command"
onclick="openZoneModal(0,'command')" data-tooltip="Command Zone">
<span class="zone-icon">&#x1F451;</span>
<span class="zone-name">Commander</span>
</div>
</div>
<!-- Battle Zone -->
<div class="battle-zone" id="battle-zone-1">
<div class="battle-zone-inner"></div>
<span class="battle-zone-label">Battlefield</span>
<!-- Battlefield cards injected here -->
</div>
</div>
<!-- PLAYER 2: Top-Right (Sapphire) -->
<div class="player-area p2" id="player-area-2">
<span class="player-label" id="player-label-2">Player 2</span>
<div class="zone-row" id="zone-row-2">
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
onclick="openZoneModal(1,'library')" data-tooltip="Library">
<span class="card-count" id="lib-count-2">0</span>
<span class="zone-icon">&#x1F4DA;</span>
<span class="zone-name">Library</span>
</div>
<div class="zone exile" id="exile-zone-2" data-player="2" data-zone="exile"
onclick="openZoneModal(1,'exile')" data-tooltip="Exile">
<span class="card-count" id="exile-count-2" style="display:none;">0</span>
<span class="zone-icon">&#x2B21;</span>
<span class="zone-name">Exile</span>
</div>
<div class="zone graveyard" id="gv-zone-2" data-player="2" data-zone="graveyard"
onclick="openZoneModal(1,'graveyard')" data-tooltip="Graveyard">
<span class="card-count" id="gv-count-2" style="display:none;">0</span>
<span class="zone-icon">&#x2620;</span>
<span class="zone-name">Grave</span>
</div>
<div class="zone hand" id="hand-zone-2" data-player="2" data-zone="hand"
onclick="openZoneModal(1,'hand')" data-tooltip="Hand">
<span class="zone-icon">&#x1F0CF;</span>
<span class="zone-name">Hand (<span id="hand-count-2">0</span>)</span>
</div>
</div>
<div class="commander-row" id="cmd-row-2">
<div class="zone commander" id="cmd-zone-2" data-player="2" data-zone="command"
onclick="openZoneModal(1,'command')" data-tooltip="Command Zone">
<span class="zone-icon">&#x1F451;</span>
<span class="zone-name">Commander</span>
</div>
</div>
<div class="battle-zone" id="battle-zone-2">
<div class="battle-zone-inner"></div>
<span class="battle-zone-label">Battlefield</span>
</div>
</div>
<!-- PLAYER 3: Bottom-Left (Emerald) -->
<div class="player-area p3" id="player-area-3">
<!-- Battle zone on top for bottom players -->
<div class="battle-zone" id="battle-zone-3">
<div class="battle-zone-inner"></div>
<span class="battle-zone-label">Battlefield</span>
</div>
<div class="commander-row" id="cmd-row-3">
<div class="zone commander" id="cmd-zone-3" data-player="3" data-zone="command"
onclick="openZoneModal(2,'command')" data-tooltip="Command Zone">
<span class="zone-icon">&#x1F451;</span>
<span class="zone-name">Commander</span>
</div>
</div>
<div class="zone-row" id="zone-row-3">
<div class="zone library" id="lib-zone-3" data-player="3" data-zone="library"
onclick="openZoneModal(2,'library')" data-tooltip="Library">
<span class="card-count" id="lib-count-3">0</span>
<span class="zone-icon">&#x1F4DA;</span>
<span class="zone-name">Library</span>
</div>
<div class="zone exile" id="exile-zone-3" data-player="3" data-zone="exile"
onclick="openZoneModal(2,'exile')" data-tooltip="Exile">
<span class="card-count" id="exile-count-3" style="display:none;">0</span>
<span class="zone-icon">&#x2B21;</span>
<span class="zone-name">Exile</span>
</div>
<div class="zone graveyard" id="gv-zone-3" data-player="3" data-zone="graveyard"
onclick="openZoneModal(2,'graveyard')" data-tooltip="Graveyard">
<span class="card-count" id="gv-count-3" style="display:none;">0</span>
<span class="zone-icon">&#x2620;</span>
<span class="zone-name">Grave</span>
</div>
<div class="zone hand" id="hand-zone-3" data-player="3" data-zone="hand"
onclick="openZoneModal(2,'hand')" data-tooltip="Hand">
<span class="zone-icon">&#x1F0CF;</span>
<span class="zone-name">Hand (<span id="hand-count-3">0</span>)</span>
</div>
</div>
<span class="player-label" id="player-label-3">Player 3</span>
</div>
<!-- PLAYER 4: Bottom-Right (Gold) -->
<div class="player-area p4" id="player-area-4">
<div class="battle-zone" id="battle-zone-4">
<div class="battle-zone-inner"></div>
<span class="battle-zone-label">Battlefield</span>
</div>
<div class="commander-row" id="cmd-row-4">
<div class="zone commander" id="cmd-zone-4" data-player="4" data-zone="command"
onclick="openZoneModal(3,'command')" data-tooltip="Command Zone">
<span class="zone-icon">&#x1F451;</span>
<span class="zone-name">Commander</span>
</div>
</div>
<div class="zone-row" id="zone-row-4">
<div class="zone library" id="lib-zone-4" data-player="4" data-zone="library"
onclick="openZoneModal(3,'library')" data-tooltip="Library">
<span class="card-count" id="lib-count-4">0</span>
<span class="zone-icon">&#x1F4DA;</span>
<span class="zone-name">Library</span>
</div>
<div class="zone exile" id="exile-zone-4" data-player="4" data-zone="exile"
onclick="openZoneModal(3,'exile')" data-tooltip="Exile">
<span class="card-count" id="exile-count-4" style="display:none;">0</span>
<span class="zone-icon">&#x2B21;</span>
<span class="zone-name">Exile</span>
</div>
<div class="zone graveyard" id="gv-zone-4" data-player="4" data-zone="graveyard"
onclick="openZoneModal(3,'graveyard')" data-tooltip="Graveyard">
<span class="card-count" id="gv-count-4" style="display:none;">0</span>
<span class="zone-icon">&#x2620;</span>
<span class="zone-name">Grave</span>
</div>
<div class="zone hand" id="hand-zone-4" data-player="4" data-zone="hand"
onclick="openZoneModal(3,'hand')" data-tooltip="Hand">
<span class="zone-icon">&#x1F0CF;</span>
<span class="zone-name">Hand (<span id="hand-count-4">0</span>)</span>
</div>
</div>
<span class="player-label" id="player-label-4">Player 4</span>
</div>
</div><!-- /.battlefield -->
<!-- LIFE PANELS OVERLAY (top-most layer) -->
<div id="life-panels-overlay">
<div class="life-panel lp-p1" id="life-panel-1">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-1">
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">&#x2212;</button>
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-0" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-0" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p2" id="life-panel-2">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-2">
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">&#x2212;</button>
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-1" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-1" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p3" id="life-panel-3">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-3">
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">&#x2212;</button>
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-2" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-2" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p4" id="life-panel-4">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-4">
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">&#x2212;</button>
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-3" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-3" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div>
</div>
<!-- COMBAT SVG OVERLAY (arrows) -->
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(211,32,42,0.7)"/>
</marker>
</defs>
<g id="combat-arrows-svg"></g>
</svg>
<!-- FLOATING LOG -->
<div id="floating-log" class="floating-log"></div>
<!-- SHORTCUT HINT -->
<div class="shortcut-hint"></div>
<!-- CARD SEARCH SIDEBAR (right side, collapsible) -->
<div id="card-search-sidebar" class="collapsed">
<div class="sidebar-header">
<span class="sidebar-title">Card Search</span>
<button class="sidebar-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Close search">&#x2715;</button>
</div>
<div class="search-input-wrap">
<input class="search-input" id="card-search-input" type="text" placeholder="Search Scryfall..." autocomplete="off"/>
</div>
<div class="search-status" id="search-status">Type to search cards...</div>
<div class="card-results" id="card-results"></div>
</div>
<!-- Sidebar edge tab -->
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">&#x2039;</div>
<!-- LEFT ACTION PANEL (triggers + quick actions) -->
<div id="left-action-panel" class="collapsed">
<div class="left-panel-header">
<span class="left-panel-title">Triggers <span id="left-panel-trigger-count" class="left-panel-count">0</span></span>
<button class="sidebar-toggle" onclick="toggleLeftPanel()" title="Close panel">&#x2715;</button>
</div>
<div class="left-panel-actions">
<button class="left-panel-btn" onclick="openTokenModal((typeof gameState !== 'undefined' && gameState) ? (gameState.currentPlayerIndex || 0) : 0)" title="Create token">&#x2728; Create Token</button>
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">&#x21C4; Auto-Arrange</button>
</div>
<div class="left-panel-body" id="left-panel-triggers">
<div class="left-panel-empty">No pending triggers.</div>
</div>
</div>
<!-- Left panel edge tab -->
<div id="left-panel-edge-tab" class="left-panel-edge-tab" onclick="toggleLeftPanel()" title="Toggle quick actions">&#x203A;</div>
</div><!-- /.table-surface / #game-battlefield -->
<!-- ====================================================
CONTEXT MENU
==================================================== -->
<div id="context-menu"></div>
<!-- ====================================================
FLOATING CARD PREVIEW
==================================================== -->
<div id="card-preview">
<img src="" alt="Card Preview"/>
<div class="card-preview-pt-overlay" id="card-preview-pt-overlay"></div>
<div class="card-preview-info">
<div class="card-preview-name"></div>
<div class="card-preview-type"></div>
<div class="card-preview-oracle"></div>
<div class="card-preview-pt"></div>
</div>
</div>
<!-- ====================================================
ZONE MODAL
==================================================== -->
<div id="zone-modal">
<div class="zone-modal-card">
<div class="zone-modal-header">
<span class="zone-modal-title" id="zone-modal-title">Player Zones</span>
<button class="btn-close" onclick="closeZoneModal()">&#x2715;</button>
</div>
<div class="zone-modal-tabs" id="zone-modal-tabs">
<!-- Populated by renderZoneModal() -->
</div>
<div class="zone-modal-body" id="zone-modal-body">
<!-- Populated by renderZoneModal() -->
</div>
</div>
</div>
<!-- ====================================================
DICE MODAL
==================================================== -->
<div id="dice-modal">
<div class="dice-modal-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.08em;">Dice &amp; Coins</span>
<button class="btn-close" onclick="closeDiceModal()">&#x2715;</button>
</div>
<div class="dice-result-display" id="dice-result-display">&#x2014;</div>
<div class="dice-label" id="dice-label">Choose a die</div>
<div class="dice-buttons">
<button class="btn-dice" onclick="rollDice(4)">d4</button>
<button class="btn-dice" onclick="rollDice(6)">d6</button>
<button class="btn-dice" onclick="rollDice(8)">d8</button>
<button class="btn-dice" onclick="rollDice(10)">d10</button>
<button class="btn-dice" onclick="rollDice(12)">d12</button>
<button class="btn-dice" onclick="rollDice(20)">d20</button>
<button class="btn-dice" onclick="rollDice(100)">d100</button>
<button class="btn-dice coin" onclick="flipCoin()">Coin Flip</button>
</div>
</div>
</div>
<!-- ====================================================
TOKEN MODAL
==================================================== -->
<div id="token-modal">
<div class="token-modal-card">
<div class="modal-header">
<span class="modal-title">Create Token</span>
<button class="btn-close" onclick="closeTokenModal()">&#x2715;</button>
</div>
<div class="modal-body" id="token-modal-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;">
<div>
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Token Name</label>
<input class="game-input" id="token-name" type="text" placeholder="e.g. 1/1 White Soldier"/>
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Type Line</label>
<input class="game-input" id="token-type" type="text" placeholder="Creature — Soldier"/>
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Power/Toughness</label>
<input class="game-input" id="token-pt" type="text" placeholder="1/1"/>
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Count</label>
<input class="game-input" id="token-count" type="number" value="1" min="1" max="20"/>
</div>
</div>
<div style="margin-bottom:10px;">
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Keywords (comma-separated)</label>
<input class="game-input" id="token-keywords" type="text" placeholder="Flying, Vigilance"/>
</div>
<div style="margin-bottom:12px;">
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Player</label>
<select class="game-input" id="token-player-select" style="height:28px;">
<option value="0">Player 1</option>
<option value="1">Player 2</option>
<option value="2">Player 3</option>
<option value="3">Player 4</option>
</select>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" onclick="createTokenFromModal()">Create Token(s)</button>
<button class="btn btn-cancel" onclick="closeTokenModal()">Cancel</button>
</div>
</div>
</div>
</div>
<!-- ====================================================
DECK MODAL
==================================================== -->
<div id="deck-modal">
<div class="deck-modal-card">
<div class="modal-header">
<span class="modal-title">Deck Manager</span>
<button class="btn-close" onclick="closeDeckModal()">&#x2715;</button>
</div>
<div class="modal-body" id="deck-modal-body">
<!-- Populated by renderDeckModalContent() -->
</div>
</div>
</div>
<!-- ====================================================
COMMANDER DAMAGE MATRIX MODAL
==================================================== -->
<div id="cmd-dmg-matrix-modal">
<!-- Populated by renderCmdDmgMatrix() -->
</div>
<!-- ====================================================
TRIGGER STACK PANEL
==================================================== -->
<div id="trigger-stack-panel">
<div class="trigger-stack-card">
<div class="trigger-stack-header">
<span class="trigger-stack-title">Trigger Stack</span>
<button class="btn-close" style="font-size:14px;width:20px;height:20px;" onclick="closeTriggerStack()">&#x2715;</button>
</div>
<div class="trigger-stack-body" id="trigger-stack-body">
<div class="trigger-stack-empty">No pending triggers.</div>
</div>
<div class="trigger-stack-actions">
<button class="btn-resolve" onclick="resolveNextTrigger()">Resolve Next</button>
<button class="btn-resolve-all" onclick="resolveAllTriggers()">Resolve All</button>
</div>
</div>
</div>
<!-- ====================================================
SETTINGS PANEL
==================================================== -->
<div id="settings-panel">
<div class="modal-header" style="flex-shrink:0;">
<span class="modal-title">Settings</span>
<button class="btn-close" onclick="toggleSettingsPanel()">&#x2715;</button>
</div>
<div class="settings-body" id="settings-body" style="flex:1;overflow-y:auto;padding:12px;">
<!-- Populated by renderSettingsPanel() -->
</div>
<div style="padding:10px 14px;border-top:1px solid var(--color-border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;">
<button class="btn btn-primary" onclick="openDiceModal()">&#x1F3B2; Dice</button>
<button class="btn btn-gold" onclick="openCmdDmgMatrix()">CMD Damage Matrix</button>
<button class="btn btn-cancel" onclick="checkStateBasedActions()">Check SBA</button>
<button class="btn btn-cancel" onclick="autoArrangeBattlefield()">Auto-Arrange</button>
</div>
</div>
<!-- ====================================================
PRIORITY BANNER
==================================================== -->
<div id="priority-banner">
<span class="priority-text" id="priority-text">Waiting for priority...</span>
<button class="btn-priority-pass" onclick="passPriority()">Pass Priority</button>
<button class="btn-close" style="font-size:13px;" onclick="closePriorityWindow()">&#x2715;</button>
</div>
<!-- ====================================================
LEGAL ACTIONS BAR
==================================================== -->
<div id="legal-actions-bar">
<!-- Populated by renderLegalActionsBar() -->
</div>
<!-- ====================================================
UNDO HISTORY PANEL
==================================================== -->
<div id="undo-history-panel">
<!-- Populated by renderUndoHistory() -->
</div>
<!-- ====================================================
MANA COLOR PICKER (dynamically appended) -->
<!-- Zone card context menu (dynamically appended) -->
<!-- SBA alert panel (dynamically appended) -->
<!-- Set-life dialog (dynamically appended) -->
<!-- ====================================================
SCRIPTS
Load order matters:
1. precons.js — static PRECON_DATA fallback
2. engine.js — game state & core logic
3. ai-bridge.js — backend connector & DeckSourceClient
4. precon-loader.js — startGameWithDecks() + loadPreconDeckFromDck()
5. player-panels.js — player panel rendering
6. ui.js — setup screen, deck picker, UI wiring
==================================================== -->
<script src="precons.js?v=20260403_0300"></script>
<script src="engine.js?v=20260403_0300"></script>
<script src="ai-bridge.js?v=20260403_0300"></script>
<script src="precon-loader.js?v=20260403_0300"></script>
<script src="player-panels.js?v=20260403_0300"></script>
<script src="ui.js?v=20260403_0300"></script>
<script>
// ============================================================
// STARTUP & BRIDGE FUNCTIONS
// ============================================================
// Token creation from modal
function createTokenFromModal() {
if (!gameState) return;
const name = document.getElementById('token-name').value.trim() || '1/1 Token';
const typeLine = document.getElementById('token-type').value.trim() || 'Creature';
const pt = document.getElementById('token-pt').value.trim() || '1/1';
const count = parseInt(document.getElementById('token-count').value) || 1;
const keywords = document.getElementById('token-keywords').value.trim();
const playerIdx = parseInt(document.getElementById('token-player-select').value) || 0;
const parts = pt.split('/');
const power = parts[0] ? parts[0].trim() : '1';
const toughness = parts[1] ? parts[1].trim() : '1';
for (let i = 0; i < count; i++) {
addCardToBattlefield(name, '', typeLine, playerIdx, undefined, undefined,
keywords ? keywords.replace(/,/g, ' ') : '',
{ power, toughness, pt, isToken: true });
}
addLogEntry('Created ' + count + 'x <strong>' + name + '</strong> token(s) for Player ' + (playerIdx + 1));
closeTokenModal();
}
// Pass priority
function passPriority() {
addLogEntry('Priority passed.');
closePriorityWindow();
}
// Open precon browser (stub - opens deck modal for current player)
function openPreconBrowser() {
if (gameState) openDeckModal(gameState.currentPlayerIndex);
}
// Open collection modal (stub)
function openCollectionModal() { showToast('Collection tracker available in full version.'); }
// Scry dialog stub
function showScryDialog(playerId) {
if (!gameState) return;
const player = gameState.players[playerId];
if (!player || player.zones.library.length === 0) {
addLogEntry('!! ' + (player ? player.name : 'Player') + ' has no cards to scry.');
return;
}
const count = Math.min(parseInt(prompt('Scry how many? (1-7)', '1') || '1'), 7, player.zones.library.length);
if (isNaN(count) || count < 1) return;
const topCards = player.zones.library.slice(0, count);
const names = topCards.map(c => c.name || '?').join(', ');
addLogEntry('<strong>' + player.name + '</strong> scrys ' + count + ': [' + names + ']');
}
// Mill dialog stub
function showMillDialog(playerId) {
if (!gameState) return;
const count = parseInt(prompt('Mill how many cards?', '1') || '1');
if (isNaN(count) || count < 1) return;
millCards(playerId, count);
}
// Become monarch
function becomeMonarch(playerId) {
if (!gameState) return;
gameState.monarchPlayerIndex = playerId;
const player = gameState.players[playerId];
addLogEntry('&#x1F451; <strong>' + (player ? player.name : 'Player') + '</strong> becomes the Monarch!');
updateMonarchBadges();
}
// Empower the life counter right-click on the quadrant labels
document.addEventListener('DOMContentLoaded', function() {
// Wire right-click on life values to set-life dialog
for (var i = 0; i < 4; i++) {
(function(idx) {
var lifeEl = document.getElementById('life-' + idx);
if (lifeEl) {
lifeEl.addEventListener('contextmenu', function(e) {
e.preventDefault();
showSetLifeDialog(idx);
});
}
})(i);
}
// Life counter right-click on quadrant buttons too
// (already handled via inline onclick in HTML)
// Auto-detect Commander-AI-Lab backend on page load
if (typeof AIBridge !== 'undefined') {
AIBridge.init().then(function(status) {
var indicator = document.getElementById('backend-status-indicator');
if (indicator) {
indicator.textContent = status.connected
? (status.modelLoaded ? '\u{1F9E0} ML Backend Connected' : '\u26a1 Backend Connected (no ML model)')
: '\u{1F4E1} Local Mode (no backend)';
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
// Wire up WebSocket push events from backend
AIBridge.connector.on('ai:decision', function(msg) {
if (!gameState || !msg.action) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (player && player.isAI) {
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
}
});
AIBridge.connector.on('state:snapshot', function(msg) {
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
console.log('[WS] State snapshot received from backend');
}
});
});
}
});
</script>
<!-- ====================================================
DECK BUILDER PANEL (iframe)
==================================================== -->
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Deck Builder</span>
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">&times;</button>
</div>
<iframe src="deckbuilder.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckbuilder-iframe">
</iframe>
</div>
<div id="panel-collection" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Collection</span>
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">&times;</button>
</div>
<iframe src="collection.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="collection-iframe">
</iframe>
</div>
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Auto Deck Generator</span>
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">&times;</button>
</div>
<iframe src="deckgenerator.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckgenerator-iframe">
</iframe>
</div>
<script>
function toggleDeckBuilderPanel() {
var panel = document.getElementById('panel-deckbuilder');
if (panel) panel.classList.toggle('hidden');
}
function toggleDeckGenPanel() {
var panel = document.getElementById('panel-deckgenerator');
if (panel) panel.classList.toggle('hidden');
}
function toggleCollectionPanel() {
var panel = document.getElementById('panel-collection');
if (panel) panel.classList.toggle('hidden');
}
function toggleLeftPanel() {
var panel = document.getElementById('left-action-panel');
if (panel) {
panel.classList.toggle('collapsed');
if (!panel.classList.contains('collapsed') && typeof refreshLeftPanelTriggers === 'function') {
refreshLeftPanelTriggers();
}
}
}
// Render the triggers list inside the left action panel.
// Mirrors #trigger-stack-body rendering but stays embedded in the panel.
function refreshLeftPanelTriggers() {
var body = document.getElementById('left-panel-triggers');
var countEl = document.getElementById('left-panel-trigger-count');
if (!body) return;
if (typeof gameState === 'undefined' || !gameState) {
if (countEl) countEl.textContent = '0';
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var pending = (gameState.triggerStack || []).filter(function(t){ return !t.resolved; });
if (countEl) countEl.textContent = String(pending.length);
if (pending.length === 0) {
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var icons = (typeof TRIGGER_TYPE_ICONS !== 'undefined') ? TRIGGER_TYPE_ICONS : {};
var labels = (typeof TRIGGER_TYPE_LABELS !== 'undefined') ? TRIGGER_TYPE_LABELS : {};
var html = '';
pending.forEach(function(t) {
var icon = icons[t.type] || '\u2747';
var label = labels[t.type] || t.type || '';
var owner = (gameState.players && gameState.players[t.ownerIndex]) || {};
var color = owner.color || '#888';
var name = (t.cardName || 'Unknown').replace(/</g,'&lt;');
var snip = (t.oracleSnippet || '').replace(/</g,'&lt;');
html +=
'<div class="lpt-item">' +
'<div class="lpt-icon">' + icon + '</div>' +
'<div class="lpt-body">' +
'<div class="lpt-title">' + name + '</div>' +
'<div class="lpt-type">' + label + '</div>' +
'<div class="lpt-text">' + snip + '</div>' +
'</div>' +
'<div class="lpt-dot" style="background:' + color + '"></div>' +
'<button class="lpt-dismiss" title="Dismiss" onclick="if(typeof dismissTrigger===\'function\')dismissTrigger(' + t.id + ');refreshLeftPanelTriggers();">&#x2715;</button>' +
'</div>';
});
body.innerHTML = html;
}
// Populate each seat's Cmd Dmg score-area box with per-opponent badges.
// Skips the seat's own player number from the display.
function renderCmdDmgInline() {
if (typeof gameState === 'undefined' || !gameState || !gameState.players) return;
var players = gameState.players;
for (var i = 0; i < players.length; i++) {
var box = document.getElementById('cmd-dmg-inline-' + i);
if (!box) continue;
var row = box.querySelector('.cmd-dmg-inline-row');
if (!row) continue;
var me = players[i];
if (!me) continue;
var html = '';
for (var j = 0; j < players.length; j++) {
if (j === i) continue; // leave own player number out
var op = players[j];
if (!op) continue;
var dmg = (me.commanderDamage && me.commanderDamage[op.id]) || 0;
var danger = dmg >= 21 ? ' danger' : dmg >= 15 ? ' crit' : dmg >= 8 ? ' warn' : '';
html +=
'<span class="cdi-badge' + danger + '" title="Commander damage from ' + (op.name||'P'+(j+1)) + '">' +
'<span class="cdi-dot" style="background:' + (op.color||'#888') + '"></span>' +
'P' + (j + 1) + ' <b>' + dmg + '</b>' +
'</span>';
}
row.innerHTML = html || '&#8212;';
}
}
// Hook into the trigger-stack badge updater so the left panel stays in sync.
(function() {
if (typeof window === 'undefined') return;
var tryHook = function() {
if (typeof window.updateTriggerBadge === 'function' && !window.updateTriggerBadge._leftPanelHooked) {
var orig = window.updateTriggerBadge;
window.updateTriggerBadge = function() {
var r = orig.apply(this, arguments);
try { refreshLeftPanelTriggers(); } catch(e) {}
return r;
};
window.updateTriggerBadge._leftPanelHooked = true;
}
// Also wrap adjustCommanderDamage so badges refresh after matrix clicks.
if (typeof window.adjustCommanderDamage === 'function' && !window.adjustCommanderDamage._cdmInlineHooked) {
var origAdj = window.adjustCommanderDamage;
window.adjustCommanderDamage = function() {
var r = origAdj.apply(this, arguments);
try { renderCmdDmgInline(); } catch(e) {}
return r;
};
window.adjustCommanderDamage._cdmInlineHooked = true;
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryHook);
} else {
tryHook();
}
// Also attempt the hook again a tick later (engine.js loads after this script in some orderings)
setTimeout(tryHook, 0);
setTimeout(tryHook, 500);
})();
// Listen for deck exports from the deck builder iframe
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
var deckData = e.data;
// Build a decklist text string compatible with importDeckForPlayer()
var lines = [];
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
if (commanders.length > 0) {
lines.push('[Commander]');
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
lines.push('');
}
lines.push('[Deck]');
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
// Store on window for the setup screen to pick up
window._pendingDeckImport = {
text: lines.join('\n'),
deckName: deckData.deckName || 'Imported Deck',
commander: deckData.commander || '',
};
// If a game is in progress, import into current player's slot
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
var playerIdx = gameState.currentPlayerIndex || 0;
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
if (typeof renderHandArea === 'function') renderHandArea();
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof addLogEntry === 'function') {
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
}
});
} else if (typeof addLogEntry === 'function') {
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
}
// Close the deck builder panel
toggleDeckBuilderPanel();
});
</script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
// player-panels.js — Enhanced Player Panel Component
// Provides collapsible panels with life, commander damage, counters, and emblems.
'use strict';
/**
* Create an enhanced player panel widget for a quadrant.
* Replaces the basic life counter with a full tracking panel.
*
* @param {object} player - Player object from gameState
* @param {number} quadrant - 1-4 (matches CSS class p1-p4)
* @param {boolean} isActive - Whether this is the active player
*/
function createEnhancedPlayerPanel(player, quadrant, isActive) {
var panel = document.createElement('div');
panel.className = 'enhanced-player-panel' +
(isActive ? ' active-turn' : '') +
(player.eliminated ? ' eliminated' : '');
panel.id = 'enhanced-panel-' + player.id;
panel.setAttribute('data-player-id', player.id);
// Track collapse state
var collapseKey = 'panel-collapsed-' + player.id;
var isCollapsed = localStorage.getItem(collapseKey) === 'true';
// ── Header Row ──────────────────────────────────────────
var headerHtml =
'<div class="epp-header" onclick="togglePlayerPanel(' + player.id + ')">' +
'<span class="epp-collapse-arrow">' + (isCollapsed ? '\u25b8' : '\u25be') + '</span>' +
'<div class="epp-name-dot" style="background:' + player.color + '"></div>' +
'<span class="epp-name">' + player.name + '</span>' +
(player.isAI ? '<span class="epp-ai-badge">AI</span>' : '') +
'<span class="epp-life-badge">' +
'\u2665 ' + player.life +
(player.life < player.startingLife
? ' / ' + player.startingLife
: '') +
'</span>' +
'</div>';
// ── Life Counter Row ────────────────────────────────────
var lifeHtml =
'<div class="epp-life-row">' +
'<button class="epp-life-btn minus" onclick="adjustLife(' + player.id + ',-1)" ' +
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',-5)" ' +
'title="Left: -1 | Right: -5">\u2212</button>' +
'<div class="epp-life-value" id="epp-life-' + player.id + '" ' +
'onclick="showSetLifeDialog(' + player.id + ')">' +
player.life +
'</div>' +
'<button class="epp-life-btn plus" onclick="adjustLife(' + player.id + ',1)" ' +
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',5)" ' +
'title="Left: +1 | Right: +5">+</button>' +
'</div>';
// ── Commander Damage Section ────────────────────────────
var otherPlayers = gameState.players.filter(function(p) {
return p.id !== player.id && !p.eliminated;
});
var cmdDmgHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Commander Damage</div>' +
'<div class="epp-cmd-dmg-grid">';
otherPlayers.forEach(function(op) {
var dmg = player.commanderDamage[op.id] || 0;
var pct = Math.round((dmg / 21) * 100);
var dangerClass = dmg >= 18 ? 'critical' : dmg >= 10 ? 'warning' : '';
cmdDmgHtml +=
'<div class="epp-cmd-dmg-item ' + dangerClass + '">' +
'<div class="epp-cmd-dmg-bar" style="width:' + pct + '%"></div>' +
'<div class="epp-cmd-dmg-dot" style="background:' + op.color + '"></div>' +
'<span class="epp-cmd-dmg-name">' + op.name + '</span>' +
'<span class="epp-cmd-dmg-value">' + dmg + '/21</span>' +
'<button class="epp-counter-btn" ' +
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',1)" ' +
'title="Add commander damage">+</button>' +
'<button class="epp-counter-btn" ' +
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',-1)" ' +
'title="Remove commander damage">\u2212</button>' +
'</div>';
});
cmdDmgHtml += '</div></div>';
// ── Counters Section ────────────────────────────────────
var poison = player.poison || 0;
var energy = player.energy || 0;
var experience = player.experience || 0;
var rad = player.rad || 0;
var countersHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Counters</div>' +
'<div class="epp-counters-grid">' +
_buildCounterWidget('Poison', '\u2620', poison, 10,
'adjustPoison(' + player.id + ',1)',
'adjustPoison(' + player.id + ',-1)',
poison >= 8 ? 'critical' : poison >= 5 ? 'warning' : '') +
_buildCounterWidget('Energy', '\u269b', energy, 0,
'adjustEnergy(' + player.id + ',1)',
'adjustEnergy(' + player.id + ',-1)', '') +
_buildCounterWidget('Experience', '\u2605', experience, 0,
'adjustExperience(' + player.id + ',1)',
'adjustExperience(' + player.id + ',-1)', '') +
_buildCounterWidget('Rad', '\u2622', rad, 0,
'adjustRad(' + player.id + ',1)',
'adjustRad(' + player.id + ',-1)', '') +
'</div>' +
'</div>';
// ── Emblems Section ─────────────────────────────────────
var emblems = player.emblems || [];
var emblemsHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Emblems</div>' +
'<div class="epp-emblems">';
if (emblems.length === 0) {
emblemsHtml += '<span class="epp-no-emblems">None</span>';
} else {
emblems.forEach(function(emb, idx) {
emblemsHtml +=
'<div class="epp-emblem-chip" title="' + emb.name + '">' +
'<span class="epp-emblem-name">' + emb.name + '</span>' +
'<button class="epp-emblem-remove" ' +
'onclick="event.stopPropagation();removeEmblem(' + player.id + ',' + idx + ')">\u00d7</button>' +
'</div>';
});
}
emblemsHtml +=
'<button class="epp-add-emblem-btn" ' +
'onclick="event.stopPropagation();showAddEmblemDialog(' + player.id + ')" ' +
'title="Add an emblem">+ Emblem</button>' +
'</div>' +
'</div>';
// ── Assemble Panel ──────────────────────────────────────
var bodyHtml =
'<div class="epp-body" id="epp-body-' + player.id + '" ' +
'style="' + (isCollapsed ? 'display:none;' : '') + '">' +
lifeHtml + cmdDmgHtml + countersHtml + emblemsHtml +
'</div>';
panel.innerHTML = headerHtml + bodyHtml;
return panel;
}
function _buildCounterWidget(label, icon, value, threshold, onInc, onDec, dangerClass) {
return '<div class="epp-counter ' + dangerClass + '">' +
'<div class="epp-counter-icon">' + icon + '</div>' +
'<div class="epp-counter-info">' +
'<span class="epp-counter-value">' + value + '</span>' +
'<span class="epp-counter-label">' + label + '</span>' +
'</div>' +
'<div class="epp-counter-btns">' +
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onInc + '">+</button>' +
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onDec + '">\u2212</button>' +
'</div>' +
'</div>';
}
function togglePlayerPanel(playerId) {
var body = document.getElementById('epp-body-' + playerId);
var panel = document.getElementById('enhanced-panel-' + playerId);
if (!body || !panel) return;
var isCollapsed = body.style.display === 'none';
body.style.display = isCollapsed ? '' : 'none';
var arrow = panel.querySelector('.epp-collapse-arrow');
if (arrow) arrow.textContent = isCollapsed ? '\u25be' : '\u25b8';
localStorage.setItem('panel-collapsed-' + playerId, !isCollapsed);
}
// Counter adjustment functions
function adjustEnergy(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.energy = Math.max(0, (player.energy || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> energy: ' + player.energy);
renderEnhancedPanels();
}
function adjustExperience(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.experience = Math.max(0, (player.experience || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> experience: ' + player.experience);
renderEnhancedPanels();
}
function adjustRad(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.rad = Math.max(0, (player.rad || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> rad counters: ' + player.rad);
renderEnhancedPanels();
}
function showAddEmblemDialog(playerId) {
var name = prompt('Enter emblem name (e.g., "Sorin, Vengeful Bloodlord"):');
if (!name || !name.trim()) return;
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
if (!player.emblems) player.emblems = [];
player.emblems.push({ name: name.trim() });
addLogEntry('<strong>' + player.name + '</strong> gained emblem: ' + name.trim());
renderEnhancedPanels();
}
function removeEmblem(playerId, idx) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player || !player.emblems) return;
var removed = player.emblems.splice(idx, 1);
if (removed.length > 0) {
addLogEntry('<strong>' + player.name + '</strong> lost emblem: ' + removed[0].name);
}
renderEnhancedPanels();
}
// index: 0 1 2 3 → area: 1 2 4 3
// players[2] → bottom-RIGHT (area-4), players[3] → bottom-LEFT (area-3)
// Keeps turn order P1→P2→P3→P4 visually clockwise.
var PLAYER_INDEX_TO_QUADRANT = [1, 2, 4, 3];
/**
* Re-render all enhanced player panels in their quadrant positions.
*/
function renderEnhancedPanels() {
if (!gameState) return;
for (var i = 0; i < gameState.players.length; i++) {
var player = gameState.players[i];
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
var areaEl = document.getElementById('player-area-' + quadrant);
if (!areaEl) continue;
// Find or create the enhanced panel container
var container = areaEl.querySelector('.epp-container');
if (!container) {
container = document.createElement('div');
container.className = 'epp-container';
// Insert before the zone row
var zoneRow = areaEl.querySelector('.zone-row');
if (zoneRow) {
areaEl.insertBefore(container, zoneRow);
} else {
areaEl.appendChild(container);
}
}
var isActive = gameState.currentPlayerIndex === i;
var panel = createEnhancedPlayerPanel(player, quadrant, isActive);
container.innerHTML = '';
container.appendChild(panel);
// Hide the old life counter (we have our own now)
var oldLifeCounter = areaEl.querySelector('.life-counter');
if (oldLifeCounter) oldLifeCounter.style.display = 'none';
// Hide old poison badge (we have counters section)
var oldPoison = areaEl.querySelector('.poison-badge');
if (oldPoison) oldPoison.style.display = 'none';
}
}
+426
View File
@@ -0,0 +1,426 @@
/**
* precon-loader.js
* Handles loading precon .dck files from the backend into the lan-client simulator.
* Depends on: engine.js (gameState, initGame, addLogEntry, renderPlayerPanels)
* ai-bridge.js (AIBridge.deckSource.fetchPreconDeck)
* ui.js (setupDeckChoices, setupPlayerCount, setupStartingLife)
*/
'use strict';
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
// ────────────────────────────────────────────────────────────
// Scryfall image cache: cardName -> { imageUrl, typeLine, ... }
// Persisted to localStorage so repeat games don't re-hit Scryfall.
// ────────────────────────────────────────────────────────────
const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1';
const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const _SCRYFALL_CACHE_MAX_ENTRIES = 20000;
let _scryfallCache = {};
(function _loadScryfallCacheFromLocalStorage() {
try {
const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
const now = Date.now();
const stamp = parsed.__stamp || 0;
if (now - stamp > _SCRYFALL_CACHE_TTL_MS) {
console.log('[scryfall-cache] expired (> 30 days), clearing');
localStorage.removeItem(_SCRYFALL_CACHE_KEY);
return;
}
delete parsed.__stamp;
_scryfallCache = parsed;
const n = Object.keys(_scryfallCache).length;
console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage');
} catch (e) {
console.warn('[scryfall-cache] load failed:', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
_scryfallCache = {};
}
})();
let _scryfallCacheSaveTimer = null;
function _persistScryfallCache() {
// Debounced — coalesce rapid writes during batch enrichment
if (_scryfallCacheSaveTimer) return;
_scryfallCacheSaveTimer = setTimeout(function () {
_scryfallCacheSaveTimer = null;
try {
// Trim to MAX_ENTRIES if over (drop entries with no imageUrl first)
const keys = Object.keys(_scryfallCache);
if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) {
const trimmed = {};
const sorted = keys.sort((a, b) =>
(_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0)
).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES);
for (const k of sorted) trimmed[k] = _scryfallCache[k];
_scryfallCache = trimmed;
}
const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache);
localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload));
} catch (e) {
// Quota exceeded — clear and skip. Cache will rebuild next session.
console.warn('[scryfall-cache] persist failed (quota?):', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
}
}, 750);
}
function clearScryfallCache() {
_scryfallCache = {};
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
console.log('[scryfall-cache] cleared');
}
// ============================================================
// startGameWithDecks
// Called by ui.js startGame() when players have selected precon decks.
// ============================================================
async function startGameWithDecks() {
const playerNames = [];
for (let i = 0; i < (setupPlayerCount || 4); i++) {
const inp = document.getElementById('player-name-' + i);
playerNames.push(inp && inp.value.trim() ? inp.value.trim() : 'Player ' + (i + 1));
}
initGame(setupPlayerCount || 4, playerNames, setupStartingLife || 40);
const choices = typeof setupDeckChoices !== 'undefined' ? setupDeckChoices : {};
const loadPromises = Object.entries(choices).map(async ([idxStr, choice]) => {
const playerIdx = parseInt(idxStr, 10);
if (choice.type !== 'precon' || !choice.preconId) return;
try {
const fileName = choice.preconId.endsWith('.dck') ? choice.preconId : choice.preconId + '.dck';
const data = await AIBridge.deckSource.fetchPreconDeck(fileName);
if (!data || !data.decklist) {
addLogEntry('\u26A0 Could not load deck for Player ' + (playerIdx + 1) + ' (no decklist returned)');
return;
}
loadPreconDeckFromDck(playerIdx, data.decklist, choice.preconId);
} catch (e) {
console.warn('[precon-loader] Failed to load deck for player', playerIdx, e);
addLogEntry('\u26A0 Deck load failed for Player ' + (playerIdx + 1) + ': ' + e.message);
}
});
await Promise.all(loadPromises);
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
// Block on Scryfall enrichment BEFORE the game becomes interactive. This is
// critical: the opening 7 cards are dealt via player.zones.hand.push(lib.shift())
// directly (bypassing drawCard()), so they don't get _enrichCardFromCache'd at
// draw time. Without awaiting here, the AI's first turn fires with empty
// typeLine/cmc/oracleText/isRamp/isRemoval flags and can only play lands.
addLogEntry('\u23F3 Loading card data from Scryfall...');
try {
await enrichAllZonesWithScryfall();
} catch (e) {
console.warn('[precon-loader] Enrichment failed, continuing anyway:', e);
}
addLogEntry('\u2705 Card data loaded. Game ready.');
}
// ============================================================
// loadPreconDeckFromDck
// Parses a Forge-format .dck file string and populates a player's zones.
// ============================================================
function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
let commander = '';
let section = '';
const mainCards = [];
for (const rawLine of dckContent.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('//')) continue;
if (line.startsWith('[') && line.endsWith(']')) {
section = line.slice(1, -1);
continue;
}
if (line.includes('=') && !line.match(/^\d/)) continue;
const m = line.match(/^(\d+)\s+(.+?)(?:\|.*)?$/);
if (!m) continue;
const qty = parseInt(m[1], 10);
const name = m[2].trim();
if (section === 'Commander') {
commander = name;
} else if (section === 'Main') {
for (let q = 0; q < qty; q++) mainCards.push(name);
}
}
player.zones.command = [];
player.zones.library = [];
player.zones.hand = [];
player.zones.graveyard = [];
player.zones.exile = [];
if (commander) {
player.zones.command.push({
id: 'cmd-' + playerIdx + '-' + Date.now(),
name: commander,
imageUrl: '',
typeLine: 'Legendary Creature',
oracleText: '',
manaCost: '',
});
player.commanderCardName = commander;
}
for (const cardName of mainCards) {
player.zones.library.push({
id: 'lib-' + playerIdx + '-' + cardName + '-' + Math.random().toString(36).slice(2, 6),
name: cardName,
imageUrl: '',
typeLine: '',
oracleText: '',
manaCost: '',
});
}
// Fisher-Yates shuffle
const lib = player.zones.library;
for (let i = lib.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lib[i], lib[j]] = [lib[j], lib[i]];
}
// Draw opening hand
for (let d = 0; d < 7 && lib.length > 0; d++) {
player.zones.hand.push(lib.shift());
}
if (typeof updateZoneCountsForPlayer === 'function') updateZoneCountsForPlayer(playerIdx);
if (typeof renderCommanderZone === 'function') renderCommanderZone(playerIdx);
if (typeof renderHandZone === 'function') renderHandZone(playerIdx);
const label = deckLabel ? deckLabel.replace('.dck', '') : 'Unknown Deck';
addLogEntry(
'\uD83C\uDCCF <strong>' + player.name + '</strong> loaded: <em>' + label + '</em>' +
(commander ? ' \u2014 Commander: <strong>' + commander + '</strong>' : '') +
' (' + lib.length + ' in library, 7 in hand)'
);
}
// ============================================================
// SCRYFALL IMAGE ENRICHMENT
// After zones are populated with card names, batch-fetch card
// data from Scryfall and backfill imageUrl on every card object.
// Uses the /cards/collection endpoint (75 cards per request).
// Results are cached in _scryfallCache so re-renders are instant.
// ============================================================
async function enrichAllZonesWithScryfall() {
if (!gameState) return;
// Gather all unique card names across all players and all zones
const nameSet = new Set();
let totalCards = 0;
let cacheHits = 0;
for (const player of gameState.players) {
for (const zone of Object.values(player.zones)) {
for (const card of zone) {
if (!card.name) continue;
totalCards++;
if (_scryfallCache[card.name]) {
cacheHits++;
} else {
nameSet.add(card.name);
}
}
}
}
if (nameSet.size === 0) {
// Everything was in cache — just apply and bail, no network
addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).');
_applyScryfallDataToAllCards();
return;
}
const names = Array.from(nameSet);
const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0;
addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...');
// Split into batches of 75 (Scryfall max)
const BATCH = 75;
for (let i = 0; i < names.length; i += BATCH) {
const batch = names.slice(i, i + BATCH);
try {
await _fetchScryfallBatch(batch);
} catch (e) {
console.warn('[precon-loader] Scryfall batch failed', e);
}
// Polite delay between batches to respect Scryfall rate limit (50-100ms)
if (i + BATCH < names.length) await _sleep(100);
}
// Apply cached data to every card object in every zone
_applyScryfallDataToAllCards();
addLogEntry('\u2705 Card images loaded.');
}
async function _fetchScryfallBatch(names) {
const identifiers = names.map(name => ({ name }));
const resp = await fetch(SCRYFALL_COLLECTION_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifiers }),
});
if (!resp.ok) throw new Error('Scryfall HTTP ' + resp.status);
const json = await resp.json();
for (const card of (json.data || [])) {
const name = card.name;
// Pick the best image: normal > large > small, handle double-faced
let imageUrl = '';
if (card.image_uris) {
imageUrl = card.image_uris.normal || card.image_uris.large || card.image_uris.small || '';
} else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
imageUrl = card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.large || '';
}
_scryfallCache[name] = {
imageUrl,
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
cmc: card.cmc || 0,
power: card.power || '',
toughness: card.toughness || '',
};
}
// Mark not-found cards so we don't re-fetch them on next enrichment
for (const name of names) {
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
}
_persistScryfallCache();
}
function _applyScryfallDataToAllCards() {
if (!gameState) return;
for (const player of gameState.players) {
for (const zone of Object.values(player.zones)) {
for (const card of zone) {
const data = _scryfallCache[card.name];
if (data) {
card.imageUrl = data.imageUrl || card.imageUrl;
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag roles from oracle text so aiCategorizeHand works
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
// Also enrich battlefield cards owned by this player
if (gameState.battlefieldCards) {
for (const card of gameState.battlefieldCards) {
if (card.ownerIndex !== player.id) continue;
const data = _scryfallCache[card.name];
if (data) {
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
}
// Trigger UI refresh so images appear without a full reload
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof renderCommanderZone === 'function') {
for (let i = 0; i < gameState.players.length; i++) renderCommanderZone(i);
}
if (typeof renderHandZone === 'function') {
for (let i = 0; i < gameState.players.length; i++) renderHandZone(i);
}
if (typeof updateZoneCounts === 'function') updateZoneCounts();
}
/**
* Synchronously enrich a single card from the Scryfall cache.
* Called at draw time so cards have typeLine/cmc/oracle data
* before the AI's first turn, even if async enrichment hasn't completed.
*/
function _enrichCardFromCache(card) {
if (!card || !card.name) return;
const data = _scryfallCache[card.name];
if (!data) return;
card.imageUrl = data.imageUrl || card.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine || '';
card.oracleText = data.oracleText || card.oracleText || '';
card.manaCost = data.manaCost || card.manaCost || '';
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag oracle roles
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
/**
* Enrich a single card object on-demand (useful for cards drawn or played after initial load).
* Returns a Promise that resolves when the card's imageUrl is set.
*/
async function enrichCardWithScryfall(card) {
if (!card || !card.name) return;
if (card.imageUrl) return; // already has image
const cached = _scryfallCache[card.name];
if (cached) {
card.imageUrl = cached.imageUrl || '';
card.typeLine = cached.typeLine || card.typeLine;
card.oracleText = cached.oracleText || card.oracleText;
card.manaCost = cached.manaCost || card.manaCost;
return;
}
await _fetchScryfallBatch([card.name]);
const data = _scryfallCache[card.name];
if (data) {
card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
}
}
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
+167
View File
@@ -0,0 +1,167 @@
// Auto-generated from precon-decks/precon-index.json
// Run: node scripts/generate-precons-js.js to regenerate
var PRECON_DATA = [
{"id":"Counterpunch","name":"Counterpunch","commander":"Ghave, Guru of Spores","set":"Commander 2011","fileName":"Counterpunch.dck","colors":[],"year":2011},
{"id":"Devour_for_Power_CMD","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander 2011","fileName":"Devour_for_Power_CMD.dck","colors":[],"year":2011},
{"id":"Heavenly_Inferno_CMD","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander 2011","fileName":"Heavenly_Inferno_CMD.dck","colors":[],"year":2011},
{"id":"Mirror_Mastery","name":"Mirror Mastery","commander":"Riku of Two Reflections","set":"Commander 2011","fileName":"Mirror_Mastery.dck","colors":[],"year":2011},
{"id":"Political_Puppets","name":"Political Puppets","commander":"Zedruu the Greathearted","set":"Commander 2011","fileName":"Political_Puppets.dck","colors":[],"year":2011},
{"id":"Eternal_Bargain","name":"Eternal Bargain","commander":"Oloro, Ageless Ascetic","set":"Commander 2013","fileName":"Eternal_Bargain.dck","colors":[],"year":2013},
{"id":"Evasive_Maneuvers_C13","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander 2013","fileName":"Evasive_Maneuvers_C13.dck","colors":[],"year":2013},
{"id":"Mind_Seize","name":"Mind Seize","commander":"Jeleva, Nephalia's Scourge","set":"Commander 2013","fileName":"Mind_Seize.dck","colors":[],"year":2013},
{"id":"Nature_of_the_Beast","name":"Nature of the Beast","commander":"Marath, Will of the Wild","set":"Commander 2013","fileName":"Nature_of_the_Beast.dck","colors":[],"year":2013},
{"id":"Power_Hungry","name":"Power Hungry","commander":"Prossh, Skyraider of Kher","set":"Commander 2013","fileName":"Power_Hungry.dck","colors":[],"year":2013},
{"id":"Built_From_Scratch_C14","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander 2014","fileName":"Built_From_Scratch_C14.dck","colors":[],"year":2014},
{"id":"Forged_In_Stone","name":"Forged In Stone","commander":"Nahiri, the Lithomancer","set":"Commander 2014","fileName":"Forged_In_Stone.dck","colors":[],"year":2014},
{"id":"Guided_By_Nature","name":"Guided By Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander 2014","fileName":"Guided_By_Nature.dck","colors":[],"year":2014},
{"id":"Peer_Through_Time","name":"Peer Through Time","commander":"Teferi, Temporal Archmage","set":"Commander 2014","fileName":"Peer_Through_Time.dck","colors":[],"year":2014},
{"id":"Sworn_To_Darkness","name":"Sworn To Darkness","commander":"Ob Nixilis of the Black Oath","set":"Commander 2014","fileName":"Sworn_To_Darkness.dck","colors":[],"year":2014},
{"id":"Call_the_Spirits","name":"Call the Spirits","commander":"Daxos the Returned","set":"Commander 2015","fileName":"Call_the_Spirits.dck","colors":[],"year":2015},
{"id":"Plunder_the_Graves_C15","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander 2015","fileName":"Plunder_the_Graves_C15.dck","colors":[],"year":2015},
{"id":"Seize_Control","name":"Seize Control","commander":"Mizzix of the Izmagnus","set":"Commander 2015","fileName":"Seize_Control.dck","colors":[],"year":2015},
{"id":"Swell_the_Host","name":"Swell the Host","commander":"Ezuri, Claw of Progress","set":"Commander 2015","fileName":"Swell_the_Host.dck","colors":[],"year":2015},
{"id":"Wade_into_Battle_C15","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander 2015","fileName":"Wade_into_Battle_C15.dck","colors":[],"year":2015},
{"id":"Breed_Lethality_C16","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander 2016","fileName":"Breed_Lethality_C16.dck","colors":[],"year":2016},
{"id":"Entropic_Uprising","name":"Entropic Uprising","commander":"Yidris, Maelstrom Wielder","set":"Commander 2016","fileName":"Entropic_Uprising.dck","colors":[],"year":2016},
{"id":"Invent_Superiority","name":"Invent Superiority","commander":"Breya, Etherium Shaper","set":"Commander 2016","fileName":"Invent_Superiority.dck","colors":[],"year":2016},
{"id":"Open_Hostility","name":"Open Hostility","commander":"Saskia the Unyielding","set":"Commander 2016","fileName":"Open_Hostility.dck","colors":[],"year":2016},
{"id":"Stalwart_Unity","name":"Stalwart Unity","commander":"Kynaios and Tiro of Meletis","set":"Commander 2016","fileName":"Stalwart_Unity.dck","colors":[],"year":2016},
{"id":"Evasive_Maneuvers_CMA","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander Anthology","fileName":"Evasive_Maneuvers_CMA.dck","colors":[],"year":2017},
{"id":"Guided_by_Nature","name":"Guided by Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander Anthology","fileName":"Guided_by_Nature.dck","colors":[],"year":2017},
{"id":"Heavenly_Inferno_CMA","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander Anthology","fileName":"Heavenly_Inferno_CMA.dck","colors":[],"year":2017},
{"id":"Plunder_the_Graves_CMA","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander Anthology","fileName":"Plunder_the_Graves_CMA.dck","colors":[],"year":2017},
{"id":"Arcane_Wizardry","name":"Arcane Wizardry","commander":"Inalla, Archmage Ritualist","set":"Commander 2017","fileName":"Arcane_Wizardry.dck","colors":[],"year":2017},
{"id":"Draconic_Domination","name":"Draconic Domination","commander":"The Ur-Dragon","set":"Commander 2017","fileName":"Draconic_Domination.dck","colors":[],"year":2017},
{"id":"Feline_Ferocity","name":"Feline Ferocity","commander":"Arahbo, Roar of the World","set":"Commander 2017","fileName":"Feline_Ferocity.dck","colors":[],"year":2017},
{"id":"Vampiric_Bloodlust","name":"Vampiric Bloodlust","commander":"Edgar Markov","set":"Commander 2017","fileName":"Vampiric_Bloodlust.dck","colors":[],"year":2017},
{"id":"Breed_Lethality_CM2","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander Anthology Volume II","fileName":"Breed_Lethality_CM2.dck","colors":[],"year":2018},
{"id":"Built_From_Scratch_CM2","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander Anthology Volume II","fileName":"Built_From_Scratch_CM2.dck","colors":[],"year":2018},
{"id":"Devour_for_Power_CM2","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander Anthology Volume II","fileName":"Devour_for_Power_CM2.dck","colors":[],"year":2018},
{"id":"Wade_into_Battle_CM2","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander Anthology Volume II","fileName":"Wade_into_Battle_CM2.dck","colors":[],"year":2018},
{"id":"Adaptive_Enchantment","name":"Adaptive Enchantment","commander":"Estrid, the Masked","set":"Commander 2018","fileName":"Adaptive_Enchantment.dck","colors":[],"year":2018},
{"id":"Exquisite_Invention","name":"Exquisite Invention","commander":"Saheeli, the Gifted","set":"Commander 2018","fileName":"Exquisite_Invention.dck","colors":[],"year":2018},
{"id":"Natures_Vengeance","name":"Nature's Vengeance","commander":"Lord Windgrace","set":"Commander 2018","fileName":"Natures_Vengeance.dck","colors":[],"year":2018},
{"id":"Subjective_Reality","name":"Subjective Reality","commander":"Aminatou, the Fateshifter","set":"Commander 2018","fileName":"Subjective_Reality.dck","colors":[],"year":2018},
{"id":"Faceless_Menace","name":"Faceless Menace","commander":"Kadena, Slinking Sorcerer","set":"Commander 2019","fileName":"Faceless_Menace.dck","colors":[],"year":2019},
{"id":"Merciless_Rage","name":"Merciless Rage","commander":"Anje Falkenrath","set":"Commander 2019","fileName":"Merciless_Rage.dck","colors":[],"year":2019},
{"id":"Mystic_Intellect","name":"Mystic Intellect","commander":"Sevinne, the Chronoclasm","set":"Commander 2019","fileName":"Mystic_Intellect.dck","colors":[],"year":2019},
{"id":"Primal_Genesis","name":"Primal Genesis","commander":"Ghired, Conclave Exile","set":"Commander 2019","fileName":"Primal_Genesis.dck","colors":[],"year":2019},
{"id":"Arcane_Maelstrom","name":"Arcane Maelstrom","commander":"Kalamax, the Stormsire","set":"Commander 2020","fileName":"Arcane_Maelstrom.dck","colors":[],"year":2020},
{"id":"Enhanced_Evolution","name":"Enhanced Evolution","commander":"Otrimi, the Ever-Playful","set":"Commander 2020","fileName":"Enhanced_Evolution.dck","colors":[],"year":2020},
{"id":"Ruthless_Regiment","name":"Ruthless Regiment","commander":"Jirina Kudro","set":"Commander 2020","fileName":"Ruthless_Regiment.dck","colors":[],"year":2020},
{"id":"Symbiotic_Swarm","name":"Symbiotic Swarm","commander":"Kathril, Aspect Warper","set":"Commander 2020","fileName":"Symbiotic_Swarm.dck","colors":[],"year":2020},
{"id":"Timeless_Wisdom","name":"Timeless Wisdom","commander":"Gavi, Nest Warden","set":"Commander 2020","fileName":"Timeless_Wisdom.dck","colors":[],"year":2020},
{"id":"Lands_Wrath","name":"Land's Wrath","commander":"Obuun, Mul Daya Ancestor","set":"Zendikar Rising Commander","fileName":"Lands_Wrath.dck","colors":[],"year":2020},
{"id":"Sneak_Attack","name":"Sneak Attack","commander":"Anowon, the Ruin Thief","set":"Zendikar Rising Commander","fileName":"Sneak_Attack.dck","colors":[],"year":2020},
{"id":"Arm_for_Battle","name":"Arm for Battle","commander":"Wyleth, Soul of Steel","set":"Commander Legends","fileName":"Arm_for_Battle.dck","colors":[],"year":2020},
{"id":"Reap_the_Tides","name":"Reap the Tides","commander":"Aesi, Tyrant of Gyre Strait","set":"Commander Legends","fileName":"Reap_the_Tides.dck","colors":[],"year":2020},
{"id":"Elven_Empire","name":"Elven Empire","commander":"Lathril, Blade of the Elves","set":"Kaldheim Commander","fileName":"Elven_Empire.dck","colors":[],"year":2021},
{"id":"Phantom_Premonition","name":"Phantom Premonition","commander":"Ranar the Ever-Watchful","set":"Kaldheim Commander","fileName":"Phantom_Premonition.dck","colors":[],"year":2021},
{"id":"Lorehold_Legacies","name":"Lorehold Legacies","commander":"Osgir, the Reconstructor","set":"Commander 2021","fileName":"Lorehold_Legacies.dck","colors":[],"year":2021},
{"id":"Prismari_Performance","name":"Prismari Performance","commander":"Zaffai, Thunder Conductor","set":"Commander 2021","fileName":"Prismari_Performance.dck","colors":[],"year":2021},
{"id":"Quantum_Quandrix","name":"Quantum Quandrix","commander":"Adrix and Nev, Twincasters","set":"Commander 2021","fileName":"Quantum_Quandrix.dck","colors":[],"year":2021},
{"id":"Silverquill_Statement","name":"Silverquill Statement","commander":"Breena, the Demagogue","set":"Commander 2021","fileName":"Silverquill_Statement.dck","colors":[],"year":2021},
{"id":"Witherbloom_Witchcraft","name":"Witherbloom Witchcraft","commander":"Willowdusk, Essence Seer","set":"Commander 2021","fileName":"Witherbloom_Witchcraft.dck","colors":[],"year":2021},
{"id":"Aura_of_Courage","name":"Aura of Courage","commander":"Galea, Kindler of Hope","set":"Forgotten Realms Commander","fileName":"Aura_of_Courage.dck","colors":[],"year":2021},
{"id":"Draconic_Rage","name":"Draconic Rage","commander":"Vrondiss, Rage of Ancients","set":"Forgotten Realms Commander","fileName":"Draconic_Rage.dck","colors":[],"year":2021},
{"id":"Dungeons_of_Death","name":"Dungeons of Death","commander":"Sefris of the Hidden Ways","set":"Forgotten Realms Commander","fileName":"Dungeons_of_Death.dck","colors":[],"year":2021},
{"id":"Planar_Portal","name":"Planar Portal","commander":"Prosper, Tome-Bound","set":"Forgotten Realms Commander","fileName":"Planar_Portal.dck","colors":[],"year":2021},
{"id":"Coven_Counters","name":"Coven Counters","commander":"Leinore, Autumn Sovereign","set":"Midnight Hunt Commander","fileName":"Coven_Counters.dck","colors":[],"year":2021},
{"id":"Undead_Unleashed","name":"Undead Unleashed","commander":"Wilhelt, the Rotcleaver","set":"Midnight Hunt Commander","fileName":"Undead_Unleashed.dck","colors":[],"year":2021},
{"id":"Spirit_Squadron","name":"Spirit Squadron","commander":"Millicent, Restless Revenant","set":"Crimson Vow Commander","fileName":"Spirit_Squadron.dck","colors":[],"year":2021},
{"id":"Vampiric_Bloodline","name":"Vampiric Bloodline","commander":"Strefan, Maurer Progenitor","set":"Crimson Vow Commander","fileName":"Vampiric_Bloodline.dck","colors":[],"year":2021},
{"id":"Buckle_Up","name":"Buckle Up","commander":"Kotori, Pilot Prodigy","set":"Neon Dynasty Commander","fileName":"Buckle_Up.dck","colors":[],"year":2022},
{"id":"Upgrades_Unleashed","name":"Upgrades Unleashed","commander":"Chishiro, the Shattered Blade","set":"Neon Dynasty Commander","fileName":"Upgrades_Unleashed.dck","colors":[],"year":2022},
{"id":"Heads_I_Win,_Tails_You_Lose","name":"Heads I Win, Tails You Lose","commander":"Zndrsplt, Eye of Wisdom","set":"Secret Lair Drop","fileName":"Heads_I_Win,_Tails_You_Lose.dck","colors":[],"year":2022},
{"id":"Bedecked_Brokers","name":"Bedecked Brokers","commander":"Perrie, the Pulverizer","set":"New Capenna Commander","fileName":"Bedecked_Brokers.dck","colors":[],"year":2022},
{"id":"Cabaretti_Cacophony","name":"Cabaretti Cacophony","commander":"Kitt Kanto, Mayhem Diva","set":"New Capenna Commander","fileName":"Cabaretti_Cacophony.dck","colors":[],"year":2022},
{"id":"Maestros_Massacre","name":"Maestros Massacre","commander":"Anhelo, the Painter","set":"New Capenna Commander","fileName":"Maestros_Massacre.dck","colors":[],"year":2022},
{"id":"Obscura_Operation","name":"Obscura Operation","commander":"Kamiz, Obscura Oculus","set":"New Capenna Commander","fileName":"Obscura_Operation.dck","colors":[],"year":2022},
{"id":"Riveteers_Rampage","name":"Riveteers Rampage","commander":"Henzie \"Toolbox\" Torre","set":"New Capenna Commander","fileName":"Riveteers_Rampage.dck","colors":[],"year":2022},
{"id":"Draconic_Dissent","name":"Draconic Dissent","commander":"Firkraag, Cunning Instigator","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Draconic_Dissent.dck","colors":[],"year":2022},
{"id":"Exit_from_Exile","name":"Exit from Exile","commander":"Faldorn, Dread Wolf Herald","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Exit_from_Exile.dck","colors":[],"year":2022},
{"id":"Mind_Flayarrrs","name":"Mind Flayarrrs","commander":"Captain N'ghathrod","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Mind_Flayarrrs.dck","colors":[],"year":2022},
{"id":"Party_Time","name":"Party Time","commander":"Nalia de'Arnise","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Party_Time.dck","colors":[],"year":2022},
{"id":"Legends_Legacy","name":"Legends' Legacy","commander":"Dihada, Binder of Wills","set":"Dominaria United Commander","fileName":"Legends_Legacy.dck","colors":[],"year":2022},
{"id":"Painbow","name":"Painbow","commander":"Jared Carthalion","set":"Dominaria United Commander","fileName":"Painbow.dck","colors":[],"year":2022},
{"id":"Forces_of_the_Imperium","name":"Forces of the Imperium","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium.dck","colors":[],"year":2022},
{"id":"Forces_of_the_Imperium_Collectors_Edition","name":"Forces of the Imperium Collector's Edition","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium_Collectors_Edition.dck","colors":[],"year":2022},
{"id":"Necron_Dynasties","name":"Necron Dynasties","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties.dck","colors":[],"year":2022},
{"id":"Necron_Dynasties_Collectors_Edition","name":"Necron Dynasties Collector's Edition","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties_Collectors_Edition.dck","colors":[],"year":2022},
{"id":"The_Ruinous_Powers","name":"The Ruinous Powers","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers.dck","colors":[],"year":2022},
{"id":"The_Ruinous_Powers_Collectors_Edition","name":"The Ruinous Powers Collector's Edition","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers_Collectors_Edition.dck","colors":[],"year":2022},
{"id":"Tyranid_Swarm","name":"Tyranid Swarm","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm.dck","colors":[],"year":2022},
{"id":"Tyranid_Swarm_Collectors_Edition","name":"Tyranid Swarm Collector's Edition","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm_Collectors_Edition.dck","colors":[],"year":2022},
{"id":"Mishras_Burnished_Banner","name":"Mishra's Burnished Banner","commander":"Mishra, Eminent One","set":"The Brothers' War Commander","fileName":"Mishras_Burnished_Banner.dck","colors":[],"year":2022},
{"id":"Urzas_Iron_Alliance","name":"Urza's Iron Alliance","commander":"Urza, Chief Artificer","set":"The Brothers' War Commander","fileName":"Urzas_Iron_Alliance.dck","colors":[],"year":2022},
{"id":"Chaos_Incarnate","name":"Chaos Incarnate","commander":"Kardur, Doomscourge","set":"Starter Commander Decks","fileName":"Chaos_Incarnate.dck","colors":[],"year":2022},
{"id":"Draconic_Destruction","name":"Draconic Destruction","commander":"Atarka, World Render","set":"Starter Commander Decks","fileName":"Draconic_Destruction.dck","colors":[],"year":2022},
{"id":"First_Flight","name":"First Flight","commander":"Isperia, Supreme Judge","set":"Starter Commander Decks","fileName":"First_Flight.dck","colors":[],"year":2022},
{"id":"Grave_Danger","name":"Grave Danger","commander":"Gisa and Geralf","set":"Starter Commander Decks","fileName":"Grave_Danger.dck","colors":[],"year":2022},
{"id":"Token_Triumph","name":"Token Triumph","commander":"Emmara, Soul of the Accord","set":"Starter Commander Decks","fileName":"Token_Triumph.dck","colors":[],"year":2022},
{"id":"Corrupting_Influence","name":"Corrupting Influence","commander":"Ixhel, Scion of Atraxa","set":"Phyrexia: All Will Be One Commander","fileName":"Corrupting_Influence.dck","colors":[],"year":2023},
{"id":"Rebellion_Rising","name":"Rebellion Rising","commander":"Neyali, Suns' Vanguard","set":"Phyrexia: All Will Be One Commander","fileName":"Rebellion_Rising.dck","colors":[],"year":2023},
{"id":"From_Cute_to_Brute","name":"From Cute to Brute","commander":"Esika, God of the Tree","set":"Secret Lair Drop","fileName":"From_Cute_to_Brute.dck","colors":[],"year":2023},
{"id":"Elven_Council","name":"Elven Council","commander":"Galadriel, Elven-Queen","set":"Tales of Middle-earth Commander","fileName":"Elven_Council.dck","colors":[],"year":2023},
{"id":"Food_and_Fellowship","name":"Food and Fellowship","commander":"Frodo, Adventurous Hobbit","set":"Tales of Middle-earth Commander","fileName":"Food_and_Fellowship.dck","colors":[],"year":2023},
{"id":"Riders_of_Rohan","name":"Riders of Rohan","commander":"Éowyn, Shieldmaiden","set":"Tales of Middle-earth Commander","fileName":"Riders_of_Rohan.dck","colors":[],"year":2023},
{"id":"The_Hosts_of_Mordor","name":"The Hosts of Mordor","commander":"Sauron, Lord of the Rings","set":"Tales of Middle-earth Commander","fileName":"The_Hosts_of_Mordor.dck","colors":[],"year":2023},
{"id":"Eldrazi_Unbound","name":"Eldrazi Unbound","commander":"Zhulodok, Void Gorger","set":"Commander Masters","fileName":"Eldrazi_Unbound.dck","colors":[],"year":2023},
{"id":"Enduring_Enchantments","name":"Enduring Enchantments","commander":"Anikthea, Hand of Erebos","set":"Commander Masters","fileName":"Enduring_Enchantments.dck","colors":[],"year":2023},
{"id":"Planeswalker_Party","name":"Planeswalker Party","commander":"Commodore Guff","set":"Commander Masters","fileName":"Planeswalker_Party.dck","colors":[],"year":2023},
{"id":"Sliver_Swarm","name":"Sliver Swarm","commander":"Sliver Gravemother","set":"Commander Masters","fileName":"Sliver_Swarm.dck","colors":[],"year":2023},
{"id":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings","name":"Angels: They're Just Like Us but Cooler and with Wings","commander":"Gisela, the Broken Blade","set":"Secret Lair Drop","fileName":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings.dck","colors":[],"year":2023},
{"id":"Fae_Dominion","name":"Fae Dominion","commander":"Tegwyll, Duke of Splendor","set":"Wilds of Eldraine Commander","fileName":"Fae_Dominion.dck","colors":[],"year":2023},
{"id":"Virtue_and_Valor","name":"Virtue and Valor","commander":"Ellivere of the Wild Court","set":"Wilds of Eldraine Commander","fileName":"Virtue_and_Valor.dck","colors":[],"year":2023},
{"id":"Ahoy_Mateys","name":"Ahoy Mateys","commander":"Admiral Brass, Unsinkable","set":"The Lost Caverns of Ixalan Commander","fileName":"Ahoy_Mateys.dck","colors":[],"year":2023},
{"id":"Blood_Rites","name":"Blood Rites","commander":"Clavileño, First of the Blessed","set":"The Lost Caverns of Ixalan Commander","fileName":"Blood_Rites.dck","colors":[],"year":2023},
{"id":"Explorers_of_the_Deep","name":"Explorers of the Deep","commander":"Hakbal of the Surging Soul","set":"The Lost Caverns of Ixalan Commander","fileName":"Explorers_of_the_Deep.dck","colors":[],"year":2023},
{"id":"Veloci-Ramp-Tor","name":"Veloci-Ramp-Tor","commander":"Pantlaza, Sun-Favored","set":"The Lost Caverns of Ixalan Commander","fileName":"Veloci-Ramp-Tor.dck","colors":[],"year":2023},
{"id":"Raining_Cats_and_Dogs","name":"Raining Cats and Dogs","commander":"Rin and Seri, Inseparable","set":"Secret Lair Drop","fileName":"Raining_Cats_and_Dogs.dck","colors":[],"year":2024},
{"id":"Blame_Game","name":"Blame Game","commander":"Nelly Borca, Impulsive Accuser","set":"Murders at Karlov Manor Commander","fileName":"Blame_Game.dck","colors":[],"year":2024},
{"id":"Deadly_Disguise","name":"Deadly Disguise","commander":"Kaust, Eyes of the Glade","set":"Murders at Karlov Manor Commander","fileName":"Deadly_Disguise.dck","colors":[],"year":2024},
{"id":"Deep_Clue_Sea","name":"Deep Clue Sea","commander":"Morska, Undersea Sleuth","set":"Murders at Karlov Manor Commander","fileName":"Deep_Clue_Sea.dck","colors":[],"year":2024},
{"id":"Revenant_Recon","name":"Revenant Recon","commander":"Mirko, Obsessive Theorist","set":"Murders at Karlov Manor Commander","fileName":"Revenant_Recon.dck","colors":[],"year":2024},
{"id":"Hail,_Caesar","name":"Hail, Caesar","commander":"Caesar, Legion's Emperor","set":"Fallout","fileName":"Hail,_Caesar.dck","colors":[],"year":2024},
{"id":"Mutant_Menace","name":"Mutant Menace","commander":"The Wise Mothman","set":"Fallout","fileName":"Mutant_Menace.dck","colors":[],"year":2024},
{"id":"Science","name":"Science!","commander":"Dr. Madison Li","set":"Fallout","fileName":"Science.dck","colors":[],"year":2024},
{"id":"Scrappy_Survivors","name":"Scrappy Survivors","commander":"Dogmeat, Ever Loyal","set":"Fallout","fileName":"Scrappy_Survivors.dck","colors":[],"year":2024},
{"id":"Desert_Bloom","name":"Desert Bloom","commander":"Yuma, Proud Protector","set":"Outlaws of Thunder Junction Commander","fileName":"Desert_Bloom.dck","colors":[],"year":2024},
{"id":"Grand_Larceny","name":"Grand Larceny","commander":"Gonti, Canny Acquisitor","set":"Outlaws of Thunder Junction Commander","fileName":"Grand_Larceny.dck","colors":[],"year":2024},
{"id":"Most_Wanted","name":"Most Wanted","commander":"Olivia, Opulent Outlaw","set":"Outlaws of Thunder Junction Commander","fileName":"Most_Wanted.dck","colors":[],"year":2024},
{"id":"Quick_Draw","name":"Quick Draw","commander":"Stella Lee, Wild Card","set":"Outlaws of Thunder Junction Commander","fileName":"Quick_Draw.dck","colors":[],"year":2024},
{"id":"Creative_Energy","name":"Creative Energy","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy.dck","colors":[],"year":2024},
{"id":"Creative_Energy_Collectors_Edition","name":"Creative Energy Collector's Edition","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy_Collectors_Edition.dck","colors":[],"year":2024},
{"id":"Eldrazi_Incursion","name":"Eldrazi Incursion","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion.dck","colors":[],"year":2024},
{"id":"Eldrazi_Incursion_Collectors_Edition","name":"Eldrazi Incursion Collector's Edition","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion_Collectors_Edition.dck","colors":[],"year":2024},
{"id":"Graveyard_Overdrive","name":"Graveyard Overdrive","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive.dck","colors":[],"year":2024},
{"id":"Graveyard_Overdrive_Collectors_Edition","name":"Graveyard Overdrive Collector's Edition","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive_Collectors_Edition.dck","colors":[],"year":2024},
{"id":"Tricky_Terrain","name":"Tricky Terrain","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain.dck","colors":[],"year":2024},
{"id":"Tricky_Terrain_Collectors_Edition","name":"Tricky Terrain Collector's Edition","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain_Collectors_Edition.dck","colors":[],"year":2024},
{"id":"Animated_Army","name":"Animated Army","commander":"Bello, Bard of the Brambles","set":"Bloomburrow Commander","fileName":"Animated_Army.dck","colors":[],"year":2024},
{"id":"Family_Matters","name":"Family Matters","commander":"Zinnia, Valley's Voice","set":"Bloomburrow Commander","fileName":"Family_Matters.dck","colors":[],"year":2024},
{"id":"Peace_Offering","name":"Peace Offering","commander":"Ms. Bumbleflower","set":"Bloomburrow Commander","fileName":"Peace_Offering.dck","colors":[],"year":2024},
{"id":"Squirreled_Away","name":"Squirreled Away","commander":"Hazel of the Rootbloom","set":"Bloomburrow Commander","fileName":"Squirreled_Away.dck","colors":[],"year":2024},
{"id":"20_Ways_to_Win","name":"20 Ways to Win","commander":"Go-Shintai of Life's Origin","set":"Secret Lair Drop","fileName":"20_Ways_to_Win.dck","colors":[],"year":2024},
{"id":"Eternal_Might","name":"Eternal Might","commander":"Temmet, Naktamun's Will","set":"Aetherdrift Commander","fileName":"Eternal_Might.dck","colors":[],"year":2025},
{"id":"Living_Energy","name":"Living Energy","commander":"Saheeli, Radiant Creator","set":"Aetherdrift Commander","fileName":"Living_Energy.dck","colors":[],"year":2025},
{"id":"Abzan_Armor","name":"Abzan Armor","commander":"Felothar the Steadfast","set":"Tarkir: Dragonstorm Commander","fileName":"Abzan_Armor.dck","colors":[],"year":2025},
{"id":"Jeskai_Striker","name":"Jeskai Striker","commander":"Shiko and Narset, Unified","set":"Tarkir: Dragonstorm Commander","fileName":"Jeskai_Striker.dck","colors":[],"year":2025},
{"id":"Mardu_Surge","name":"Mardu Surge","commander":"Zurgo Stormrender","set":"Tarkir: Dragonstorm Commander","fileName":"Mardu_Surge.dck","colors":[],"year":2025},
{"id":"Sultai_Arisen","name":"Sultai Arisen","commander":"Teval, the Balanced Scale","set":"Tarkir: Dragonstorm Commander","fileName":"Sultai_Arisen.dck","colors":[],"year":2025},
{"id":"Temur_Roar","name":"Temur Roar","commander":"Eshki, Temur's Roar","set":"Tarkir: Dragonstorm Commander","fileName":"Temur_Roar.dck","colors":[],"year":2025},
{"id":"Everyones_Invited","name":"Everyone's Invited!","commander":"Morophon, the Boundless","set":"Secret Lair Drop","fileName":"Everyones_Invited.dck","colors":[],"year":2025},
{"id":"Counter_Blitz_(FINAL_FANTASY_X)","name":"Counter Blitz (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
{"id":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X)","name":"Counter Blitz Collector's Edition (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
{"id":"Limit_Break_(FINAL_FANTASY_VII)","name":"Limit Break (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
{"id":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII)","name":"Limit Break Collector's Edition (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
{"id":"Revival_Trance_(FINAL_FANTASY_VI)","name":"Revival Trance (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
{"id":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI)","name":"Revival Trance Collector's Edition (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
{"id":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
{"id":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft Collector's Edition (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
{"id":"Counter_Intelligence","name":"Counter Intelligence","commander":"Inspirit, Flagship Vessel","set":"Edge of Eternities Commander","fileName":"Counter_Intelligence.dck","colors":[],"year":2025},
{"id":"World_Shaper","name":"World Shaper","commander":"Hearthhull, the Worldseed","set":"Edge of Eternities Commander","fileName":"World_Shaper.dck","colors":[],"year":2025},
{"id":"Blight_Curse","name":"Blight Curse","commander":"Auntie Ool, Cursewretch","set":"Lorwyn Eclipsed Commander","fileName":"Blight_Curse.dck","colors":[],"year":2026},
{"id":"Dance_of_the_Elements","name":"Dance of the Elements","commander":"Ashling, the Limitless","set":"Lorwyn Eclipsed Commander","fileName":"Dance_of_the_Elements.dck","colors":[],"year":2026},
{"id":"Turtle_Power","name":"Turtle Power!","commander":"Heroes in a Half Shell","set":"Teenage Mutant Ninja Turtles Eternal","fileName":"Turtle_Power.dck","colors":[],"year":2026}
];
+3963
View File
File diff suppressed because it is too large. Load diff
+2515
View File
File diff suppressed because it is too large. Load diff
+204 -83
View File
@@ -4,10 +4,15 @@ Commander AI Lab — Action Labeler
Maps raw Forge action records (from DecisionSnapshot JSONL)
to one of 8 macro-action labels for supervised policy learning.
Classification logic:
1. Check action.type (cast, attack, land, cast_commander, pass)
2. For "cast" actions, classify by card oracle text / card type keywords
3. For ambiguous cases, use the raw log line and card name heuristics
Classification logic (updated):
1. Check action.type for unambiguous types (attack, land, pass, hold,
cast_commander)
2. For "cast" actions:
a. If action.card is present, match directly against name databases
b. If action.card is absent (Forge omits it), scan the active
player's hand against name databases to infer the cast type.
Priority: removal > ramp > draw > commander > creature (default)
3. Fallback: PASS
Macro-actions (from ml.config.scope):
0: CAST_CREATURE — Play a creature
@@ -41,11 +46,6 @@ logger = logging.getLogger("ml.labeler")
# Card Type Keyword Databases
# ══════════════════════════════════════════════════════════
# Known removal spells (partial list — expanded from common Commander staples)
REMOVAL_KEYWORDS = {
"destroy", "exile", "sacrifice", "counter", "bounce", "return.*to.*hand",
"-\\d+/-\\d+", "damage to", "fight", "deathtouch",
}
REMOVAL_CARD_NAMES = {
"swords to plowshares", "path to exile", "beast within", "chaos warp",
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
@@ -62,45 +62,52 @@ REMOVAL_CARD_NAMES = {
"decree of pain", "in garruk's wake", "plague wind",
"fire covenant", "imprisoned in the moon", "darksteel mutation",
"kenrith's transformation", "song of the dryads",
"abrade", "disenchant", "naturalize", "wear", "tear",
"shattering spree", "shatter", "smash to smithereens",
"lightning bolt", "lightning helix", "condemn", "oust",
"pongify", "reality shift", "turn to frog", "polymorph",
"boros charm", "utter end", "anguished unmaking", "mortify",
"putrefy", "maelstrom pulse", "void rend", "render silent",
"overwhelming denial", "test of talents", "dispel", "spell pierce",
"flusterstorm", "mental misstep", "pact of negation",
"opposition agent", "hullbreacher", "narset parter of veils",
"grafdigger's cage", "rest in peace", "leyline of the void",
"tormod's crypt", "relic of progenitus",
}
# Known draw spells
DRAW_KEYWORDS = {
"draw", "scry", "look at the top", "reveal.*library",
"search your library.*hand", "impulse", "brainstorm",
}
DRAW_CARD_NAMES = {
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
"harmonize", "blue sun's zenith", "pull from tomorrow",
"windfall", "wheel of fortune", "timetwister", "brainstorm",
"ponder", "preordain", "opt", "consider", "treasure cruise",
"dig through time", "fact or fiction", "nights whisper",
"nights whisper", "sign in blood", "read the bones",
"painful truths", "ambition's cost", "promise of power",
"necropotence", "dark confidant", "toski bearer of secrets",
"beast whisperer", "guardian project", "the great henge",
"skullclamp", "esper sentinel", "archivist of oghma",
"mind's eye", "consecrated sphinx", "ancient craving",
"faithless looting", "cathartic reunion", "thrill of possibility",
"jeska's will", "valakut awakening", "stinging study",
"night's whisper", "notion thief", "tymna the weaver",
"shamanic revelation", "rishkar's expertise", "return of the wildspeaker",
"garruk's uprising", "elemental bond", "kindred discovery",
"coastal piracy", "reconnaissance mission", "bident of thassa",
"dig through time", "fact or fiction", "night's whisper",
"sign in blood", "read the bones", "painful truths",
"ambition's cost", "promise of power", "necropotence",
"dark confidant", "toski bearer of secrets", "beast whisperer",
"guardian project", "the great henge", "skullclamp",
"esper sentinel", "archivist of oghma", "mind's eye",
"consecrated sphinx", "ancient craving", "faithless looting",
"cathartic reunion", "thrill of possibility", "jeska's will",
"valakut awakening", "stinging study", "notion thief",
"tymna the weaver", "shamanic revelation", "rishkar's expertise",
"return of the wildspeaker", "garruk's uprising", "elemental bond",
"kindred discovery", "coastal piracy", "reconnaissance mission",
"bident of thassa", "distant melody", "slate of ancestry",
"wheel of fate", "reforge the soul", "magus of the wheel",
"river's rebuke", "prosperity", "intellectual offering",
"peer into the abyss", "mind spring", "stroke of genius",
"jace's ingenuity", "tidings", "concentrate", "divination",
"inspiration", "counsel of the soratami", "accumulated knowledge",
"predict", "pressure point", "quicken", "serum visions",
"gitaxian probe", "mental note", "thought scour",
}
# Known ramp cards
RAMP_KEYWORDS = {
"add {", "add one mana", "mana of any color",
"land.*onto the battlefield", "search.*basic land.*battlefield",
}
RAMP_CARD_NAMES = {
"sol ring", "mana crypt", "mana vault", "arcane signet",
"commander's sphere", "chromatic lantern", "fellwar stone",
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
"gilded lotus", "hedron archive", "everflowing chalice",
"coalition relic", "prismatic lens", "signets", # catch-all partial
"talisman", # catch-all for talisman cycle
"coalition relic", "prismatic lens",
"cultivate", "kodama's reach", "rampant growth", "farseek",
"nature's lore", "three visits", "skyshroud claim",
"explosive vegetation", "migration path", "circuitous route",
@@ -112,13 +119,144 @@ RAMP_CARD_NAMES = {
"birds of paradise", "llanowar elves", "elvish mystic",
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
"bloom tender", "priest of titania", "selvala heart of the wilds",
"boreal druid", "green sun's zenith", "eternal witness",
"reclamation sage", "mystic snake", "fierce empath",
"signal the clans", "crop rotation", "harrow", "kodama's reach",
"traverse the ulvenwald", "search for tomorrow", "primal growth",
"land grant", "untamed wilds", "rangers path", "rangers' path",
"nissa's pilgrimage", "forest grove", "grove of the burnwillows",
"cabal stronghold", "urborg tomb of yawgmoth", "urborg, tomb of yawgmoth",
"bojuka bog", "ghost quarter", "field of ruin", "strip mine",
"wasteland", "tectonic edge", "dust bowl",
"worn powerstone", "basalt monolith", "grim monolith",
"mana geode", "darksteel ingot", "manalith", "opaline unicorn",
"vessel of endless rest", "corrupted grafstone", "dreamstone hedron",
"sisay's ring", "ur-golem's eye", "blinkmoth urn",
"astral cornucopia", "druids' repository", "black market",
"cryptolith rite", "earthcraft", "citanul hierophants",
"zendikar resurgent", "mirari's wake", "mana reflection",
"vorinclex voice of hunger", "vorinclex, voice of hunger",
}
# Known creature-type indicators
CREATURE_KEYWORDS = {
"creature", "token", "enters the battlefield",
# Partial-match prefixes (for cycles like Signets, Talismans)
RAMP_PARTIAL_PREFIXES = {
"talisman", "signet", "locket", "banner", "obelisk",
"keyrune", "cluestone", "monolith", "medallion",
}
DRAW_PARTIAL_PREFIXES = {
"scroll", "tome", "journal",
}
# ══════════════════════════════════════════════════════════
# Core classification helpers
# ══════════════════════════════════════════════════════════
def _classify_card_name(name: str) -> Optional[MacroAction]:
"""
Classify a single card name into a MacroAction, or None if unknown.
Checks exact matches first, then partial prefix matches.
Returns None for unrecognised cards (caller handles the default).
"""
n = name.lower().strip()
if not n:
return None
if n in REMOVAL_CARD_NAMES:
return MacroAction.CAST_REMOVAL
if n in DRAW_CARD_NAMES:
return MacroAction.CAST_DRAW
if n in RAMP_CARD_NAMES:
return MacroAction.CAST_RAMP
# Partial prefix match for cycles (Talismans, Signets, etc.)
for prefix in RAMP_PARTIAL_PREFIXES:
if n.startswith(prefix):
return MacroAction.CAST_RAMP
for prefix in DRAW_PARTIAL_PREFIXES:
if n.startswith(prefix):
return MacroAction.CAST_DRAW
# Substring match (catches "Doom Blade" inside longer names etc.)
for known in REMOVAL_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_REMOVAL
for known in DRAW_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_DRAW
for known in RAMP_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_RAMP
return None # Unknown → caller defaults to CAST_CREATURE
def _classify_from_hand(
players: list,
active_seat: int,
commander_names: Optional[set] = None,
) -> MacroAction:
"""
Infer the cast action type by scanning the active player's hand.
When action.card is absent (Forge omits it), the hand snapshot
is the best available signal. We scan all hand cards and return
the highest-priority match:
removal > ramp > draw > commander > creature (default)
Priority rationale: Forge's AI plays the highest-impact available
action, so if any removal/ramp/draw spell is in hand during a cast
decision, it's the most likely card played.
"""
if active_seat >= len(players):
return MacroAction.CAST_CREATURE
hand: List[str] = players[active_seat].get("hand", [])
command_zone: List[str] = players[active_seat].get("command_zone", [])
# Build commander name set from command_zone if not provided
if commander_names is None:
commander_names = {c.lower().strip() for c in command_zone if c}
found_removal = False
found_ramp = False
found_draw = False
found_commander = False
for card in hand:
card_lower = card.lower().strip()
# Commander check: hand card matches a command zone card
if card_lower in commander_names:
found_commander = True
continue
result = _classify_card_name(card_lower)
if result == MacroAction.CAST_REMOVAL:
found_removal = True
elif result == MacroAction.CAST_RAMP:
found_ramp = True
elif result == MacroAction.CAST_DRAW:
found_draw = True
# Return highest-priority found
if found_removal:
return MacroAction.CAST_REMOVAL
if found_ramp:
return MacroAction.CAST_RAMP
if found_draw:
return MacroAction.CAST_DRAW
if found_commander:
return MacroAction.CAST_COMMANDER
return MacroAction.CAST_CREATURE
# ══════════════════════════════════════════════════════════
# Main label_action entry point
# ══════════════════════════════════════════════════════════
def label_action(decision: dict) -> int:
"""
@@ -133,74 +271,52 @@ def label_action(decision: dict) -> int:
action = decision.get("action", {})
action_type = action.get("type", "").lower()
card_name = (action.get("card", "") or "").lower().strip()
raw_line = (action.get("raw", "") or "").lower()
# ── Direct type matching ────────────────────────────
players = decision.get("players", [])
active_seat = decision.get("active_seat", 0)
# ── Unambiguous direct-type matches ──────────────────────
# Commander cast is unambiguous
if action_type == "cast_commander":
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
# Attack is unambiguous
if action_type == "attack":
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
# Land play → classify as CAST_RAMP (developing mana is ramp)
# Land play → developing mana base = ramp
if action_type == "land":
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# Pass / hold mana
# Pass / hold mana — check available mana to distinguish
if action_type in ("pass", "hold"):
# Check if player had mana open (from state)
active_seat = decision.get("active_seat", 0)
players = decision.get("players", [])
if active_seat < len(players):
mana = players[active_seat].get("mana", 0)
if mana >= 2:
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
return ACTION_TO_IDX[MacroAction.PASS]
# ── Cast actions: classify by card identity ─────────
# ── Cast actions ──────────────────────────────────────
if action_type == "cast":
# 1. Check known card name databases
if card_name in REMOVAL_CARD_NAMES or _partial_match(card_name, REMOVAL_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
# Path A: action.card is present — classify it directly
if card_name:
result = _classify_card_name(card_name)
if result is not None:
return ACTION_TO_IDX[result]
# Unrecognised named card → default creature
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
# Path B: action.card absent (Forge omits it) — infer from hand
inferred = _classify_from_hand(players, active_seat)
return ACTION_TO_IDX[inferred]
if card_name in RAMP_CARD_NAMES or _partial_match(card_name, RAMP_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# 2. Check raw log line for keyword patterns
for keyword in REMOVAL_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
for keyword in DRAW_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
for keyword in RAMP_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# 3. Default: if it's a spell cast and not recognized, assume creature
# (most casts in Commander are creatures or creature-adjacent)
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
# ── Fallback ────────────────────────────────────────
# ── Fallback ──────────────────────────────────────────
return ACTION_TO_IDX[MacroAction.PASS]
def _partial_match(name: str, name_set: set) -> bool:
"""Check if card name partially matches any name in the set."""
for known in name_set:
if known in name or name in known:
return True
return False
# ══════════════════════════════════════════════════════════
# File-level helpers
# ══════════════════════════════════════════════════════════
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
"""
@@ -214,7 +330,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
labels = []
raw = []
with open(jsonl_path, "r") as f:
with open(jsonl_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
@@ -232,7 +348,11 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
"""Print and return the distribution of action labels."""
"""Print and return the distribution of action labels.
Iterates MacroAction enum in index order so the display always
matches ACTION_TO_IDX regardless of dict insertion order.
"""
dist = {}
for action in MacroAction:
idx = ACTION_TO_IDX[action]
@@ -242,10 +362,11 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
total = len(labels)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
# Sort descending by count for readability
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
logger.info(f"{'─' * 43}")
logger.info("─" * 43)
logger.info(f" {'TOTAL':<23} {total:>8}")
return dist
+9 -1
View File
@@ -292,7 +292,15 @@ class DistillationDefaults:
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
# Increased from 0.01 to 0.04 to prevent premature convergence on the
# "pass"/"land" macro-actions. The policy-collapse bug (see
# commander-ai-lab-land-only-bug-analysis.md) is exacerbated by a low
# entropy bonus: PPO settled on argmax very early in training, so the
# model never learned to cast creatures / removal / draw spells even
# when they were affordable. A higher entropy bonus keeps the action
# distribution flat enough during self-play for those alternatives to
# accumulate reward signal.
ppo_entropy_coeff: float = 0.04
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
+33 -18
View File
@@ -6,8 +6,9 @@ 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)
- Scans Forge sim files (ml-decisions-sim-*.jsonl) → source "forge"
- Scans PPO self-play (ml-decisions-ppo-*.jsonl) → source "ppo"
- Scans DeepSeek files (ml-decisions-ds-*.jsonl) → source "ds"
- Configurable source_weights for sampling ratios
- min_reward_threshold filter for PPO data quality
- Each sample tagged with source for downstream analysis
@@ -21,7 +22,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")
sources: object (N,) — data source ("forge", "ppo", or "ds")
"""
import argparse
@@ -62,11 +63,11 @@ class DatasetConfig:
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 control sampling ratios between Forge, PPO, and DS data.
# ds=4.0 oversamples the 8 DeepSeek files from ~5.8% → ~20% of effective
# training tokens, matching their higher signal quality relative to sim data.
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
)
# Minimum reward threshold for PPO data — only include decisions
@@ -81,12 +82,13 @@ class DatasetConfig:
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
"""
Scan results directory for both Forge sim and PPO decision files.
Scan results directory for Forge sim, PPO, and DeepSeek 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
"ds" for ml-decisions-ds-*.jsonl
"""
results_path = Path(results_dir)
sources = {}
@@ -101,10 +103,16 @@ def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
if ppo_files:
sources["ppo"] = ppo_files
# Also pick up legacy files that don't have sim/ppo prefix
# DeepSeek high-quality decisions (upweighted by default)
ds_files = sorted(results_path.glob("ml-decisions-ds-*.jsonl"))
if ds_files:
sources["ds"] = ds_files
# Legacy files that don't match any known prefix — treat as forge
known = set(forge_files + ppo_files + ds_files)
legacy_files = sorted(
f for f in results_path.glob("ml-decisions-*.jsonl")
if f not in set(forge_files + ppo_files)
if f not in known
)
if legacy_files:
sources.setdefault("forge", []).extend(legacy_files)
@@ -123,7 +131,7 @@ def _passes_reward_filter(
) -> bool:
"""
Filter PPO decisions by reward threshold.
Forge data always passes. PPO data must meet the threshold.
Forge and DS data always passes. PPO data must meet the threshold.
"""
if source != "ppo":
return True
@@ -155,7 +163,7 @@ def _apply_source_weights(
if weight <= 0:
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
continue
if weight >= 1.0 and weight == 1.0:
if weight == 1.0:
combined.extend(samples)
elif weight < 1.0:
# Subsample
@@ -193,8 +201,9 @@ def build_dataset(
Build complete training dataset from all ML decision files.
Phase 2 additions:
- Scans both Forge and PPO JSONL files
- Scans Forge, PPO, and DeepSeek (ds) JSONL files
- Applies source_weights for configurable mixing
(default: ds=4.0 to oversample high-quality DeepSeek decisions)
- Filters PPO data by min_reward_threshold
- Tags each sample with its source
@@ -202,14 +211,15 @@ def build_dataset(
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)
source_weights: Dict of source → sampling weight
(default: forge=1.0, ppo=0.0, ds=4.0)
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0}
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
@@ -225,7 +235,7 @@ def build_dataset(
encoder = StateEncoder(card_index)
# --- Phase 2 Task 1: Discover files from both sources ---
# --- Phase 2 Task 1: Discover files from all sources ---
source_files = _discover_jsonl_files(results_dir)
if not source_files:
logger.error("No ML decision files found in %s", results_dir)
@@ -256,7 +266,7 @@ def build_dataset(
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r") as f:
with open(jsonl_file, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
@@ -503,7 +513,11 @@ def main():
)
parser.add_argument(
"--ppo-weight", type=float, default=0.0,
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
help="Sampling weight for PPO self-play data (default: 0.0, disabled)",
)
parser.add_argument(
"--ds-weight", type=float, default=4.0,
help="Sampling weight for DeepSeek decision data (default: 4.0, ~20%% effective share)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
@@ -515,6 +529,7 @@ def main():
source_weights = {
"forge": args.forge_weight,
"ppo": args.ppo_weight,
"ds": args.ds_weight,
}
# Build dataset
+2 -2
View File
@@ -523,7 +523,7 @@ class QualityGate:
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w") as f:
with open(report_path, "w", encoding="utf-8") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
@@ -582,6 +582,6 @@ class QualityGate:
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
+106 -38
View File
@@ -141,8 +141,17 @@ class CardEmbeddingIndex:
logger.error("Auto-download failed: %s", e)
return None
def get_embedding(self, card_name: str) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found."""
def get_embedding(self, card_name) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found.
Accepts either a plain string or a dict with a 'name' key (defensive
against the LAN client sending structured battlefield objects).
"""
# Handle dict objects from the LAN client's battlefield_detail
if isinstance(card_name, dict):
card_name = card_name.get('name', '')
if not card_name or not isinstance(card_name, str):
return self._zero_vec
if not self._loaded:
return self._zero_vec
idx = self._name_to_idx.get(card_name.lower())
@@ -155,8 +164,11 @@ class CardEmbeddingIndex:
return self._embeddings[idx]
return self._zero_vec
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone)."""
def mean_pool_zone(self, card_names: list) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone).
Each entry may be a plain string or a dict with a 'name' key.
"""
if not card_names:
return self._zero_vec
vectors = [self.get_embedding(name) for name in card_names]
@@ -167,6 +179,24 @@ class CardEmbeddingIndex:
return self._loaded
def _resolve_cmdr_dmg(raw) -> int:
"""Collapse cmdr_dmg to a scalar integer regardless of source format.
Forge sends cmdr_dmg as a dict keyed by opponent seat string, e.g.
{"0": 14, "2": 7}. _build_encoder_snapshot already sums this before
passing to predict(), but the live Forge IPC path (and legacy JSONL)
may send the raw dict. Summing all values is correct for Commander:
the total commander damage received from all opponents is what matters
for the life-loss threshold (21).
"""
if isinstance(raw, dict):
return sum(int(v) for v in raw.values())
try:
return int(raw)
except (TypeError, ValueError):
return 0
class StateEncoder:
"""
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
@@ -178,6 +208,23 @@ class StateEncoder:
[0:29] - Global features (life, mana, turn, phase, etc.)
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
[6173:6177] - Playstyle one-hot (4 dims)
Global feature layout per player block (14 features × 2 players + 1 turn = 29):
idx 0 life (normalised /40)
idx 1 cmdr_dmg (normalised /21)
idx 2 mana (normalised /20, clamped)
idx 3 cmdr_tax (normalised /10, clamped)
idx 4 hand_size (normalised /15, clamped)
idx 5 graveyard_size (normalised /100, clamped)
idx 6 creatures (normalised /30, clamped)
idx 7 total_power (normalised /100, clamped)
└─ real Forge value when present;
falls back to creatures*3 heuristic
idx 8 is_active (binary 0/1)
idx 9-12 phase one-hot (4 dims: main_1/combat/main_2/end)
idx 13 lands (normalised /15, clamped)
--- repeat for player 1 at idx 14-27 ---
idx 28 turn (normalised /max_turns)
"""
def __init__(self, card_index: CardEmbeddingIndex):
@@ -197,7 +244,6 @@ class StateEncoder:
"""
players = decision.get("players", [])
if len(players) < 2:
# Pad to 2 players for 1v1
while len(players) < 2:
players.append(self._empty_player(len(players)))
@@ -218,55 +264,76 @@ class StateEncoder:
return state.astype(np.float32)
def _encode_global(self, decision: dict, players: list) -> np.ndarray:
"""Encode global scalar features. Shape: (29,)"""
"""Encode global scalar features. Shape: (29,)
Per-player block (14 features each, 2 players = 28) + 1 turn feature.
Index 7 priority for total_power:
1. p['total_power'] — real Forge value from Step 4 / _build_encoder_snapshot()
2. p['creatures'] * 3 — legacy heuristic for old JSONL snapshots
cmdr_dmg is resolved via _resolve_cmdr_dmg() which handles both the
live Forge format (dict keyed by opponent seat) and the pre-collapsed
int from _build_encoder_snapshot / JSONL training snapshots.
"""
features = []
for p in players[:2]: # Exactly 2 players for 1v1
# Per-player scalars (10 each)
for p in players[:2]:
# idx 0: life
life = p.get("life", 40)
features.append(life / 40.0) # Normalized life
features.append(life / 40.0)
cmdr_dmg = p.get("cmdr_dmg", 0)
features.append(cmdr_dmg / 21.0) # Normalized commander damage
# idx 1: commander damage taken
# _resolve_cmdr_dmg handles dict (live Forge IPC) and int (JSONL/pre-collapsed)
cmdr_dmg = _resolve_cmdr_dmg(p.get("cmdr_dmg", 0))
features.append(cmdr_dmg / 21.0)
# idx 2: mana available
mana = p.get("mana", 0)
features.append(min(mana / 20.0, 1.0)) # Normalized mana
features.append(min(mana / 20.0, 1.0))
# idx 3: commander tax
cmdr_tax = p.get("cmdr_tax", 0)
features.append(min(cmdr_tax / 10.0, 1.0)) # Normalized tax
features.append(min(cmdr_tax / 10.0, 1.0))
# idx 4: cards in hand
hand_size = len(p.get("hand", []))
features.append(min(hand_size / 15.0, 1.0))
# idx 5: graveyard size
grave_size = len(p.get("graveyard", []))
features.append(min(grave_size / 100.0, 1.0))
# idx 6: creatures on battlefield
creatures = p.get("creatures", 0)
features.append(min(creatures / 30.0, 1.0))
# Total power on board (not yet tracked accurately; use creatures as proxy)
total_power = creatures * 3 # rough heuristic: avg 3 power
# idx 7: total power on board
# Use real Forge value when available (Step 4); fall back to heuristic
# for legacy JSONL snapshots that predate Step 4.
raw_power = p.get("total_power", 0)
total_power = raw_power if raw_power > 0 else creatures * 3
features.append(min(total_power / 100.0, 1.0))
# Is active player
# idx 8: is active player
is_active = 1.0 if p.get("seat") == decision.get("active_seat") else 0.0
features.append(is_active)
# Land count (extra feature — helps distinguish ramp strategies)
lands = p.get("lands", 0)
features.append(min(lands / 15.0, 1.0))
# Phase one-hot (4 dims per player)
# idx 9-12: phase one-hot (4 dims)
phase_str = decision.get("phase", "main_1")
phase_oh = [0.0] * 4
try:
phase_enum = GamePhase(phase_str)
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
except (ValueError, KeyError):
phase_oh[0] = 1.0 # Default to main_1
phase_oh[0] = 1.0 # default to main_1
features.extend(phase_oh)
# Turn number (1 feature, shared)
# idx 13: land count
lands = p.get("lands", 0)
features.append(min(lands / 15.0, 1.0))
# idx 28: turn number (shared, 1 feature)
turn = decision.get("turn", 1)
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
@@ -290,8 +357,6 @@ class StateEncoder:
for p in players[:2]:
for zone_key in zone_keys:
cards = p.get(zone_key, [])
# mean_pool_zone returns a zero vector when cards is empty,
# preserving the fixed 768-dim slot size.
pooled = self.card_index.mean_pool_zone(cards)
vectors.append(pooled)
@@ -304,24 +369,27 @@ class StateEncoder:
style = Playstyle(playstyle_str.lower())
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
except (ValueError, KeyError):
# Default: midrange
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
return vec
def _empty_player(self, seat: int) -> dict:
"""Create an empty player dict for padding."""
return {
"seat": seat,
"life": 40,
"cmdr_dmg": 0,
"mana": 0,
"cmdr_tax": 0,
"creatures": 0,
"lands": 0,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
"seat": seat,
"life": 40,
"cmdr_dmg": 0,
"mana": 0,
"cmdr_tax": 0,
"creatures": 0,
"total_power": 0,
"total_toughness": 0,
"artifacts": 0,
"enchantments": 0,
"lands": 0,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
}
@@ -347,7 +415,7 @@ def encode_decisions_file(
states = []
raw = []
with open(jsonl_path, "r") as f:
with open(jsonl_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
if max_samples and i >= max_samples:
break
+2 -2
View File
@@ -130,7 +130,7 @@ class EloHistory:
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
with open(self.path, "r", encoding="utf-8") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
@@ -138,7 +138,7 @@ class EloHistory:
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w") as f:
with open(self.path, "w", encoding="utf-8") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
+548
View File
@@ -0,0 +1,548 @@
"""
Commander AI Lab — Forge Evaluator (Issue #153)
═════════════════════════════════════════════════
Reusable eval harness that:
1. Starts the policy inference server pointing at a given checkpoint
2. Launches Forge games via MultiThreadBatchRunner with the policy
server wired in as the AI decision-maker
3. Collects per-game metrics and writes a JSON results file
Metrics collected per game:
- won : bool
- turns : int
- life_final : int (winning seat final life)
- life_delta : int (winner life - loser life at game end)
- cmd_damage : int (commander damage dealt by policy seat)
- illegal_acts : int (actions rejected by Forge — should be 0)
- entropy_mean : float (mean policy entropy across decision steps)
- inference_ms : float (mean per-step inference latency)
Usage:
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
cfg = EvalConfig(
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
num_games=200,
num_threads=4,
run_id="forge-trained",
results_dir="results",
)
evaluator = ForgeEvaluator(cfg)
summary = evaluator.run()
print(summary)
"""
import json
import logging
import os
import sys
import time
import uuid
import subprocess
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
logger = logging.getLogger("ml.eval.forge_evaluator")
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
@dataclass
class EvalConfig:
"""All parameters needed for a single evaluation run."""
# Model
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
embeddings_dir: str = "embeddings"
device: str = None # None = auto-detect
# Forge
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
forge_work_dir: str = "forge"
java_path: str = "java"
deck_files: List[str] = field(default_factory=list)
deck_names: List[str] = field(default_factory=list)
policy_server_url: str = "http://localhost:8091"
clock_seconds: int = 120
# Run
num_games: int = 200
num_threads: int = 4
seed: Optional[int] = None
run_id: str = ""
results_dir: str = "results"
# Inference
greedy: bool = True
temperature: float = 1.0
def __post_init__(self):
if not self.run_id:
self.run_id = str(uuid.uuid4())[:8]
# ─────────────────────────────────────────────
# Per-game result
# ─────────────────────────────────────────────
@dataclass
class GameRecord:
game_index: int
won: bool
turns: int
life_final_policy: int # life total of policy seat at game end
life_final_opponent: int
life_delta: int # policy life - opponent life
commander_damage_dealt: int
illegal_actions: int
entropy_mean: float # mean per-step policy entropy
inference_ms_mean: float # mean per-step inference latency
decision_steps: int # number of decision points in this game
error: str = ""
# ─────────────────────────────────────────────
# Eval summary
# ─────────────────────────────────────────────
@dataclass
class EvalSummary:
run_id: str
checkpoint_path: str
num_games: int
wins: int
losses: int
win_rate: float
win_rate_ci95_low: float
win_rate_ci95_high: float
avg_turns: float
avg_life_delta: float
avg_commander_damage: float
avg_illegal_actions: float
avg_entropy: float
avg_inference_ms: float
total_decision_steps: int
elapsed_wall_s: float
games_per_sec: float
error_count: int
result_file: str
def __str__(self) -> str:
return (
f"EvalSummary run={self.run_id}\n"
f" checkpoint : {self.checkpoint_path}\n"
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
f" win rate : {self.win_rate:.1%} "
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
f" avg turns : {self.avg_turns:.1f}\n"
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
f" entropy : {self.avg_entropy:.4f}\n"
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
f" throughput : {self.games_per_sec:.2f} games/sec\n"
f" result file: {self.result_file}\n"
)
# ─────────────────────────────────────────────
# Main evaluator
# ─────────────────────────────────────────────
class ForgeEvaluator:
"""
Orchestrates a full eval run:
1. Spin up PolicyInferenceService on cfg.policy_server_url port
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
3. Parse Forge JSONL output for per-game metrics
4. Write results to {results_dir}/eval-{run_id}.json
"""
def __init__(self, cfg: EvalConfig):
self.cfg = cfg
self._records: List[GameRecord] = []
self._result_path = os.path.join(
cfg.results_dir, f"eval-{cfg.run_id}.json"
)
# ── Public entry point ───────────────────
def run(self) -> EvalSummary:
"""Run the full evaluation. Returns an EvalSummary."""
t0 = time.time()
os.makedirs(self.cfg.results_dir, exist_ok=True)
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
# Load policy service
service = self._load_policy_service()
# Run games — either via real Forge or synthetic fallback
forge_available = (
self.cfg.forge_jar
and Path(self.cfg.forge_jar).exists()
and len(self.cfg.deck_files) >= 3
)
if forge_available:
self._records = self._run_forge_games(service)
else:
logger.warning(
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
self.cfg.run_id
)
self._records = self._run_synthetic_eval(service)
elapsed = time.time() - t0
summary = self._build_summary(elapsed)
self._write_results(summary)
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
return summary
# ── Policy service setup ─────────────────
def _load_policy_service(self):
try:
from ml.serving.policy_server import PolicyInferenceService
svc = PolicyInferenceService(
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
embeddings_dir=self.cfg.embeddings_dir,
device=self.cfg.device,
)
svc.load()
ok = svc.reload(self.cfg.checkpoint_path)
if not ok:
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
return svc
except Exception as e:
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
return None
# ── Real Forge game runner ───────────────
def _run_forge_games(self, service) -> List[GameRecord]:
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
deck_args = []
for i, df in enumerate(self.cfg.deck_files[:4]):
deck_args.extend([f"--deck{i+1}", Path(df).stem])
# Resolve Java 17
java_exe = self.cfg.java_path
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
if Path(c).exists():
java_exe = c
break
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
if not Path(lab_jar).exists():
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
return self._run_synthetic_eval(service)
cmd = [
java_exe, "-jar", lab_jar,
"--forge-jar", self.cfg.forge_jar,
"--forge-dir", self.cfg.forge_work_dir,
*deck_args,
"--games", str(self.cfg.num_games),
"--threads", str(self.cfg.num_threads),
"--clock", str(self.cfg.clock_seconds),
"--ml-log",
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
]
if self.cfg.seed is not None:
cmd.extend(["--seed", str(self.cfg.seed)])
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
if result.returncode != 0:
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
return self._run_synthetic_eval(service)
return self._parse_batch_json(service)
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding="utf-8") as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
"""Parse JSONL files produced by a separately-launched Forge batch."""
records = []
results_path = Path(self.cfg.results_dir)
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
all_files = sorted(results_path.glob(pattern))
if not all_files:
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
return self._run_synthetic_eval(service)
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
games_seen: Dict[str, list] = {}
for f in all_files:
for snap in parse_forge_jsonl(f):
gid = snap.get("game_id", "g0")
games_seen.setdefault(gid, []).append(snap)
for idx, (gid, snaps) in enumerate(games_seen.items()):
records.append(self._snaps_to_record(idx, gid, snaps, service))
if len(records) >= self.cfg.num_games:
break
return records
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
"""Convert Java GameResult objects to GameRecord."""
records = []
for jr in java_results:
try:
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
records.append(GameRecord(
game_index=getattr(jr, "gameIndex", len(records)),
won=won,
turns=getattr(jr, "turns", 0),
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
life_delta=0,
commander_damage_dealt=0,
illegal_actions=getattr(jr, "illegalActions", 0),
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=0,
))
except Exception as e:
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
return records
# ── Synthetic fallback ───────────────────
def _run_synthetic_eval(self, service) -> List[GameRecord]:
"""Run synthetic games using self_play engine (no Forge required).
Used when Forge JAR is missing or deck files aren't configured.
Metrics like illegal_actions and commander_damage are 0 in this mode.
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
records = []
try:
from ml.training.self_play import (
create_random_initial_state, apply_action, advance_turn,
encode_state_simple, HeuristicPolicy
)
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
import torch
import torch.nn.functional as F
except ImportError as e:
logger.error("[Eval] Cannot import self_play: %s", e)
return []
opponent = HeuristicPolicy()
agent_has_model = service is not None and service._loaded
for game_idx in range(self.cfg.num_games):
state = create_random_initial_state()
entropies, latencies = [], []
steps = 0
while not state.game_over and steps < 60:
sv = encode_state_simple(state, 0, "midrange")
t0 = time.time()
if agent_has_model:
snap = state.to_snapshot()
result = service.predict(
snap, playstyle="midrange",
greedy=self.cfg.greedy,
temperature=self.cfg.temperature,
)
action_idx = result.get("action_index", 7)
probs_map = result.get("probabilities", {})
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
else:
action_idx = np.random.randint(NUM_ACTIONS)
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
latencies.append((time.time() - t0) * 1000)
p = np.clip(probs, 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
state = apply_action(state, action_idx, 0)
if not state.game_over:
opp_sv = encode_state_simple(state, 1, "midrange")
opp_a, _ = opponent.select_action(opp_sv)
state = apply_action(state, opp_a, 1)
if not state.game_over:
state = advance_turn(state)
steps += 1
won = state.winner == 0
lp = state.players[0].life_total
lo = state.players[1].life_total
records.append(GameRecord(
game_index=game_idx,
won=won,
turns=state.turn,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=steps,
))
return records
# ── JSONL snapshot → GameRecord ──────────
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
entropies, latencies = [], []
won = False
turns = len(snaps)
cmd_dmg = 0
illegal = 0
lp, lo = 40, 40
for snap in snaps:
if "outcome" in snap and snap == snaps[-1]:
won = snap["outcome"].get("winner_seat", -1) == 0
lp = snap["outcome"].get("life_remaining", 40)
lo = snap["outcome"].get("opp_life_remaining", 0)
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
if service and service._loaded and "state_vector" in snap:
t0 = time.time()
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
latencies.append((time.time() - t0) * 1000)
probs_map = result.get("probabilities", {})
if probs_map:
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
return GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=cmd_dmg,
illegal_actions=illegal,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=len(snaps),
)
# ── Summary builder ──────────────────────
def _build_summary(self, elapsed: float) -> EvalSummary:
n = len(self._records)
if n == 0:
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=0, wins=0, losses=0,
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
error_count=0, result_file=self._result_path,
)
wins = sum(1 for r in self._records if r.won)
losses = n - wins
wr = wins / n
# Wilson score 95% CI
z = 1.96
denom = 1 + z**2 / n
centre = (wr + z**2 / (2 * n)) / denom
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
ci_lo = max(0.0, centre - margin)
ci_hi = min(1.0, centre + margin)
errors = sum(1 for r in self._records if r.error)
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=n,
wins=wins,
losses=losses,
win_rate=wr,
win_rate_ci95_low=ci_lo,
win_rate_ci95_high=ci_hi,
avg_turns=float(np.mean([r.turns for r in self._records])),
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
total_decision_steps=sum(r.decision_steps for r in self._records),
elapsed_wall_s=elapsed,
games_per_sec=n / max(elapsed, 0.001),
error_count=errors,
result_file=self._result_path,
)
# ── Result writer ────────────────────────
def _write_results(self, summary: EvalSummary) -> None:
payload = {
"summary": asdict(summary),
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
+548
View File
@@ -0,0 +1,548 @@
"""
Commander AI Lab — Forge Evaluator (Issue #153)
═════════════════════════════════════════════════
Reusable eval harness that:
1. Starts the policy inference server pointing at a given checkpoint
2. Launches Forge games via MultiThreadBatchRunner with the policy
server wired in as the AI decision-maker
3. Collects per-game metrics and writes a JSON results file
Metrics collected per game:
- won : bool
- turns : int
- life_final : int (winning seat final life)
- life_delta : int (winner life - loser life at game end)
- cmd_damage : int (commander damage dealt by policy seat)
- illegal_acts : int (actions rejected by Forge — should be 0)
- entropy_mean : float (mean policy entropy across decision steps)
- inference_ms : float (mean per-step inference latency)
Usage:
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
cfg = EvalConfig(
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
num_games=200,
num_threads=4,
run_id="forge-trained",
results_dir="results",
)
evaluator = ForgeEvaluator(cfg)
summary = evaluator.run()
print(summary)
"""
import json
import logging
import os
import sys
import time
import uuid
import subprocess
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
logger = logging.getLogger("ml.eval.forge_evaluator")
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
@dataclass
class EvalConfig:
"""All parameters needed for a single evaluation run."""
# Model
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
embeddings_dir: str = "embeddings"
device: str = None # None = auto-detect
# Forge
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
forge_work_dir: str = "forge"
java_path: str = "java"
deck_files: List[str] = field(default_factory=list)
deck_names: List[str] = field(default_factory=list)
policy_server_url: str = "http://localhost:8091"
clock_seconds: int = 120
# Run
num_games: int = 200
num_threads: int = 4
seed: Optional[int] = None
run_id: str = ""
results_dir: str = "results"
# Inference
greedy: bool = True
temperature: float = 1.0
def __post_init__(self):
if not self.run_id:
self.run_id = str(uuid.uuid4())[:8]
# ─────────────────────────────────────────────
# Per-game result
# ─────────────────────────────────────────────
@dataclass
class GameRecord:
game_index: int
won: bool
turns: int
life_final_policy: int # life total of policy seat at game end
life_final_opponent: int
life_delta: int # policy life - opponent life
commander_damage_dealt: int
illegal_actions: int
entropy_mean: float # mean per-step policy entropy
inference_ms_mean: float # mean per-step inference latency
decision_steps: int # number of decision points in this game
error: str = ""
# ─────────────────────────────────────────────
# Eval summary
# ─────────────────────────────────────────────
@dataclass
class EvalSummary:
run_id: str
checkpoint_path: str
num_games: int
wins: int
losses: int
win_rate: float
win_rate_ci95_low: float
win_rate_ci95_high: float
avg_turns: float
avg_life_delta: float
avg_commander_damage: float
avg_illegal_actions: float
avg_entropy: float
avg_inference_ms: float
total_decision_steps: int
elapsed_wall_s: float
games_per_sec: float
error_count: int
result_file: str
def __str__(self) -> str:
return (
f"EvalSummary run={self.run_id}\n"
f" checkpoint : {self.checkpoint_path}\n"
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
f" win rate : {self.win_rate:.1%} "
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
f" avg turns : {self.avg_turns:.1f}\n"
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
f" entropy : {self.avg_entropy:.4f}\n"
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
f" throughput : {self.games_per_sec:.2f} games/sec\n"
f" result file: {self.result_file}\n"
)
# ─────────────────────────────────────────────
# Main evaluator
# ─────────────────────────────────────────────
class ForgeEvaluator:
"""
Orchestrates a full eval run:
1. Spin up PolicyInferenceService on cfg.policy_server_url port
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
3. Parse Forge JSONL output for per-game metrics
4. Write results to {results_dir}/eval-{run_id}.json
"""
def __init__(self, cfg: EvalConfig):
self.cfg = cfg
self._records: List[GameRecord] = []
self._result_path = os.path.join(
cfg.results_dir, f"eval-{cfg.run_id}.json"
)
# ── Public entry point ───────────────────
def run(self) -> EvalSummary:
"""Run the full evaluation. Returns an EvalSummary."""
t0 = time.time()
os.makedirs(self.cfg.results_dir, exist_ok=True)
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
# Load policy service
service = self._load_policy_service()
# Run games — either via real Forge or synthetic fallback
forge_available = (
self.cfg.forge_jar
and Path(self.cfg.forge_jar).exists()
and len(self.cfg.deck_files) >= 3
)
if forge_available:
self._records = self._run_forge_games(service)
else:
logger.warning(
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
self.cfg.run_id
)
self._records = self._run_synthetic_eval(service)
elapsed = time.time() - t0
summary = self._build_summary(elapsed)
self._write_results(summary)
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
return summary
# ── Policy service setup ─────────────────
def _load_policy_service(self):
try:
from ml.serving.policy_server import PolicyInferenceService
svc = PolicyInferenceService(
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
embeddings_dir=self.cfg.embeddings_dir,
device=self.cfg.device,
)
svc.load()
ok = svc.reload(self.cfg.checkpoint_path)
if not ok:
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
return svc
except Exception as e:
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
return None
# ── Real Forge game runner ───────────────
def _run_forge_games(self, service) -> List[GameRecord]:
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
deck_args = []
for i, df in enumerate(self.cfg.deck_files[:4]):
deck_args.extend([f"--deck{i+1}", Path(df).stem])
# Resolve Java 17
java_exe = self.cfg.java_path
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
if Path(c).exists():
java_exe = c
break
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
if not Path(lab_jar).exists():
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
return self._run_synthetic_eval(service)
cmd = [
java_exe, "-jar", lab_jar,
"--forge-jar", self.cfg.forge_jar,
"--forge-dir", self.cfg.forge_work_dir,
*deck_args,
"--games", str(self.cfg.num_games),
"--threads", str(self.cfg.num_threads),
"--clock", str(self.cfg.clock_seconds),
"--ml-log",
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
]
if self.cfg.seed is not None:
cmd.extend(["--seed", str(self.cfg.seed)])
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
if result.returncode != 0:
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
return self._run_synthetic_eval(service)
return self._parse_batch_json(service)
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding="utf-8") as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
"""Parse JSONL files produced by a separately-launched Forge batch."""
records = []
results_path = Path(self.cfg.results_dir)
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
all_files = sorted(results_path.glob(pattern))
if not all_files:
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
return self._run_synthetic_eval(service)
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
games_seen: Dict[str, list] = {}
for f in all_files:
for snap in parse_forge_jsonl(f):
gid = snap.get("game_id", "g0")
games_seen.setdefault(gid, []).append(snap)
for idx, (gid, snaps) in enumerate(games_seen.items()):
records.append(self._snaps_to_record(idx, gid, snaps, service))
if len(records) >= self.cfg.num_games:
break
return records
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
"""Convert Java GameResult objects to GameRecord."""
records = []
for jr in java_results:
try:
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
records.append(GameRecord(
game_index=getattr(jr, "gameIndex", len(records)),
won=won,
turns=getattr(jr, "turns", 0),
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
life_delta=0,
commander_damage_dealt=0,
illegal_actions=getattr(jr, "illegalActions", 0),
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=0,
))
except Exception as e:
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
return records
# ── Synthetic fallback ───────────────────
def _run_synthetic_eval(self, service) -> List[GameRecord]:
"""Run synthetic games using self_play engine (no Forge required).
Used when Forge JAR is missing or deck files aren't configured.
Metrics like illegal_actions and commander_damage are 0 in this mode.
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
records = []
try:
from ml.training.self_play import (
create_random_initial_state, apply_action, advance_turn,
encode_state_simple, HeuristicPolicy
)
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
import torch
import torch.nn.functional as F
except ImportError as e:
logger.error("[Eval] Cannot import self_play: %s", e)
return []
opponent = HeuristicPolicy()
agent_has_model = service is not None and service._loaded
for game_idx in range(self.cfg.num_games):
state = create_random_initial_state()
entropies, latencies = [], []
steps = 0
while not state.game_over and steps < 60:
sv = encode_state_simple(state, 0, "midrange")
t0 = time.time()
if agent_has_model:
snap = state.to_snapshot()
result = service.predict(
snap, playstyle="midrange",
greedy=self.cfg.greedy,
temperature=self.cfg.temperature,
)
action_idx = result.get("action_index", 7)
probs_map = result.get("probabilities", {})
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
else:
action_idx = np.random.randint(NUM_ACTIONS)
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
latencies.append((time.time() - t0) * 1000)
p = np.clip(probs, 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
state = apply_action(state, action_idx, 0)
if not state.game_over:
opp_sv = encode_state_simple(state, 1, "midrange")
opp_a, _ = opponent.select_action(opp_sv)
state = apply_action(state, opp_a, 1)
if not state.game_over:
state = advance_turn(state)
steps += 1
won = state.winner == 0
lp = state.players[0].life_total
lo = state.players[1].life_total
records.append(GameRecord(
game_index=game_idx,
won=won,
turns=state.turn,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=steps,
))
return records
# ── JSONL snapshot → GameRecord ──────────
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
entropies, latencies = [], []
won = False
turns = len(snaps)
cmd_dmg = 0
illegal = 0
lp, lo = 40, 40
for snap in snaps:
if "outcome" in snap and snap == snaps[-1]:
won = snap["outcome"].get("winner_seat", -1) == 0
lp = snap["outcome"].get("life_remaining", 40)
lo = snap["outcome"].get("opp_life_remaining", 0)
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
if service and service._loaded and "state_vector" in snap:
t0 = time.time()
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
latencies.append((time.time() - t0) * 1000)
probs_map = result.get("probabilities", {})
if probs_map:
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
return GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=cmd_dmg,
illegal_actions=illegal,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=len(snaps),
)
# ── Summary builder ──────────────────────
def _build_summary(self, elapsed: float) -> EvalSummary:
n = len(self._records)
if n == 0:
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=0, wins=0, losses=0,
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
error_count=0, result_file=self._result_path,
)
wins = sum(1 for r in self._records if r.won)
losses = n - wins
wr = wins / n
# Wilson score 95% CI
z = 1.96
denom = 1 + z**2 / n
centre = (wr + z**2 / (2 * n)) / denom
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
ci_lo = max(0.0, centre - margin)
ci_hi = min(1.0, centre + margin)
errors = sum(1 for r in self._records if r.error)
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=n,
wins=wins,
losses=losses,
win_rate=wr,
win_rate_ci95_low=ci_lo,
win_rate_ci95_high=ci_hi,
avg_turns=float(np.mean([r.turns for r in self._records])),
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
total_decision_steps=sum(r.decision_steps for r in self._records),
elapsed_wall_s=elapsed,
games_per_sec=n / max(elapsed, 0.001),
error_count=errors,
result_file=self._result_path,
)
# ── Result writer ────────────────────────
def _write_results(self, summary: EvalSummary) -> None:
payload = {
"summary": asdict(summary),
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
+1 -1
View File
@@ -417,7 +417,7 @@ def main():
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
+2 -2
View File
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
section = "Main"
with open(dck_file, "r") as f:
with open(dck_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//") or line.startswith("Name="):
@@ -380,7 +380,7 @@ def main():
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
total_decisions = 0
with open(args.output, "w") as f:
with open(args.output, "w", encoding="utf-8") as f:
for game_idx in range(args.games):
# Pick two random decks
d1_name, d2_name = random.sample(deck_names, 2)
+3 -3
View File
@@ -68,7 +68,7 @@ def cmd_status(args):
for f in jsonl_files:
lines = 0
try:
with open(f) as fh:
with open(f, encoding="utf-8") as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -130,7 +130,7 @@ def cmd_inspect(args):
print(f"\n Inspecting: {filepath.name}\n")
count = 0
with open(filepath) as f:
with open(filepath, encoding="utf-8") as f:
for i, line in enumerate(f):
if i >= args.limit:
break
@@ -150,7 +150,7 @@ def cmd_inspect(args):
except Exception as e:
print(f" [{i:>4}] ERROR: {e}")
with open(filepath) as f:
with open(filepath, encoding="utf-8") as f:
total = sum(1 for _ in f)
print(f"\n Showing {count} of {total:,} total decisions.\n")
+48 -13
View File
@@ -52,14 +52,44 @@ from ml.config.scope import (
)
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
logger = logging.getLogger("ml.serving")
# Unique logger name so log lines are attributable to this module specifically
logger = logging.getLogger("ml.serving.policy_server")
def _unwrap_logits(raw):
"""
Normalise whatever self.model() returns to a plain logits tensor.
PolicyNetwork.forward() returns a raw tensor, but the model may be
wrapped (DataParallel, torch.compile) or accidentally swapped for
PolicyValueNetwork whose forward_with_value() returns (logits, value).
A dict return would cause 'dict / float' TypeError on the temperature
division -- guard against all three cases here.
"""
if isinstance(raw, (tuple, list)):
# (logits, value) from PolicyValueNetwork or similar
return raw[0]
if isinstance(raw, dict):
# e.g. {"logits": tensor, "value": tensor}
for key in ("logits", "policy", "action_logits"):
if key in raw:
return raw[key]
# Fallback: grab the first tensor value in the dict
for v in raw.values():
if isinstance(v, torch.Tensor):
return v
raise TypeError(
f"model() returned a dict with no recognisable logits key: {list(raw.keys())}"
)
# Plain tensor -- the normal case
return raw
class PolicyInferenceService:
"""
Loads a trained PolicyNetwork and provides action predictions.
Thread-safe — uses torch.no_grad() for inference.
Thread-safe -- uses torch.no_grad() for inference.
Designed to be embedded in lab_api.py or run standalone.
"""
@@ -100,7 +130,7 @@ class PolicyInferenceService:
"""
if not TORCH_AVAILABLE:
self._load_error = "PyTorch not installed"
logger.error("PyTorch not installed — cannot serve policy")
logger.error("PyTorch not installed -- cannot serve policy")
return False
# 1. Load card embeddings
@@ -146,7 +176,7 @@ class PolicyInferenceService:
except Exception as e:
self._load_error = str(e)
logger.error("Failed to load checkpoint: %s", e)
logger.error("Failed to load checkpoint: %s", e, exc_info=True)
return False
def reload(self, checkpoint_path: str = None) -> bool:
@@ -178,7 +208,7 @@ class PolicyInferenceService:
except Exception as e:
self._load_error = str(e)
logger.error("Hot-reload failed: %s", e)
logger.error("Hot-reload failed: %s", e, exc_info=True)
return False
def predict(
@@ -224,7 +254,10 @@ class PolicyInferenceService:
# Run inference
with torch.no_grad():
logits = self.model(state_tensor)
raw_output = self.model(state_tensor)
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
# or accidental PolicyValueNetwork swap) before any arithmetic.
logits = _unwrap_logits(raw_output)
if greedy:
action_idx = logits.argmax(dim=-1).item()
@@ -254,7 +287,7 @@ class PolicyInferenceService:
except Exception as e:
elapsed_ms = (time.time() - t_start) * 1000
logger.error("Inference error: %s", e)
logger.error("Inference error: %s", e, exc_info=True)
return {
"error": str(e),
"inference_ms": round(elapsed_ms, 2),
@@ -286,7 +319,9 @@ class PolicyInferenceService:
# Batch inference
with torch.no_grad():
logits = self.model(state_tensor)
raw_output = self.model(state_tensor)
# Same guard as predict() -- unwrap before softmax/argmax.
logits = _unwrap_logits(raw_output)
probs = torch.softmax(logits, dim=-1).cpu().numpy()
if greedy:
@@ -312,7 +347,7 @@ class PolicyInferenceService:
)
except Exception as e:
logger.error("Batch inference error: %s", e)
logger.error("Batch inference error: %s", e, exc_info=True)
results = [{"error": str(e)}] * len(snapshots)
return results
@@ -331,9 +366,9 @@ class PolicyInferenceService:
}
# ══════════════════════════════════════════════════════════
# ==========================================================
# Standalone FastAPI Server
# ══════════════════════════════════════════════════════════
# ==========================================================
def create_standalone_app():
"""Create a standalone FastAPI app for the policy server."""
@@ -341,7 +376,7 @@ def create_standalone_app():
from pydantic import BaseModel
app = FastAPI(
title="Commander AI Lab — Policy Server",
title="Commander AI Lab -- Policy Server",
description="Serves macro-action predictions from a trained policy network",
version="1.0.0",
)
@@ -424,7 +459,7 @@ def main():
import uvicorn
parser = argparse.ArgumentParser(
description="Commander AI Lab — Policy Inference Server"
description="Commander AI Lab -- Policy Inference Server"
)
parser.add_argument("--port", type=int, default=8090)
parser.add_argument("--host", default="127.0.0.1")
+1 -1
View File
@@ -253,7 +253,7 @@ class DecisionExporter:
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
filepath = os.path.join(self.config.output_dir, filename)
with open(filepath, "w") as f:
with open(filepath, "w", encoding="utf-8") as f:
for record in self._records:
f.write(json.dumps(record) + "\n")
+2 -2
View File
@@ -759,7 +759,7 @@ class DistillationLoop:
"""Save generation history to JSON."""
path = os.path.join(self.config.history_dir, "generations.json")
history = [g.to_dict() for g in self.generations]
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info(" Generation history saved: %s", path)
@@ -793,7 +793,7 @@ class DistillationLoop:
def _save_summary(self, summary: Dict):
"""Save final summary to JSON."""
path = os.path.join(self.config.history_dir, "summary.json")
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, default=str)
logger.info(" Summary saved: %s", path)
+154 -11
View File
@@ -34,6 +34,7 @@ from ml.training.reward import (
compute_intermediate_reward,
compute_terminal_reward,
)
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
logger = logging.getLogger("ml.forge_episode_generator")
@@ -51,6 +52,8 @@ class ForgeTransition:
log_prob: float
value: float
done: bool
# Raw snapshot retained so the exporter can reconstruct game-state context
snapshot: Optional[Dict[str, Any]] = None
@dataclass
@@ -61,6 +64,8 @@ class ForgeEpisode:
winner: Optional[int] = None
total_turns: int = 0
deck_names: List[str] = field(default_factory=list)
agent_seat: int = 0
playstyle: str = "midrange"
@property
def length(self) -> int:
@@ -70,6 +75,10 @@ class ForgeEpisode:
def total_reward(self) -> float:
return sum(t.reward for t in self.transitions)
@property
def won(self) -> bool:
return self.winner == self.agent_seat
# ══════════════════════════════════════════════════════════
# JSONL Parser — reads Forge ML decision snapshots
@@ -82,7 +91,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
@@ -95,9 +104,44 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
return snapshots
def _make_minimal_state(snap: Dict[str, Any]) -> Dict[str, Any]:
"""Build a minimal prev/curr state dict from a Forge snapshot.
compute_intermediate_reward() expects a dict with a 'players' list
where each player has 'life_total', 'cards_in_hand',
'creatures_on_battlefield', etc. Forge snapshots carry a 'players'
array directly; we forward it as-is and fall back to scalar fields
when the array is absent.
"""
if "players" in snap:
return {"players": snap["players"]}
# Fallback: reconstruct a two-player view from flat snapshot fields
agent_seat = snap.get("active_seat", 0)
opp_seat = 1 - agent_seat
players = [None, None]
def _player(life_key: str, hand_key: str, creatures_key: str) -> Dict:
return {
"life_total": snap.get(life_key, 40),
"cards_in_hand": snap.get(hand_key, 0),
"creatures_on_battlefield": snap.get(creatures_key, 0),
"cards_in_graveyard": 0,
"mana_available": snap.get("mana_available", 0),
"commander_damage_taken": 0,
"total_power_on_board": 0,
}
players[agent_seat] = _player("agent_life", "agent_hand_size", "agent_creatures")
players[opp_seat] = _player("opp_life", "opp_hand_size", "opp_creatures")
return {"players": players}
def snapshots_to_episode(
snapshots: List[Dict[str, Any]],
reward_config: Optional[RewardConfig] = None,
agent_seat: int = 0,
playstyle: str = "midrange",
) -> ForgeEpisode:
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
@@ -111,6 +155,8 @@ def snapshots_to_episode(
episode = ForgeEpisode(
game_id=first.get("game_id", "unknown"),
deck_names=first.get("deck_names", []),
agent_seat=agent_seat,
playstyle=playstyle,
)
rc = reward_config or RewardConfig()
@@ -130,20 +176,26 @@ def snapshots_to_episode(
except (ValueError, KeyError):
action_idx = ACTION_TO_IDX[MacroAction.PASS]
# Reward
# Reward — use correct reward.py signatures
is_done = i == len(snapshots) - 1
if is_done and "outcome" in snap:
outcome = snap["outcome"]
reward = compute_terminal_reward(
won=snap["outcome"].get("won", False),
turns=snap.get("turn", 1),
life_remaining=snap["outcome"].get("life_remaining", 0),
won=outcome.get("won", False),
draw=outcome.get("draw", False),
timeout=outcome.get("timeout", False),
config=rc,
)
else:
# Build minimal prev/curr dicts for intermediate reward
prev_snap = snapshots[i - 1] if i > 0 else snap
prev_state = _make_minimal_state(prev_snap)
curr_state = _make_minimal_state(snap)
reward = compute_intermediate_reward(
life_delta=snap.get("life_delta", 0),
cards_drawn=snap.get("cards_drawn", 0),
creatures_played=snap.get("creatures_played", 0),
prev_state=prev_state,
curr_state=curr_state,
action_taken=action_label,
agent_seat=agent_seat,
config=rc,
)
@@ -154,6 +206,7 @@ def snapshots_to_episode(
log_prob=snap.get("log_prob", 0.0),
value=snap.get("value", 0.0),
done=is_done,
snapshot=snap,
))
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
@@ -163,6 +216,61 @@ def snapshots_to_episode(
return episode
# ══════════════════════════════════════════════════════════
# DecisionExporter adapter for Forge episodes
# ══════════════════════════════════════════════════════════
def _export_forge_episode(episode: ForgeEpisode, exporter: DecisionExporter) -> None:
"""Feed a completed ForgeEpisode into the DecisionExporter.
Translates each ForgeTransition back into the game_state_snapshot
format expected by DecisionExporter.record_step(), then finalises
the episode so the exporter can apply its win/length gates.
The raw snapshot stored on each ForgeTransition is used directly
when available; otherwise a minimal dict is synthesised from the
transition's action index and reward so the schema is always valid.
"""
exporter.begin_episode(
agent_seat=episode.agent_seat,
playstyle=episode.playstyle,
)
for t in episode.transitions:
if t.snapshot is not None:
game_state = _make_minimal_state(t.snapshot)
# Carry through turn/phase/active_seat if present
game_state["turn"] = t.snapshot.get("turn", 1)
game_state["phase"] = t.snapshot.get("phase", "main_1")
game_state["active_seat"] = t.snapshot.get("active_seat", episode.agent_seat)
else:
# Minimal fallback — exporter only needs players list for schema
game_state = {
"turn": 1,
"phase": "main_1",
"active_seat": episode.agent_seat,
"players": [
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
"cards_in_graveyard": 0, "mana_available": 0,
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
"cards_in_graveyard": 0, "mana_available": 0,
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
],
}
exporter.record_step(
game_state_snapshot=game_state,
action_idx=t.action,
reward=t.reward,
)
exporter.end_episode(
won=episode.won,
episode_return=episode.total_reward,
)
# ══════════════════════════════════════════════════════════
# Async Episode Queue (producer-consumer)
# ══════════════════════════════════════════════════════════
@@ -271,6 +379,7 @@ async def ppo_consumer(
queue: ForgeEpisodeQueue,
buffer_size: int = 64,
on_batch_ready=None,
exporter: Optional[DecisionExporter] = None,
) -> int:
"""Consumer coroutine: pulls episodes from the queue and batches
them for PPO training updates.
@@ -279,6 +388,11 @@ async def ppo_consumer(
queue: ForgeEpisodeQueue to consume from
buffer_size: number of episodes per training batch
on_batch_ready: callback(episodes) when a batch is full
exporter: Optional DecisionExporter — when provided, every
consumed episode is fed into the exporter so winning
Forge games accumulate in the distillation JSONL pool.
Call exporter.flush() after the pipeline completes to
write the JSONL file.
Returns total episodes consumed.
"""
@@ -292,6 +406,14 @@ async def ppo_consumer(
break
continue
# Feed into distillation exporter (win-gate applied inside exporter)
if exporter is not None:
try:
_export_forge_episode(episode, exporter)
except Exception as exc:
logger.warning("[CONSUMER] Exporter error for episode %s: %s",
episode.game_id, exc)
batch.append(episode)
total_consumed += 1
@@ -322,12 +444,24 @@ async def run_forge_pipeline(
max_queue: int = 256,
on_batch_ready=None,
reward_config: Optional[RewardConfig] = None,
) -> Dict[str, int]:
exporter: Optional[DecisionExporter] = None,
) -> Dict[str, Any]:
"""Run the full Forge producer → PPO consumer pipeline.
The Forge Java batch must be started separately (via forge_runner.py
or MultiThreadBatchRunner). This function handles the Python side:
reading JSONL output and feeding episodes into the PPO trainer.
If an exporter is provided, winning Forge episodes are recorded and
flushed to a PPO JSONL file at the end of the pipeline run. The
output path is returned in the stats dict under "exporter_file".
Example::
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
exporter = DecisionExporter(ExporterConfig(model_version="gen-1"))
stats = await run_forge_pipeline(num_games=128, exporter=exporter)
print(stats["exporter_file"]) # data/results/ml-decisions-ppo-<id>.jsonl
"""
queue = ForgeEpisodeQueue(max_size=max_queue)
@@ -336,16 +470,25 @@ async def run_forge_pipeline(
reward_config=reward_config)
)
consumer_task = asyncio.create_task(
ppo_consumer(queue, buffer_size, on_batch_ready)
ppo_consumer(queue, buffer_size, on_batch_ready, exporter=exporter)
)
produced = await producer_task
queue.close()
consumed = await consumer_task
stats = {
# Flush exporter to disk and capture output path
exporter_file = None
if exporter is not None:
exporter_file = exporter.flush()
if exporter_file:
logger.info("[PIPELINE] Distillation export: %s stats=%s",
exporter_file, exporter.stats)
stats: Dict[str, Any] = {
"produced": produced,
"consumed": consumed,
"exporter_file": exporter_file,
**queue.stats,
}
logger.info("[PIPELINE] Complete: %s", stats)
+2 -1
View File
@@ -223,7 +223,8 @@ def load_checkpoint(path: str, device: str = "cpu"):
num_layers=config.get("num_layers", TRAINING_CONFIG.num_layers),
dropout=config.get("dropout", TRAINING_CONFIG.dropout),
)
model.load_state_dict(checkpoint["model_state_dict"])
state = {k: v for k, v in checkpoint["model_state_dict"].items() if not k.startswith("value_head")}
model.load_state_dict(state, strict=False)
model.to(device)
return model, checkpoint
+339 -132
View File
@@ -9,22 +9,27 @@ Proximal Policy Optimization (Clip) with:
- Self-play episode collection between updates
- Periodic checkpointing and evaluation
- Win-rate tracking against opponent policies
- Forge-based episode source via --data-source forge|mixed
Reference: Schulman et al., "Proximal Policy Optimization Algorithms", 2017
Usage:
python -m ml.training.ppo_trainer --iterations 100 --episodes-per-iter 64
python -m ml.training.ppo_trainer --load-supervised ml/models/checkpoints/best_policy.pt
python -m ml.training.ppo_trainer --data-source forge --forge-results-dir results --iterations 50
python -m ml.training.ppo_trainer --data-source mixed --forge-weight 0.7 --iterations 100
"""
import argparse
import asyncio
import json
import logging
import os
import sys
import threading
import time
from pathlib import Path
from typing import Dict, Optional
from typing import Dict, List, Optional
import numpy as np
@@ -51,6 +56,114 @@ from ml.training.self_play import (
logger = logging.getLogger("ml.ppo")
# ══════════════════════════════════════════════════════════
# Forge data-source integration
# ══════════════════════════════════════════════════════════
def episodes_to_buffer(episodes, buffer: RolloutBuffer) -> int:
"""Drain a list of ForgeEpisodes into an existing RolloutBuffer.
Returns the number of transitions added.
"""
added = 0
for ep in episodes:
for t in ep.transitions:
if buffer.size >= buffer.buffer_size:
break
buffer.add(
state=t.state,
action=t.action,
reward=t.reward,
log_prob=t.log_prob,
value=t.value,
done=t.done,
)
added += 1
return added
class ForgeDataSource:
"""Runs the Forge producer pipeline in a background thread and
exposes a synchronous drain() method for the PPO training loop.
Usage::
source = ForgeDataSource(results_dir="results", batch_id="run1", num_games=64)
source.start()
# ... in training loop ...
episodes = source.drain()
source.stop()
"""
def __init__(
self,
results_dir: str = "results",
batch_id: str = "forge-batch",
num_games: int = 256,
reward_config: Optional[RewardConfig] = None,
queue_size: int = 256,
):
self.results_dir = results_dir
self.batch_id = batch_id
self.num_games = num_games
self.reward_config = reward_config or RewardConfig()
self.queue_size = queue_size
self._episodes: List = []
self._lock = threading.Lock()
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
def start(self):
"""Launch the background producer thread."""
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
logger.info("[ForgeDataSource] Producer started (results_dir=%s, batch_id=%s, num_games=%d)",
self.results_dir, self.batch_id, self.num_games)
def _run(self):
from ml.training.forge_episode_generator import ForgeEpisodeQueue, forge_producer
async def _produce():
queue = ForgeEpisodeQueue(max_size=self.queue_size)
produced = await forge_producer(
queue=queue,
results_dir=self.results_dir,
batch_id=self.batch_id,
num_games=self.num_games,
reward_config=self.reward_config,
)
queue.close()
# Drain remaining items
while not queue.is_closed:
ep = await queue.get(timeout=1.0)
if ep is not None:
with self._lock:
self._episodes.append(ep)
logger.info("[ForgeDataSource] Producer finished: %d episodes", produced)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(_produce())
finally:
loop.close()
def drain(self) -> List:
"""Return and clear all buffered episodes (thread-safe)."""
with self._lock:
eps = list(self._episodes)
self._episodes.clear()
return eps
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5.0)
# ══════════════════════════════════════════════════════════
# PPO Config
# ══════════════════════════════════════════════════════════
class PPOConfig:
"""PPO hyperparameters."""
@@ -91,6 +204,13 @@ class PPOConfig:
# Supervised preload
load_supervised: Optional[str] = None,
# Data source
data_source: str = "synthetic", # synthetic | forge | mixed
forge_results_dir: str = "results",
forge_batch_id: str = "forge-batch",
forge_num_games: int = 256,
forge_weight: float = 0.7, # fraction of buffer from Forge when mixed
):
self.iterations = iterations
self.episodes_per_iter = episodes_per_iter
@@ -114,10 +234,19 @@ class PPOConfig:
self.eval_every = eval_every
self.eval_episodes = eval_episodes
self.load_supervised = load_supervised
self.data_source = data_source
self.forge_results_dir = forge_results_dir
self.forge_batch_id = forge_batch_id
self.forge_num_games = forge_num_games
self.forge_weight = max(0.0, min(1.0, forge_weight))
# ══════════════════════════════════════════════════════════
# PPO Trainer
# ══════════════════════════════════════════════════════════
class PPOTrainer:
"""Proximal Policy Optimization trainer with self-play."""
"""Proximal Policy Optimization trainer with self-play and optional Forge data source."""
def __init__(self, config: PPOConfig):
if not TORCH_AVAILABLE:
@@ -126,28 +255,24 @@ class PPOTrainer:
self.config = config
self.device = self._detect_device()
# Create actor-critic model
from ml.training.policy_network import PolicyValueNetwork
self.model = PolicyValueNetwork(
input_dim=STATE_DIMS.total_state_dim,
hidden_dim=TRAINING_CONFIG.hidden_dim,
num_actions=NUM_ACTIONS,
num_layers=TRAINING_CONFIG.num_layers,
dropout=0.0, # No dropout during RL
dropout=0.0,
).to(self.device)
# Optionally load from supervised checkpoint
if config.load_supervised and os.path.exists(config.load_supervised):
self._load_supervised_weights(config.load_supervised)
# Optimizer
self.optimizer = torch.optim.Adam(
self.model.parameters(),
lr=config.learning_rate,
eps=1e-5,
)
# Learning rate scheduler
self.scheduler = None
if config.lr_schedule == "linear":
self.scheduler = torch.optim.lr_scheduler.LinearLR(
@@ -163,7 +288,6 @@ class PPOTrainer:
eta_min=config.min_lr,
)
# Rollout buffer
self.buffer = RolloutBuffer(
buffer_size=config.buffer_size,
state_dim=STATE_DIMS.total_state_dim,
@@ -171,16 +295,23 @@ class PPOTrainer:
gae_lambda=config.gae_lambda,
)
# Reward config
self.reward_config = RewardConfig(
gamma=config.gamma,
gae_lambda=config.gae_lambda,
)
# Opponent policy
self.opponent = self._create_opponent(config.opponent)
# Tracking
# Forge data source (started lazily in train())
self._forge_source: Optional[ForgeDataSource] = None
if config.data_source in ("forge", "mixed"):
self._forge_source = ForgeDataSource(
results_dir=config.forge_results_dir,
batch_id=config.forge_batch_id,
num_games=config.forge_num_games,
reward_config=self.reward_config,
)
self.iteration = 0
self.total_steps = 0
self.best_win_rate = 0.0
@@ -196,13 +327,9 @@ class PPOTrainer:
return "cpu"
def _load_supervised_weights(self, path: str):
"""Load weights from a supervised PolicyNetwork checkpoint into the
PolicyValueNetwork (ignoring the value head, which is new)."""
logger.info("Loading supervised weights from %s", path)
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
state_dict = checkpoint.get("model_state_dict", checkpoint)
# Filter out keys that don't match (e.g., value_head)
model_dict = self.model.state_dict()
compatible = {k: v for k, v in state_dict.items() if k in model_dict and v.shape == model_dict[k].shape}
model_dict.update(compatible)
@@ -216,14 +343,10 @@ class PPOTrainer:
elif opponent_type == "heuristic":
return HeuristicPolicy()
elif opponent_type == "self":
# Self-play: opponent uses a snapshot of the current policy
return self._create_self_play_opponent()
else:
return HeuristicPolicy()
return HeuristicPolicy()
def _create_self_play_opponent(self):
"""Create an opponent that uses a frozen copy of the current policy."""
class SelfPlayPolicy:
def __init__(self, model, device):
self.model = model
@@ -241,11 +364,99 @@ class PPOTrainer:
return SelfPlayPolicy(self.model, self.device)
def ppo_update(self) -> Dict:
"""Run PPO update epochs on the collected rollout buffer.
def _collect_forge_rollouts(self) -> Dict:
"""Drain buffered Forge episodes into the RolloutBuffer.
Returns dict of loss metrics.
Returns stats dict compatible with collect_rollouts() output.
"""
episodes = self._forge_source.drain()
if not episodes:
logger.warning("[Forge] No episodes available yet — waiting for JSONL output in %s",
self.config.forge_results_dir)
return {"steps": 0, "win_rate": 0.0, "avg_reward_per_ep": 0.0,
"wins": 0, "losses": 0, "draws": 0, "source": "forge"}
added = episodes_to_buffer(episodes, self.buffer)
wins = sum(1 for ep in episodes if ep.winner == 0)
win_rate = wins / max(len(episodes), 1)
avg_reward = sum(ep.total_reward for ep in episodes) / max(len(episodes), 1)
logger.info("[Forge] Drained %d episodes (%d transitions) into buffer — WR=%.0f%% AvgR=%.3f",
len(episodes), added, win_rate * 100, avg_reward)
return {
"steps": added,
"win_rate": win_rate,
"avg_reward_per_ep": avg_reward,
"wins": wins,
"losses": len(episodes) - wins,
"draws": 0,
"source": "forge",
}
def _collect_rollouts_for_iter(self) -> Dict:
"""Collect rollouts according to configured data_source.
- synthetic: pure self_play.collect_rollouts (original behaviour)
- forge: pure Forge episode drain
- mixed: Forge fills forge_weight fraction of buffer, synthetic fills remainder
"""
cfg = self.config
self.buffer.reset()
self.model.eval()
if cfg.data_source == "forge":
stats = self._collect_forge_rollouts()
elif cfg.data_source == "mixed":
forge_cap = int(cfg.buffer_size * cfg.forge_weight)
synth_cap = cfg.buffer_size - forge_cap
# Temporarily shrink buffer for Forge slice
original_size = self.buffer.buffer_size
self.buffer.buffer_size = forge_cap
forge_stats = self._collect_forge_rollouts()
self.buffer.buffer_size = original_size
synth_eps = max(1, int(cfg.episodes_per_iter * (1.0 - cfg.forge_weight)))
synth_stats = collect_rollouts(
agent_model=self.model,
opponent_policy=self.opponent,
buffer=self.buffer,
num_episodes=synth_eps,
playstyle=cfg.playstyle,
reward_config=self.reward_config,
)
stats = {
"steps": forge_stats["steps"] + synth_stats["steps"],
"win_rate": (
forge_stats["win_rate"] * cfg.forge_weight
+ synth_stats["win_rate"] * (1.0 - cfg.forge_weight)
),
"avg_reward_per_ep": (
forge_stats["avg_reward_per_ep"] * cfg.forge_weight
+ synth_stats["avg_reward_per_ep"] * (1.0 - cfg.forge_weight)
),
"wins": forge_stats["wins"] + synth_stats["wins"],
"losses": forge_stats["losses"] + synth_stats["losses"],
"draws": synth_stats.get("draws", 0),
"source": "mixed",
}
else: # synthetic (default — unchanged behaviour)
stats = collect_rollouts(
agent_model=self.model,
opponent_policy=self.opponent,
buffer=self.buffer,
num_episodes=cfg.episodes_per_iter,
playstyle=cfg.playstyle,
reward_config=self.reward_config,
)
stats["source"] = "synthetic"
self.model.train()
return stats
def ppo_update(self) -> Dict:
"""Run PPO update epochs on the collected rollout buffer."""
cfg = self.config
total_policy_loss = 0.0
total_value_loss = 0.0
@@ -258,56 +469,42 @@ class PPOTrainer:
for batch in self.buffer.get_batches(cfg.batch_size):
states, actions, old_log_probs, advantages, returns = batch
# To tensors
states_t = torch.from_numpy(states).to(self.device)
actions_t = torch.from_numpy(actions).to(self.device)
old_log_probs_t = torch.from_numpy(old_log_probs).to(self.device)
advantages_t = torch.from_numpy(advantages).to(self.device)
returns_t = torch.from_numpy(returns).to(self.device)
# Forward pass
logits, values = self.model.forward_with_value(states_t)
values = values.squeeze(-1)
# Action distribution
probs = F.softmax(logits, dim=-1)
dist = torch.distributions.Categorical(probs)
new_log_probs = dist.log_prob(actions_t)
entropy = dist.entropy().mean()
# --- Policy Loss (clipped surrogate) ---
ratio = torch.exp(new_log_probs - old_log_probs_t)
surr1 = ratio * advantages_t
surr2 = torch.clamp(ratio, 1.0 - cfg.clip_epsilon, 1.0 + cfg.clip_epsilon) * advantages_t
policy_loss = -torch.min(surr1, surr2).mean()
# --- Value Loss (clipped) ---
if cfg.value_clip > 0:
# Get old values from buffer for clipping
old_values_t = torch.from_numpy(
self.buffer.values[:self.buffer.size]
).to(self.device)
# Reindex to match batch
# Since we use shuffled indices in get_batches, we clip against returns
value_loss_unclipped = (values - returns_t) ** 2
value_loss = 0.5 * value_loss_unclipped.mean()
else:
value_loss = 0.5 * ((values - returns_t) ** 2).mean()
# --- Total Loss ---
loss = (
policy_loss
+ cfg.value_loss_coeff * value_loss
- cfg.entropy_coeff * entropy
)
# Optimize
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(self.model.parameters(), cfg.max_grad_norm)
self.optimizer.step()
# Track metrics
with torch.no_grad():
approx_kl = ((ratio - 1) - torch.log(ratio)).mean().item()
clip_frac = ((ratio - 1.0).abs() > cfg.clip_epsilon).float().mean().item()
@@ -330,12 +527,8 @@ class PPOTrainer:
}
def evaluate(self, num_episodes: int = 50) -> Dict:
"""Evaluate the current policy against opponents.
Returns win rates and metrics.
"""
"""Evaluate the current policy against heuristic and random opponents."""
self.model.eval()
results = {}
for opp_name, opp_policy in [
("heuristic", HeuristicPolicy()),
@@ -363,18 +556,15 @@ class PPOTrainer:
"avg_reward": stats["avg_reward_per_ep"],
"avg_steps": stats["avg_steps_per_ep"],
}
self.model.train()
return results
def save_checkpoint(self, path: str = None, metrics: dict = None):
"""Save PPO checkpoint."""
if path is None:
path = os.path.join(
self.config.checkpoint_dir,
f"ppo_iter_{self.iteration:04d}.pt",
)
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save({
"iteration": self.iteration,
@@ -388,6 +578,7 @@ class PPOTrainer:
"learning_rate": self.config.learning_rate,
"opponent": self.config.opponent,
"playstyle": self.config.playstyle,
"data_source": self.config.data_source,
},
"best_win_rate": self.best_win_rate,
"model_config": {
@@ -401,14 +592,7 @@ class PPOTrainer:
logger.info("Checkpoint saved: %s", path)
def train(self, progress_callback=None) -> Dict:
"""Run the full PPO training loop.
Args:
progress_callback: Optional callable(iteration, metrics) for progress reporting
Returns:
Training summary dict
"""
"""Run the full PPO training loop."""
cfg = self.config
logger.info("=" * 60)
logger.info("PPO Training — Commander AI Lab")
@@ -423,100 +607,96 @@ class PPOTrainer:
logger.info(" Opponent: %s", cfg.opponent)
logger.info(" Device: %s", self.device)
logger.info(" Supervised: %s", cfg.load_supervised or "None")
logger.info(" Data source: %s", cfg.data_source)
if cfg.data_source in ("forge", "mixed"):
logger.info(" Forge results: %s / %s", cfg.forge_results_dir, cfg.forge_batch_id)
logger.info(" Forge weight: %.0f%%", cfg.forge_weight * 100)
logger.info("")
# Start Forge producer background thread if needed
if self._forge_source:
self._forge_source.start()
t_start = time.time()
for iteration in range(1, cfg.iterations + 1):
self.iteration = iteration
t_iter = time.time()
try:
for iteration in range(1, cfg.iterations + 1):
self.iteration = iteration
t_iter = time.time()
# --- Collect rollouts ---
self.model.eval()
rollout_stats = collect_rollouts(
agent_model=self.model,
opponent_policy=self.opponent,
buffer=self.buffer,
num_episodes=cfg.episodes_per_iter,
playstyle=cfg.playstyle,
reward_config=self.reward_config,
)
self.total_steps += rollout_stats["steps"]
self.model.train()
rollout_stats = self._collect_rollouts_for_iter()
self.total_steps += rollout_stats["steps"]
# --- PPO Update ---
update_metrics = self.ppo_update()
update_metrics = self.ppo_update()
# --- LR Schedule ---
current_lr = self.optimizer.param_groups[0]["lr"]
if self.scheduler:
self.scheduler.step()
current_lr = self.optimizer.param_groups[0]["lr"]
if self.scheduler:
self.scheduler.step()
# --- Evaluate ---
eval_results = None
if iteration % cfg.eval_every == 0 or iteration == 1:
eval_results = self.evaluate(cfg.eval_episodes)
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
eval_results = None
if iteration % cfg.eval_every == 0 or iteration == 1:
eval_results = self.evaluate(cfg.eval_episodes)
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
if heuristic_wr > self.best_win_rate:
self.best_win_rate = heuristic_wr
best_path = os.path.join(cfg.checkpoint_dir, "best_ppo.pt")
self.save_checkpoint(best_path, {"eval": eval_results})
logger.info(" ★ New best win rate: %.1f%%", heuristic_wr * 100)
if heuristic_wr > self.best_win_rate:
self.best_win_rate = heuristic_wr
best_path = os.path.join(cfg.checkpoint_dir, "best_ppo.pt")
self.save_checkpoint(best_path, {"eval": eval_results})
logger.info(" ★ New best win rate: %.1f%%", heuristic_wr * 100)
if iteration % cfg.save_every == 0:
self.save_checkpoint()
# --- Save periodic checkpoint ---
if iteration % cfg.save_every == 0:
self.save_checkpoint()
iter_time = time.time() - t_iter
metrics = {
"iteration": iteration,
"steps": rollout_stats["steps"],
"total_steps": self.total_steps,
"win_rate": rollout_stats["win_rate"],
"avg_reward": rollout_stats["avg_reward_per_ep"],
"policy_loss": update_metrics["policy_loss"],
"value_loss": update_metrics["value_loss"],
"entropy": update_metrics["entropy"],
"approx_kl": update_metrics["approx_kl"],
"clip_fraction": update_metrics["clip_fraction"],
"lr": current_lr,
"iter_time_s": round(iter_time, 1),
"data_source": rollout_stats.get("source", cfg.data_source),
"eval": eval_results,
}
self.history.append(metrics)
# --- Log ---
iter_time = time.time() - t_iter
metrics = {
"iteration": iteration,
"steps": rollout_stats["steps"],
"total_steps": self.total_steps,
"win_rate": rollout_stats["win_rate"],
"avg_reward": rollout_stats["avg_reward_per_ep"],
"policy_loss": update_metrics["policy_loss"],
"value_loss": update_metrics["value_loss"],
"entropy": update_metrics["entropy"],
"approx_kl": update_metrics["approx_kl"],
"clip_fraction": update_metrics["clip_fraction"],
"lr": current_lr,
"iter_time_s": round(iter_time, 1),
"eval": eval_results,
}
self.history.append(metrics)
eval_str = ""
if eval_results:
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
r_wr = eval_results.get("random", {}).get("win_rate", 0)
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
# Pretty log
eval_str = ""
if eval_results:
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
r_wr = eval_results.get("random", {}).get("win_rate", 0)
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
src_tag = f"[{rollout_stats.get('source', '?')[:3]}]"
logger.info(
" [%3d/%d]%s wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
iteration, cfg.iterations, src_tag,
rollout_stats["win_rate"] * 100,
rollout_stats["avg_reward_per_ep"],
update_metrics["policy_loss"],
update_metrics["value_loss"],
update_metrics["entropy"],
update_metrics["approx_kl"],
eval_str,
iter_time,
)
logger.info(
" [%3d/%d] wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
iteration, cfg.iterations,
rollout_stats["win_rate"] * 100,
rollout_stats["avg_reward_per_ep"],
update_metrics["policy_loss"],
update_metrics["value_loss"],
update_metrics["entropy"],
update_metrics["approx_kl"],
eval_str,
iter_time,
)
if progress_callback:
progress_callback(iteration, metrics)
if progress_callback:
progress_callback(iteration, metrics)
finally:
if self._forge_source:
self._forge_source.stop()
# --- Final Save ---
total_time = time.time() - t_start
self.save_checkpoint()
# Save training history
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
with open(history_path, "w") as f:
with open(history_path, "w", encoding="utf-8") as f:
json.dump(self.history, f, indent=2, default=str)
summary = {
@@ -526,6 +706,7 @@ class PPOTrainer:
"final_win_rate": self.history[-1]["win_rate"] if self.history else 0,
"total_time_s": round(total_time, 1),
"device": self.device,
"data_source": cfg.data_source,
"checkpoint_dir": cfg.checkpoint_dir,
"history_path": history_path,
}
@@ -536,6 +717,7 @@ class PPOTrainer:
logger.info(" Total time: %.1f s", total_time)
logger.info(" Total steps: %d", self.total_steps)
logger.info(" Best win rate: %.1f%%", self.best_win_rate * 100)
logger.info(" Data source: %s", cfg.data_source)
logger.info(" History: %s", history_path)
logger.info("=" * 60)
@@ -578,12 +760,32 @@ def main():
parser.add_argument("--load-supervised", default=None,
help="Path to supervised checkpoint to initialize from")
# Data source
parser.add_argument(
"--data-source", default="synthetic",
choices=["synthetic", "forge", "mixed"],
help="Episode source: synthetic (self-play), forge (Forge JSONL), mixed (blend)",
)
parser.add_argument(
"--forge-results-dir", default="results",
help="Directory where Forge writes ml-decisions-forge-*.jsonl files",
)
parser.add_argument(
"--forge-batch-id", default="forge-batch",
help="Batch ID prefix for Forge JSONL files (matches BatchRunner --batchId)",
)
parser.add_argument(
"--forge-num-games", type=int, default=256,
help="Total Forge games to consume before stopping the producer",
)
parser.add_argument(
"--forge-weight", type=float, default=0.7,
help="Fraction of RolloutBuffer filled from Forge when --data-source mixed (0.0–1.0)",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = PPOConfig(
iterations=args.iterations,
@@ -605,6 +807,11 @@ def main():
eval_every=args.eval_every,
eval_episodes=args.eval_episodes,
load_supervised=args.load_supervised,
data_source=args.data_source,
forge_results_dir=args.forge_results_dir,
forge_batch_id=args.forge_batch_id,
forge_num_games=args.forge_num_games,
forge_weight=args.forge_weight,
)
trainer = PPOTrainer(config)
+10 -4
View File
@@ -322,7 +322,7 @@ class SupervisedTrainer:
}
serializable_history.append(entry)
with open(history_path, "w") as f:
with open(history_path, "w", encoding="utf-8") as f:
json.dump(serializable_history, f, indent=2)
logger.info(" History saved: %s", history_path)
@@ -547,7 +547,7 @@ def get_best_device() -> str:
return "cpu"
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
@@ -562,6 +562,12 @@ def get_best_device() -> str:
# ══════════════════════════════════════════════════════════
def main():
# Ensure logger output is visible on console
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
parser = argparse.ArgumentParser(
description="Commander AI Lab — Train Policy Network"
)
@@ -638,7 +644,7 @@ def main():
# Save evaluation results
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
with open(eval_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
return
@@ -696,7 +702,7 @@ def main():
print_evaluation(results)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
with open(eval_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
logger.info(" Eval results saved: %s", eval_path)
else:
+2 -1
View File
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
clock: int = 5000
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
@@ -170,6 +170,7 @@ class CoachGoalsRequest(BaseModel):
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
+159 -102
View File
@@ -1,102 +1,159 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
CFG = Config()
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
active_batches: dict[str, BatchState] = {}
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
# ── .env bootstrap ────────────────────────────────────────────
# Load .env automatically so plain `python script.py` and bare uvicorn
# both pick up FORGE_DIR, PRECON_DIR, etc. without a manual load_dotenv
# call at every entry point. override=False means real env vars always win.
try:
from dotenv import load_dotenv as _load_dotenv
_here = Path(__file__).resolve().parent
for _candidate in [_here, _here.parent, _here.parent.parent]:
_env_file = _candidate / ".env"
if _env_file.is_file():
_load_dotenv(dotenv_path=_env_file, override=False)
break
except ImportError:
pass # python-dotenv not installed; rely on real environment variables
# ─────────────────────────────────────────────────────────────
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
user_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from environment so uvicorn/ASGI mode works without CLI args.
# CLI args (lab_api.py main()) will override these if provided.
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
CFG.forge_jar = os.environ.get("FORGE_JAR", "")
CFG.forge_dir = os.environ.get("FORGE_DIR", "")
CFG.lab_jar = os.environ.get("LAB_JAR", "")
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
CFG.user_decks_dir = os.environ.get(
"USER_DECKS_DIR",
os.path.join(os.path.dirname(__file__), "..", "user-decks")
)
def validate_forge_config() -> None:
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
if not CFG.forge_dir:
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
return
base = Path(CFG.forge_dir)
a = base / "forge-gui" / "res" / "cardsfolder"
b = base / "res" / "cardsfolder"
if not a.is_dir() and not b.is_dir():
log.warning(
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
" %s\n %s\n"
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
CFG.forge_dir, a, b
)
else:
resolved = a if a.is_dir() else b
log.info("Forge cardsfolder confirmed at %s", resolved)
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
import asyncio as _asyncio
active_batches: dict[str, BatchState] = {}
active_batches_lock = _asyncio.Lock()
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced — Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive — attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control — defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo — ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange — flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
+19 -5
View File
@@ -50,6 +50,7 @@ DEFAULT_CONFIG = {
"train_lr": 0.001, # learning rate
"train_batch_size": 256, # training batch size
"train_patience": 15, # early stopping patience
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
# Java-only settings (ignored for DeepSeek):
"java_threads": 4,
"java_clock": 300,
@@ -279,14 +280,21 @@ def run_update_weights(cfg):
return False
def start_training(base_url, epochs, lr, batch_size, patience):
"""Trigger ML training and return immediately."""
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
"""Trigger ML training and return immediately.
ds_weight: upsampling multiplier for DeepSeek decision files
(ml-decisions-ds-*.jsonl). Matches the default in
DatasetConfig.source_weights added in the recent
dataset_builder.py update.
"""
resp = api_post(base_url, "/api/ml/train", {
"epochs": epochs,
"lr": lr,
"batchSize": batch_size,
"patience": patience,
"rebuildDataset": True,
"dsWeight": ds_weight,
})
return resp
@@ -416,6 +424,7 @@ def run_overnight(cfg):
log.info(f" Engine: {engine}")
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
log.info("")
while True:
@@ -510,7 +519,8 @@ def run_overnight(cfg):
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
f"DS Weight: {cfg['train_ds_weight']}x")
try:
start_training(
@@ -519,6 +529,7 @@ def run_overnight(cfg):
lr=cfg["train_lr"],
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
ds_weight=cfg["train_ds_weight"],
)
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
@@ -556,8 +567,8 @@ Examples:
# Just run sims, skip both weight update and training:
python overnight-run.py --no-train --no-update-weights
# Custom training settings:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
# Custom training settings with higher DS upsampling:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
""",
)
parser.add_argument("--url", default="http://localhost:8080",
@@ -590,6 +601,8 @@ Examples:
help="Training batch size (default: 256)")
parser.add_argument("--patience", type=int, default=15,
help="Early stopping patience (default: 15)")
parser.add_argument("--ds-weight", type=float, default=4.0,
help="DeepSeek source upsampling weight for training (default: 4.0)")
parser.add_argument("--threads", type=int, default=4,
help="Java engine threads (default: 4)")
parser.add_argument("--clock", type=int, default=300,
@@ -611,6 +624,7 @@ Examples:
cfg["train_lr"] = args.lr
cfg["train_batch_size"] = args.batch_size
cfg["train_patience"] = args.patience
cfg["train_ds_weight"] = args.ds_weight
cfg["java_threads"] = args.threads
cfg["java_clock"] = args.clock
+68
View File
@@ -0,0 +1,68 @@
import re
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
content = f.read()
# Fix 1: >= 2 to >= 3
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
content = content.replace(
'return self._run_forge_via_subprocess(service)',
'return self._parse_batch_json(service)'
)
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
new_method = '''
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding='utf-8') as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON -- falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
'''
content = content.replace(
' def _run_forge_via_subprocess(',
new_method + ' def _run_forge_via_subprocess('
)
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
f.write(content)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('Syntax OK - patch applied')
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
Run from anywhere — edit UI_JS_PATH to point at your file.
"""
import re
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
content = f.read()
original_size = len(content)
changes = []
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
old1 = re.search(
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
content, re.DOTALL
)
if old1:
content = content[:old1.start()] + \
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
content[old1.end():]
changes.append("renderManaPool -> stub")
else:
print("WARNING: renderManaPool pattern not matched — skipping")
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
old2 = re.search(
r'function showManaPool\(\) \{.*?\}',
content, re.DOTALL
)
if old2:
content = content[:old2.start()] + \
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
content[old2.end():]
changes.append("showManaPool -> stub")
else:
print("WARNING: showManaPool pattern not matched — skipping")
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
mana_row = (
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
)
if mana_row in content:
content = content.replace(mana_row, ";", 1)
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
else:
print("WARNING: Mana Auto-Clear row not found — skipping")
# ── Write ─────────────────────────────────────────────────────────────────────
if changes:
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
f.write(content)
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
for c in changes:
print(f" ✓ {c}")
else:
print("No changes applied.")
+104
View File
@@ -0,0 +1,104 @@
[metadata]
Name=20 Ways to Win
[Commander]
1 Go-Shintai of Life's Origin
[Main]
1 Alseid of Life's Bounty
1 Approach of the Second Sun
1 Arcane Signet
1 Augur of Autumn
1 Auriok Champion
1 Austere Command
1 Azorius Guildgate
1 Baldur's Gate
1 Biovisionary
1 Black Dragon Gate
1 Boros Guildgate
1 Bountiful Promenade
1 Brushland
1 Chromatic Lantern
1 Circuitous Route
1 Citadel Gate
1 Clever Concealment
1 Cliffgate
1 Command Tower
1 Crib Swap
1 Darksteel Citadel
1 Darksteel Mutation
1 Dimir Guildgate
1 Drown in Dreams
1 Dryad of the Ilysian Grove
1 Eerie Ultimatum
1 Exotic Orchard
1 Faeburrow Elder
1 Felidar Sovereign
1 Fertile Ground
1 Forbidden Orchard
1 Forest
1 Forgotten Ancient
1 Gateway Plaza
1 Ghostly Prison
1 Ghoulish Impetus
1 Golgari Guildgate
1 Gond Gate
1 Gruul Guildgate
1 Halo Fountain
1 Hangarback Walker
1 Happily Ever After
1 Heap Gate
1 Heliod's Intervention
1 Heliod, Sun-Crowned
1 Helix Pinnacle
1 Hellkite Tyrant
1 Homeward Path
1 Izzet Guildgate
1 Kynaios and Tiro of Meletis
1 Lightning Greaves
1 Lignify
1 Liliana's Contract
1 Mangara, the Diplomat
1 Manor Gate
1 Maskwood Nexus
1 Mayael's Aria
1 Maze's End
1 Mechanized Production
1 Monologue Tax
1 Morbid Opportunist
1 Mystic Remora
1 Orzhov Guildgate
1 Overgrown Farmland
1 Pariah
1 Path of Ancestry
1 Plains
1 Plaza of Harmony
1 Rakdos Guildgate
1 Razorverge Thicket
1 Reflecting Pool
1 Revel in Riches
1 Rite of Replication
1 Sanctum Weaver
1 Sea Gate
1 Seedborn Muse
1 Selesnya Guildgate
1 Shalai, Voice of Plenty
1 Shiny Impetus
1 Simic Ascendancy
1 Simic Guildgate
1 Sol Ring
1 Soul's Attendant
1 Sphinx's Revelation
1 Sun Titan
1 Sunscorch Regent
1 Supreme Verdict
1 Taurean Mauler
1 Test of Endurance
1 The Eternal Wanderer
1 The World Tree
1 Thornglint Bridge
1 Tireless Tracker
1 Trace of Abundance
1 Tragic Arrogance
1 Triskaidekaphile
1 Twenty-Toed Toad
1 Wooded Bastion
1 Yahenni, Undying Partisan
+92
View File
@@ -0,0 +1,92 @@
[metadata]
Name=Abzan Armor
[Commander]
1 Felothar the Steadfast
[Main]
1 Betor, Ancestor's Voice
1 Protector of the Wastes
1 Reunion of the House
1 Jaws of Defeat
1 Tip the Scales
1 Will of the Abzan
1 Arbor Adherent
1 Canopy Gargantuan
1 Rampart Architect
1 Tree of Redemption
1 Ikra Shidiqi, the Usurper
1 Baldin, Century Herdmaster
1 Expel the Interlopers
1 Indomitable Ancients
1 Rhox Faithmender
1 Shalai, Voice of Plenty
1 Wakestone Gargoyle
1 Wall of Reverence
1 Welcoming Vampire
1 Zetalpa, Primal Dawn
1 Arasta of the Endless Web
1 Assault Formation
1 Hornet Nest
1 Seedborn Muse
1 Sylvan Caryatid
1 Towering Titan
1 Anguished Unmaking
1 Dragonlord Dromoka
1 Faeburrow Elder
1 Shadrix Silverquill
1 Sidar Kondo of Jamuraa
1 Colfenor's Urn
1 Staff of Compleation
1 Weathered Sentinels
1 Canopy Vista
1 Exotic Orchard
1 Fortified Village
1 Isolated Chapel
1 Overgrown Farmland
1 Sungrass Prairie
1 Sunpetal Grove
1 Temple of Malady
1 Temple of Plenty
1 Temple of Silence
1 Twilight Mire
1 Woodland Cemetery
1 Arcane Signet
1 Sol Ring
1 Command Tower
1 Nyx-Fleece Ram
1 Slaughter the Strong
1 Swords to Plowshares
1 Wall of Omens
1 Wingmantle Chaplain
1 Behind the Scenes
1 Blight Pile
1 Feed the Swarm
1 Infernal Grasp
1 Wall of Limbs
1 Arboreal Grazer
1 Axebane Guardian
1 Carven Caryatid
1 Evolving Wilds
1 Jaddi Offshoot
1 Overgrown Battlement
1 Sandsteppe Citadel
1 Tower Defense
1 Wall of Blossoms
1 Wall of Roots
1 Despark
1 Indulging Patrician
1 Crashing Drawbridge
1 Orzhov Signet
1 Selesnya Signet
1 Swiftfoot Boots
1 Walking Bulwark
1 Access Tunnel
1 Bojuka Bog
1 Deceptive Landscape
1 Path of Ancestry
1 Radiant Grove
3 Plains
3 Plains
3 Swamp
2 Swamp
4 Forest
3 Forest
+90
View File
@@ -0,0 +1,90 @@
[metadata]
Name=Adaptive Enchantment
[Commander]
1 Estrid, the Masked
[Main]
1 Bruna, Light of Alabaster
1 Eidolon of Blossoms
1 Hydra Omnivore
1 Ajani's Chosen
1 Celestial Archon
1 Silent Sentinel
1 Boon Satyr
1 Herald of the Pantheon
1 Cold-Eyed Selkie
1 Daxos of Meletis
1 Elderwood Scion
1 Archetype of Imagination
1 Whitewater Naiads
1 Aura Gnarlid
1 Reclamation Sage
1 Yavimaya Enchantress
1 Martial Coup
1 Phyrexian Rebirth
1 Winds of Rath
1 Creeping Renaissance
1 Kruphix's Insight
1 Dismantling Blow
1 Bant Charm
1 Sol Ring
1 Sigil of the Empty Throne
1 Dictate of Kruphix
1 Bear Umbra
1 Enchantress's Presence
1 Epic Proportions
1 Ground Seal
1 Spawning Grounds
1 Finest Hour
1 Righteous Authority
1 Sage's Reverie
1 Soul Snare
1 Unquestioned Authority
1 Eel Umbra
1 Vow of Flight
1 Dawn's Reflection
1 Fertile Ground
1 Overgrowth
1 Snake Umbra
1 Vow of Wildness
1 Wild Growth
1 Unflinching Courage
3 Plains
3 Plains
3 Plains
2 Island
2 Island
2 Island
3 Forest
3 Forest
2 Forest
1 Azorius Chancery
1 Blossoming Sands
1 Command Tower
1 Evolving Wilds
1 Krosan Verge
1 Meandering River
1 Mosswort Bridge
1 Seaside Citadel
1 Selesnya Sanctuary
1 Simic Growth Chamber
1 Terramorphic Expanse
1 Thornwood Falls
1 Tranquil Cove
1 Tranquil Expanse
1 Woodland Stream
1 Kestia, the Cultivator
1 Tuvasa the Sunlit
1 Empyrial Storm
1 Heavenly Blademaster
1 Estrid's Invocation
1 Ever-Watching Threshold
1 Octopus Umbra
1 Genesis Storm
1 Myth Unbound
1 Nylea's Colossus
1 Ravenous Slime
1 Arixmethes, Slumbering Isle
1 Loyal Unicorn
1 Loyal Drake
1 Loyal Guardian
1 Forge of Heroes
+95
View File
@@ -0,0 +1,95 @@
[metadata]
Name=Ahoy Mateys
[Commander]
1 Admiral Brass, Unsinkable
[Main]
1 Don Andres, the Renegade
1 The Indomitable
1 Storm Fleet Negotiator
1 Francisco, Fowl Marauder
1 The Grim Captain's Locker
1 Skeleton Crew
1 Broadside Bombardiers
1 Gemcutter Buccaneer
1 Arm-Mounted Anchor
1 Amphin Mutineer
1 Bident of Thassa
1 Corsair Captain
1 Evacuation
1 Timestream Navigator
1 Warkite Marauder
1 Black Market Connections
1 Blood Money
1 Dire Fleet Ravager
1 Fathom Fleet Captain
1 Lethal Scheme
1 Angrath's Marauders
1 Blasphemous Act
1 Captain Lannery Storm
1 Captivating Crew
1 Chaos Warp
1 Coercive Recruiter
1 Dire Fleet Daredevil
1 Kari Zev, Skyship Raider
1 Port Razer
1 Shared Animosity
1 Admiral Beckett Brass
1 Hostage Taker
1 King Narfi's Betrayal
1 Prismari Command
1 Zara, Renegade Recruiter
1 Icon of Ancestry
1 Vanquisher's Banner
1 Choked Estuary
1 Desolate Lighthouse
1 Exotic Orchard
1 Foreboding Ruins
1 Frostboil Snarl
1 Geier Reach Sanitarium
1 Nephalia Drownyard
1 Smoldering Marsh
1 Sulfur Falls
1 Sunken Hollow
1 Azure Fleet Admiral
1 Enterprising Scallywag
1 Daring Saboteur
1 Departed Deckhand
1 Distant Melody
1 Ghost of Ramirez DePietro
1 Malcolm, Keen-Eyed Navigator
1 Merchant Raiders
1 Siren Stormtamer
1 Spectral Sailor
1 Windfall
1 Feed the Swarm
1 Pitiless Plunderer
1 Breeches, Brazen Plunderer
1 Faithless Looting
1 Rakdos Charm
1 Ramirez DePietro, Pillager
1 Arcane Signet
1 Commander's Sphere
1 Dimir Signet
1 Herald's Horn
1 Izzet Signet
1 Rakdos Signet
1 Sol Ring
1 Wayfarer's Bauble
1 Command Tower
1 Crumbling Necropolis
1 Evolving Wilds
1 Path of Ancestry
1 Port of Karfell
1 Rogue's Passage
1 Secluded Courtyard
1 Terramorphic Expanse
1 Thriving Bluff
1 Thriving Isle
1 Thriving Moor
1 Unclaimed Territory
3 Island
3 Island
2 Swamp
2 Swamp
3 Mountain
2 Mountain
@@ -0,0 +1,78 @@
[metadata]
Name=Angels: They're Just Like Us but Cooler and with Wings
[Commander]
1 Gisela, the Broken Blade
[Main]
1 Bruna, the Fading Light
1 Ajani, Strength of the Pride
1 Angel of Destiny
1 Angel of Finality
1 Angel of Serenity
1 Angel of the Ruins
1 Angel of Vitality
1 Angelic Accord
1 Angelic Field Marshal
1 Arcane Signet
1 Arch of Orazca
1 Archangel of Thune
1 Archangel of Tithes
1 Arden Angel
1 Austere Command
1 Bishop of Wings
1 Bonders' Enclave
1 Breathkeeper Seraph
1 Cleansing Nova
1 Commander's Plate
1 Cosmos Elixir
1 Court of Grace
1 Dawn of Hope
1 Dawnbreak Reclaimer
1 Dismantling Wave
1 Emeria Shepherd
1 Emeria, the Sky Ruin
1 Endless Atlas
1 Entreat the Angels
1 Everflowing Chalice
1 Giada, Font of Hope
1 Griffin Aerie
1 Heirloom Blade
1 Invoke the Divine
1 Karoo
1 Keeper of the Accord
1 Kindred Boon
1 Lightning Greaves
1 Marble Diamond
1 Mazemind Tome
1 Mind Stone
1 Myriad Landscape
1 Nykthos Paragon
1 Nykthos, Shrine to Nyx
1 Oketra's Monument
1 Path of Ancestry
1 Path to Exile
1 Pearl Medallion
8 Plains
8 Plains
7 Plains
7 Plains
1 Righteous Valkyrie
1 Search for Glory
1 Sephara, Sky's Blade
1 Seraph Sanctuary
1 Serra Ascendant
1 Shattered Angel
1 Sol Ring
1 Speaker of the Heavens
1 Starnheim Aspirant
1 Sunblast Angel
1 Swiftfoot Boots
1 Sword of the Animist
1 Swords to Plowshares
1 Thalia's Lancers
1 The Book of Exalted Deeds
1 Tome of Legends
1 Urza's Incubator
1 Valkyrie Harbinger
1 Vanquisher's Banner
1 War Room
1 Well of Lost Dreams
+90
View File
@@ -0,0 +1,90 @@
[metadata]
Name=Animated Army
[Commander]
1 Bello, Bard of the Brambles
[Main]
1 Wildsear, Scouring Maw
1 Domri, Anarch of Bolas
1 Etali, Primal Storm
1 Prosperous Bandit
1 Pyreswipe Hawk
1 Alchemist's Talent
1 Berserkers' Onslaught
1 Outpost Siege
1 Rain of Riches
1 Chaos Warp
1 Sunbird's Invocation
1 Gratuitous Violence
1 Warstorm Surge
1 Starstorm
1 Lotus Cobra
1 Evercoat Ursine
1 Brightcap Badger // Fungus Frolic
1 Trailtracker Scout
1 Thickest in the Thicket
1 Ghalta, Primal Hunger
1 Esika's Chariot
1 Unnatural Growth
1 Greater Good
1 Kodama of the East Tree
1 Grothama, All-Devouring
1 Rampaging Baloths
1 Bootleggers' Stash
1 Primeval Bounty
1 Gilded Lotus
1 Spine of Ish Sah
1 Temple of Abandon
1 Karplusan Forest
1 Exotic Orchard
1 Sheltered Thicket
1 Game Trail
1 Raging Ravine
1 Copperline Gorge
1 Mossfire Valley
1 Cinder Glade
1 Rootbound Crag
1 Mosswort Bridge
1 Blasphemous Act
1 Llanowar Loamspeaker
1 Tendershoot Dryad
1 Goreclaw, Terror of Qal Sisma
1 Path of Discovery
1 Decimate
1 Rolling Hamsphere
1 Teapot Slinger
1 Explore
1 Farseek
1 Cultivate
1 Grumgully, the Generous
1 Wandertale Mentor
1 Thought Vessel
1 Arcane Signet
1 Wooded Ridgeline
1 Big Score
1 Abrade
1 Rampant Growth
1 Sakura-Tribe Elder
1 Beast Within
1 Garruk's Packleader
1 Harmonize
1 Garruk's Uprising
1 Gruul Signet
1 Burnished Hart
1 Hedron Archive
1 Fellwar Stone
1 Thran Dynamo
1 Sol Ring
1 Mind Stone
1 Talisman of Impulse
1 Terramorphic Expanse
1 Path of Ancestry
1 Gruul Turf
1 Evolving Wilds
1 Forgotten Cave
1 Tranquil Thicket
1 Command Tower
1 Reliquary Tower
4 Mountain
4 Mountain
5 Forest
5 Forest
+95
View File
@@ -0,0 +1,95 @@
[metadata]
Name=Arcane Maelstrom
[Commander]
1 Kalamax, the Stormsire
[Main]
1 Jace, Architect of Thought
1 Xyris, the Writhing Storm
1 Haldan, Avid Arcanist
1 Pako, Arcane Retriever
1 Eon Frolicker
1 Nascent Metamorph
1 Glademuse
1 Ravenous Gigantotherium
1 Lunar Mystic
1 Niblis of Frost
1 Talrand, Sky Summoner
1 Charmbreaker Devils
1 Dualcaster Mage
1 Etali, Primal Storm
1 Goblin Dark-Dwellers
1 Djinn Illuminatus
1 Melek, Izzet Paragon
1 Rashmi, Eternities Crafter
1 Wort, the Raidmother
1 Solemn Simulacrum
1 Murmuring Mystic
1 Crackling Drake
1 Surreal Memoir
1 Decoy Gambit
1 Deflecting Swat
1 Curious Herd
1 Chaos Warp
1 Comet Storm
1 Commune with Lava
1 Starstorm
1 Strength of the Tajuru
1 Artifact Mutation
1 Prophetic Bolt
1 Clash of Titans
1 Channeled Force
1 Chemister's Insight
1 Frantic Search
1 Whiplash Trap
1 Crop Rotation
1 Evolution Charm
1 Harrow
1 Hunter's Insight
1 Hunting Pack
1 Natural Connection
1 Slice in Twain
1 Tribute to the Wild
1 Growth Spiral
1 Temur Charm
1 Lavabrink Floodgates
1 Twinning Staff
1 Bonder's Ornament
1 Arcane Signet
1 Commander's Sphere
1 Lightning Greaves
1 Sol Ring
1 Swarm Intelligence
1 Primal Empathy
1 Psychic Impetus
1 Shiny Impetus
1 Predatory Impetus
1 Wilderness Reclamation
1 Cinder Glade
1 Desolate Lighthouse
1 Exotic Orchard
1 Kessig Wolf Run
1 Mossfire Valley
1 Mosswort Bridge
1 Oran-Rief, the Vastwood
1 Scavenger Grounds
1 Yavimaya Coast
1 Command Tower
1 Frontier Bivouac
1 Gruul Turf
1 Halimar Depths
1 Izzet Boilerworks
1 Myriad Landscape
1 Rupture Spire
1 Simic Growth Chamber
1 Rugged Highlands
1 Swiftwater Cliffs
1 Thornwood Falls
3 Forest
3 Forest
2 Forest
2 Island
2 Island
1 Island
2 Mountain
2 Mountain
1 Mountain
+93
View File
@@ -0,0 +1,93 @@
[metadata]
Name=Arcane Wizardry
[Commander]
1 Inalla, Archmage Ritualist
[Main]
1 Kess, Dissident Mage
1 Mairsil, the Pretender
1 Galecaster Colossus
1 Magus of the Mind
1 Portal Mage
1 Vindictive Lich
1 Izzet Chemister
1 Taigam, Sidisi's Hand
1 Havengul Lich
1 Marchesa, the Black Rose
1 Vela the Night-Clad
1 Arcanis the Omnipotent
1 Azami, Lady of Scrolls
1 Body Double
1 Harbinger of the Tides
1 Serendib Sorcerer
1 Apprentice Necromancer
1 Magus of the Abyss
1 Puppeteer Clique
1 Etherium-Horn Sorcerer
1 Mercurial Chemister
1 Nin, the Pain Artist
1 Niv-Mizzet, the Firemind
1 Shadowmage Infiltrator
1 Bloodline Necromancer
1 Archaeomancer
1 Merchant of Secrets
1 Sea Gate Oracle
1 Corpse Augur
1 Izzet Chronarch
1 Nivix Guildmage
1 Kindred Dominance
1 Clone Legion
1 Spelltwine
1 Decree of Pain
1 Necromantic Selection
1 Comet Storm
1 Polymorphist's Jest
1 Chaos Warp
1 Memory Plunder
1 Silumgar's Command
1 Into the Roil
1 Opportunity
1 Reality Shift
1 Go for the Throat
1 Cauldron Dance
1 Crosis's Charm
1 Rakdos Charm
1 Terminate
1 Nevinyrral's Disk
1 Mirror of the Forebears
1 Commander's Sphere
1 Darksteel Ingot
1 Fellwar Stone
1 Sol Ring
1 Unstable Obelisk
1 Worn Powerstone
1 Shifting Shadow
1 Curse of Verbosity
1 Curse of Disturbance
1 Curse of Opulence
1 Exotic Orchard
1 Mystifying Maze
4 Island
3 Island
3 Island
2 Swamp
2 Swamp
2 Swamp
2 Mountain
1 Mountain
1 Mountain
1 Path of Ancestry
1 Command Tower
1 Crumbling Necropolis
1 Dimir Aqueduct
1 Dismal Backwater
1 Evolving Wilds
1 Grixis Panorama
1 Izzet Boilerworks
1 Jwar Isle Refuge
1 Rakdos Carnarium
1 Swiftwater Cliffs
1 Temple of the False God
1 Terramorphic Expanse
1 Vivid Crag
1 Vivid Creek
1 Vivid Marsh
+85
View File
@@ -0,0 +1,85 @@
[metadata]
Name=Arm for Battle
[Commander]
1 Wyleth, Soul of Steel
[Main]
1 Oreskos Explorer
1 Relic Seeker
1 Sram, Senior Edificer
1 Brass Squire
1 Danitha Capashen, Paragon
1 Dualcaster Mage
1 Flickerwisp
1 Ironclad Slayer
1 Kor Cartographer
1 Odric, Lunarch Marshal
1 Tiana, Ship's Caretaker
1 Condemn
1 Expedite
1 Swords to Plowshares
1 Abrade
1 Boros Charm
1 Comet Storm
1 Dawn Charm
1 Deflecting Palm
1 Disenchant
1 Fists of Flame
1 Temur Battle Rage
1 Valorous Stance
1 Generous Gift
1 Unbreakable Formation
1 Volcanic Fallout
1 Wear // Tear
1 White Sun's Zenith
1 Master Warcraft
1 Return to Dust
1 Wild Ricochet
1 Word of Seizing
1 Response // Resurgence
1 Jaya's Immolating Inferno
1 Martial Coup
1 Relentless Assault
1 Winds of Rath
1 Bonesplitter
1 Explorer's Scope
1 Sol Ring
1 Blackblade Reforged
1 Blazing Sunsteel
1 Boros Signet
1 Hero's Blade
1 Mask of Avacyn
1 Ring of Thune
1 Ring of Valkas
1 Swiftfoot Boots
1 Fireshrieker
1 Haunted Cloak
1 Loxodon Warhammer
1 Sunforger
1 Sword of Vengeance
1 Sigarda's Aid
1 Spirit Mantle
1 Timely Ward
1 Unquestioned Authority
1 Faith Unbroken
1 On Serra's Wings
1 Boros Garrison
1 Boros Guildgate
1 Command Tower
1 Encroaching Wastes
1 Evolving Wilds
1 Forgotten Cave
1 Memorial to War
5 Mountain
4 Mountain
1 Myriad Landscape
7 Plains
7 Plains
1 Rogue's Passage
1 Rupture Spire
1 Secluded Steppe
1 Slayers' Stronghold
1 Stone Quarry
1 Sunhome, Fortress of the Legion
1 Terramorphic Expanse
1 Transguild Promenade
1 Wind-Scarred Crag
+100
View File
@@ -0,0 +1,100 @@
[metadata]
Name=Aura of Courage
[Commander]
1 Galea, Kindler of Hope
[Main]
1 Angel of Finality
1 Puresteel Paladin
1 Realm-Cloaked Giant // Cast Off
1 Sram, Senior Edificer
1 Prognostic Sphinx
1 Cold-Eyed Selkie
1 Fleecemane Lion
1 Knight of Autumn
1 Riverwise Augur
1 Acidic Slime
1 Paradise Druid
1 Winds of Rath
1 Serum Visions
1 Nature's Lore
1 Heroic Intervention
1 Valorous Stance
1 Brainstorm
1 Bant Charm
1 Argentum Armor
1 Basilisk Collar
1 Masterwork of Ingenuity
1 Moonsilver Spear
1 Sword of the Animist
1 Behemoth Sledge
1 Arcane Signet
1 Colossus Hammer
1 Explorer's Scope
1 Sol Ring
1 Swiftfoot Boots
1 Viridian Longbow
1 Imprisoned in the Moon
1 Greater Good
1 Verdant Embrace
1 Angelic Gift
1 Gryff's Boon
1 Curse of Verbosity
1 Eel Umbra
1 Psychic Impetus
1 Abundant Growth
1 Fertile Ground
1 Kenrith's Transformation
1 Rancor
1 Utopia Sprawl
1 Wild Growth
1 Shielding Plax
1 Canopy Vista
1 Exotic Orchard
1 Fortified Village
1 Lumbering Falls
1 Port Town
1 Prairie Stream
1 Skycloud Expanse
1 Sungrass Prairie
1 Evolving Wilds
1 Azorius Chancery
1 Bant Panorama
1 Command Tower
1 Flood Plain
1 Grasslands
1 Halimar Depths
1 Mishra's Factory
1 Path of Ancestry
1 Seaside Citadel
1 Simic Growth Chamber
1 Terramorphic Expanse
1 Thriving Grove
1 Thriving Heath
1 Thriving Isle
1 Vitu-Ghazi, the City-Tree
1 Island
1 Island
1 Island
1 Island
2 Forest
2 Forest
2 Forest
2 Forest
1 Plains
1 Plains
1 Storvald, Frost Giant Jarl
1 Fey Steed
1 Holy Avenger
1 Mantle of the Ancients
1 Robe of Stars
1 Valiant Endeavor
1 Diviner's Portent
1 Netherese Puzzle-Ward
1 Winged Boots
1 Belt of Giant Strength
1 Song of Inspiration
1 Catti-brie of Mithral Hall
1 Ride the Avalanche
1 Clay Golem
1 Ebony Fly
1 Sword of Hours
+96
View File
@@ -0,0 +1,96 @@
[metadata]
Name=Bedecked Brokers
[Commander]
1 Perrie, the Pulverizer
[Main]
1 Kros, Defense Contractor
1 Ajani Unyielding
1 Angelic Sleuth
1 Aven Courier
1 Aven Mimeomancer
1 Avenging Huntbonder
1 Bribe Taker
1 Crystalline Giant
1 Devoted Druid
1 Denry Klin, Editor in Chief
1 Evolution Sage
1 Fathom Mage
1 Forgotten Ancient
1 Grateful Apparition
1 Incubation Druid
1 Jenara, Asura of War
1 Luminarch Aspirant
1 Park Heights Maverick
1 Rishkar, Peema Renegade
1 Roalesk, Apex Hybrid
1 Scavenging Ooze
1 Shield Broker
1 Skyboon Evangelist
1 Skyship Plunderer
1 Slippery Bogbonder
1 Steelbane Hydra
1 Thrummingbird
1 Vorel of the Hull Clade
1 Wall of Roots
1 Wickerbough Elder
1 Wingspan Mentor
1 Declaration in Stone
1 Damning Verdict
1 Planar Outburst
1 Tezzeret's Gambit
1 Rishkar's Expertise
1 Urban Evolution
1 Bant Charm
1 Brokers Charm
1 Brokers Confluence
1 Contractual Safeguard
1 Exotic Pets
1 Generous Gift
1 Storm of Forms
1 Family's Favor
1 Hoofprints of the Stag
1 Primal Empathy
1 Resourceful Defense
1 Together Forever
1 Agent's Toolkit
1 Arcane Signet
1 Commander's Sphere
1 Everflowing Chalice
1 Fellwar Stone
1 Gavel of the Righteous
1 Midnight Clock
1 Oblivion Stone
1 Oracle's Vault
1 Power Conduit
1 Sol Ring
1 Swiftfoot Boots
1 Ash Barrens
1 Bant Panorama
1 Brokers Hideout
1 Canopy Vista
1 Command Tower
1 Exotic Orchard
1 Flooded Grove
1 Fortified Village
1 Gavony Township
1 Karn's Bastion
1 Littjara Mirrorlake
1 Llanowar Reborn
1 Myriad Landscape
1 Nesting Grounds
1 Path of Ancestry
1 Port Town
1 Prairie Stream
1 Seaside Citadel
1 Skycloud Expanse
1 Sungrass Prairie
1 Temple of Mystery
1 Vivid Creek
1 Vivid Grove
1 Vivid Meadow
3 Forest
2 Forest
2 Island
2 Island
3 Plains
2 Plains
+92
View File
@@ -0,0 +1,92 @@
[metadata]
Name=Blame Game
[Commander]
1 Nelly Borca, Impulsive Accuser
[Main]
1 Feather, Radiant Arbiter
1 Immortal Obligation
1 Otherworldly Escort
1 Redemption Arc
1 Trouble in Pairs
1 Havoc Eater
1 Hot Pursuit
1 Mob Verdict
1 Prisoner's Dilemma
1 Take the Bait
1 Ransom Note
1 Angel of the Ruins
1 Comeuppance
1 Darien, King of Kjeldor
1 Duelist's Heritage
1 Elspeth, Sun's Champion
1 Keeper of the Accord
1 Loran of the Third Path
1 Promise of Loyalty
1 Selfless Squire
1 Sevinne's Reclamation
1 Smuggler's Share
1 Stalking Leonin
1 Sun Titan
1 Windborn Muse
1 Winds of Rath
1 Agitator Ant
1 Brash Taunter
1 Disrupt Decorum
1 Etali, Primal Storm
1 Fiendish Duo
1 Frontier Warmonger
1 Kazuul, Tyrant of the Cliffs
1 Spectacular Showdown
1 Vengeful Ancestor
1 Anya, Merciless Angel
1 Boros Reckoner
1 Deflecting Palm
1 Gisela, Blade of Goldnight
1 Ancient Stone Idol
1 Solemn Simulacrum
1 Steel Hellkite
1 Tome of Legends
1 Castle Ardenvale
1 Exotic Orchard
1 Furycalm Snarl
1 Kher Keep
1 Labyrinth of Skophos
1 Needle Spires
1 Scavenger Grounds
1 Slayers' Stronghold
1 Temple of Triumph
1 Throne of the High City
1 War Room
1 Ghostly Prison
1 Gideon's Sacrifice
1 Martial Impetus
1 Orzhov Advokist
1 Seal of Cleansing
1 Soul Snare
1 Vow of Duty
1 Wall of Omens
1 Curse of Opulence
1 Rite of the Raging Storm
1 Shiny Impetus
1 Vow of Lightning
1 Arcane Signet
1 Bloodthirsty Blade
1 Fellwar Stone
1 Mind Stone
1 Sol Ring
1 Talisman of Conviction
1 Thought Vessel
1 Access Tunnel
1 Ash Barrens
1 Boros Garrison
1 Command Tower
1 Escape Tunnel
1 Myriad Landscape
1 Reliquary Tower
1 Rogue's Passage
1 Sunhome, Fortress of the Legion
1 Temple of the False God
5 Plains
4 Plains
4 Mountain
3 Mountain
+89
View File
@@ -0,0 +1,89 @@
[metadata]
Name=Blight Curse
[Commander]
1 Auntie Ool, Cursewretch
[Main]
1 The Reaper, King No More
1 Wickersmith's Tools
1 Sinister Gnarlbark
1 Eventide's Shadow
1 Aberrant Return
1 Dread Tiller
1 Grave Venerations
1 Oft-Nabbed Goat
1 Village Pillagers
1 Puca's Covenant
1 Ferrafor, Young Yew
1 Tree of Perdition
1 Vernal Fen
1 Festering Thicket
1 Necroskitter
1 Ignoble Hierarch
1 Nesting Grounds
1 Massacre Girl, Known Killer
1 Grave Titan
1 Vraska, Betrayal's Sting
1 The Scorpion God
1 Liliana, Death Wielder
1 Chimil, the Inner Sun
1 Carnifex Demon
1 Puppeteer Clique
1 Dusk Urchins
1 Black Sun's Zenith
1 Painful Truths
1 Persist
1 Midnight Banshee
1 Chain Reaction
1 Archfiend of Ifnir
1 Channeler Initiate
1 Hapatra, Vizier of Poisons
1 Glissa Sunslayer
1 Everlasting Torment
1 Assassin's Trophy
1 Grim Poppet
1 Canyon Slough
1 Sheltered Thicket
1 Exotic Orchard
1 Smoldering Marsh
1 Cinder Glade
1 Dragonskull Summit
1 Rootbound Crag
1 Woodland Cemetery
1 Lasting Tarfire
1 Burning Curiosity
1 Hoarder's Greed
1 Blowfly Infestation
1 Fire Covenant
1 Sol Ring
1 Arcane Signet
1 Command Tower
1 Cathartic Reunion
1 Skinrender
1 Soul Snuffers
1 Incremental Blight
1 Infernal Grasp
1 Evolution Sage
1 Devoted Druid
1 Flourishing Defenses
1 Harmonize
1 Putrefy
1 Binding the Old Gods
1 Kulrath Knight
1 Contagion Clasp
1 Savage Lands
1 Ifnir Deadlands
1 Night's Whisper
1 Wickerbough Elder
1 Terminate
1 Commander's Sphere
1 Cathartic Pyre
1 Evolving Wilds
1 Terramorphic Expanse
1 Path of Ancestry
1 Riveteers Overlook
1 Rakdos Carnarium
1 Gruul Turf
1 Golgari Rot Farm
8 Swamp
4 Mountain
6 Forest
+87
View File
@@ -0,0 +1,87 @@
[metadata]
Name=Blood Rites
[Commander]
1 Clavileño, First of the Blessed
[Main]
1 Carmen, Cruel Skymarcher
1 Charismatic Conqueror
1 Elenda's Hierophant
1 March of the Canonized
1 Redemption Choir
1 Dusk Legion Sergeant
1 Master of Dark Rites
1 Promise of Aclazotz // Foul Rebirth
1 Order of Sacred Dusk
1 Austere Command
1 Kindred Boon
1 Mavren Fein, Dusk Apostle
1 Radiant Destiny
1 Welcoming Vampire
1 Bloodghast
1 Bloodtracker
1 Butcher of Malakir
1 Champion of Dusk
1 Cordial Vampire
1 Crossway Troublemakers
1 Damn
1 Drana, Liberator of Malakir
1 Exquisite Blood
1 Glass-Cast Heart
1 New Blood
1 Nighthawk Scavenger
1 Olivia's Wrath
1 Pact of the Serpent
1 Patron of the Vein
1 Sanctum Seeker
1 Timothar, Baron of Bats
1 Twilight Prophet
1 Yahenni, Undying Partisan
1 Elenda, the Dusk Rose
1 Sorin, Lord of Innistrad
1 Utter End
1 Vona, Butcher of Magan
1 Blade of the Bloodchief
1 Isolated Chapel
1 Shineshadow Snarl
1 Temple of Silence
1 Vault of the Archangel
1 Voldaren Estate
1 Windbrisk Heights
1 Martyr of Dusk
1 Return to Dust
1 Swords to Plowshares
1 Blood Artist
1 Bloodline Necromancer
1 Dusk Legion Zealot
1 Falkenrath Noble
1 Indulgent Aristocrat
1 Oathsworn Vampire
1 Village Rites
1 Viscera Seer
1 Bartolomé del Presidio
1 Cruel Celebrant
1 Etchings of the Chosen
1 Legion Lieutenant
1 Arcane Signet
1 Commander's Sphere
1 Heirloom Blade
1 Mind Stone
1 Orzhov Signet
1 Sol Ring
1 Swiftfoot Boots
1 Talisman of Hierarchy
1 Wayfarer's Bauble
1 Bojuka Bog
1 Command Tower
1 Myriad Landscape
1 Orzhov Basilica
1 Path of Ancestry
1 Rogue's Passage
1 Secluded Courtyard
1 Tainted Field
1 Temple of the False God
1 Unclaimed Territory
4 Plains
4 Plains
7 Swamp
6 Swamp
+95
View File
@@ -0,0 +1,95 @@
[metadata]
Name=Breed Lethality
[Commander]
1 Atraxa, Praetors' Voice
[Main]
1 Thrummingbird
1 Festercreep
1 Scavenging Ooze
1 Abzan Falconer
1 Orzhov Advokist
1 Tuskguard Captain
1 Necroplasm
1 Champion of Lambholt
1 Reyhan, Last of the Abzan
1 Vorel of the Hull Clade
1 Crystalline Crawler
1 Custodi Soulbinders
1 Forgotten Ancient
1 Bane of the Living
1 Ishai, Ojutai Dragonspeaker
1 Corpsejack Menace
1 Fathom Mage
1 Master Biomancer
1 Elite Scaleguard
1 Reveillark
1 Deepglow Skate
1 Kalonian Hydra
1 Ikra Shidiqi, the Usurper
1 Vulturous Zombie
1 Juniper Order Ranger
1 Ghave, Guru of Spores
1 Enduring Scalelord
1 Manifold Insights
1 Languish
1 Tezzeret's Gambit
1 Migratory Route
1 Merciless Eviction
1 Spitting Image
1 Sublime Exhalation
1 Duneblast
1 Treasure Cruise
1 Disdainful Stroke
1 Solidarity of Heroes
1 Grip of Phyresis
1 Inspiring Call
1 Mortify
1 Putrefy
1 Ancient Excavation
1 Mirrorweave
1 Sylvan Reclamation
1 Sol Ring
1 Fellwar Stone
1 Golgari Signet
1 Orzhov Signet
1 Simic Signet
1 Commander's Sphere
1 Darksteel Ingot
1 Cauldron of Souls
1 Astral Cornucopia
1 Hardened Scales
1 Brave the Sands
1 Duelist's Heritage
1 Bred for the Hunt
1 Citadel Siege
1 Cathars' Crusade
1 Arcane Sanctum
1 Ash Barrens
1 Azorius Chancery
1 Command Tower
1 Darkwater Catacombs
1 Dreadship Reef
1 Evolving Wilds
1 Exotic Orchard
1 Golgari Rot Farm
1 Murmuring Bosk
1 Opal Palace
1 Opulent Palace
1 Sandsteppe Citadel
1 Seaside Citadel
1 Sungrass Prairie
1 Temple of the False God
1 Terramorphic Expanse
1 Underground River
2 Plains
2 Plains
1 Plains
2 Island
1 Island
1 Island
2 Swamp
2 Swamp
1 Swamp
3 Forest
2 Forest
2 Forest
+104
View File
@@ -0,0 +1,104 @@
[metadata]
Name=Breed Lethality
[Commander]
1 Atraxa, Praetors' Voice
[Main]
1 Thrummingbird
1 Festercreep
1 Scavenging Ooze
1 Abzan Falconer
1 Orzhov Advokist
1 Tuskguard Captain
1 Necroplasm
1 Champion of Lambholt
1 Reyhan, Last of the Abzan
1 Vorel of the Hull Clade
1 Crystalline Crawler
1 Custodi Soulbinders
1 Forgotten Ancient
1 Bane of the Living
1 Ishai, Ojutai Dragonspeaker
1 Corpsejack Menace
1 Fathom Mage
1 Master Biomancer
1 Elite Scaleguard
1 Reveillark
1 Deepglow Skate
1 Kalonian Hydra
1 Ikra Shidiqi, the Usurper
1 Vulturous Zombie
1 Juniper Order Ranger
1 Ghave, Guru of Spores
1 Enduring Scalelord
1 Manifold Insights
1 Languish
1 Tezzeret's Gambit
1 Migratory Route
1 Merciless Eviction
1 Spitting Image
1 Sublime Exhalation
1 Duneblast
1 Treasure Cruise
1 Disdainful Stroke
1 Solidarity of Heroes
1 Grip of Phyresis
1 Inspiring Call
1 Mortify
1 Putrefy
1 Ancient Excavation
1 Mirrorweave
1 Sylvan Reclamation
1 Sol Ring
1 Fellwar Stone
1 Golgari Signet
1 Orzhov Signet
1 Simic Signet
1 Commander's Sphere
1 Darksteel Ingot
1 Cauldron of Souls
1 Astral Cornucopia
1 Hardened Scales
1 Brave the Sands
1 Duelist's Heritage
1 Bred for the Hunt
1 Citadel Siege
1 Cathars' Crusade
1 Arcane Sanctum
1 Ash Barrens
1 Azorius Chancery
1 Command Tower
1 Darkwater Catacombs
1 Dreadship Reef
1 Evolving Wilds
1 Exotic Orchard
1 Golgari Rot Farm
1 Murmuring Bosk
1 Opal Palace
1 Opulent Palace
1 Sandsteppe Citadel
1 Seaside Citadel
1 Sungrass Prairie
1 Temple of the False God
1 Terramorphic Expanse
1 Underground River
1 Plains
1 Plains
1 Plains
1 Plains
1 Plains
1 Island
1 Island
1 Island
1 Island
1 Swamp
1 Swamp
1 Swamp
1 Swamp
1 Swamp
1 Forest
1 Forest
1 Forest
1 Forest
1 Forest
1 Forest
1 Forest
+78
View File
@@ -0,0 +1,78 @@
[metadata]
Name=Buckle Up
[Commander]
1 Kotori, Pilot Prodigy
[Main]
1 Shorikai, Genesis Engine
1 Jace, Architect of Thought
1 Drumbellower
1 Ironsoul Enforcer
1 Cyberdrive Awakener
1 Kappa Cannoneer
1 Katsumasa, the Animator
1 Research Thief
1 Aeronaut Admiral
1 Cataclysmic Gearhulk
1 Indomitable Archangel
1 Myrsmith
1 Sram, Senior Edificer
1 Teshar, Ancestor's Apostle
1 Emry, Lurker of the Loch
1 Etherium Sculptor
1 Master of Etherium
1 Riddlesmith
1 Sai, Master Thopterist
1 Vedalken Engineer
1 Whirler Rogue
1 Arcanist's Owl
1 Hanna, Ship's Navigator
1 Raff Capashen, Ship's Mage
1 Foundry Inspector
1 Gold Myr
1 Shimmer Myr
1 Silver Myr
1 Solemn Simulacrum
1 Organic Extinction
1 Universal Surveillance
1 Thoughtcast
1 Dance of the Manse
1 Release to Memory
1 Access Denied
1 Armed and Armored
1 Crush Contraband
1 Dispatch
1 Generous Gift
1 Swords to Plowshares
1 Reality Shift
1 Aerial Surveyor
1 Imposter Mech
1 Imperial Recovery Unit
1 Mobilizer Mech
1 Prodigy's Prototype
1 Surgehacker Mech
1 Parhelion II
1 Arcane Signet
1 Azorius Signet
1 Colossal Plow
1 Cultivator's Caravan
1 Fellwar Stone
1 Mirage Mirror
1 Peacewalker Colossus
1 Raiders' Karve
1 Skysovereign, Consul Flagship
1 Smuggler's Copter
1 Sol Ring
1 Weatherlight
1 Swift Reconfiguration
1 Thopter Spy Network
1 Command Tower
1 Exotic Orchard
1 Port Town
1 Prairie Stream
1 Skycloud Expanse
1 Spire of Industry
1 Temple of Enlightenment
8 Plains
7 Plains
8 Island
7 Island
+79
View File
@@ -0,0 +1,79 @@
[metadata]
Name=Built From Scratch
[Commander]
1 Daretti, Scrap Savant
[Main]
1 Goblin Welder
1 Epochrasite
1 Myr Retriever
1 Myr Sire
1 Bottle Gnomes
1 Cathodion
1 Junk Diver
1 Palladium Myr
1 Pilgrim's Eye
1 Tuktuk the Explorer
1 Dualcaster Mage
1 Feldon of the Third Path
1 Solemn Simulacrum
1 Flametongue Kavu
1 Beetleback Chief
1 Ingot Chewer
1 Steel Hellkite
1 Wurmcoil Engine
1 Spitebellows
1 Hoard-Smelter Dragon
1 Warmonger Hellkite
1 Myr Battlesphere
1 Pentavus
1 Tyrant's Familiar
1 Bosh, Iron Golem
1 Bogardan Hellkite
1 Faithless Looting
1 Whipflare
1 Scrap Mastery
1 Incite Rebellion
1 Blasphemous Act
1 Impact Resonance
1 Chaos Warp
1 Volcanic Offering
1 Word of Seizing
1 Magmaquake
1 Starstorm
1 Everflowing Chalice
1 Panic Spellbomb
1 Sol Ring
1 Wayfarer's Bauble
1 Fire Diamond
1 Ichor Wellspring
1 Liquimetal Coating
1 Mind Stone
1 Mycosynth Wellspring
1 Ruby Medallion
1 Swiftfoot Boots
1 Commander's Sphere
1 Jalum Tome
1 Pristine Talisman
1 Unstable Obelisk
1 Trading Post
1 Caged Sun
1 Dreamstone Hedron
1 Loreseeker's Stone
1 Spine of Ish Sah
1 Darksteel Citadel
1 Great Furnace
1 Bitter Feud
1 Arcane Lighthouse
1 Buried Ruin
1 Dormant Volcano
1 Flamekin Village
1 Forgotten Cave
1 Ghost Quarter
1 Phyrexia's Core
1 Reliquary Tower
1 Smoldering Crater
1 Temple of the False God
8 Mountain
7 Mountain
7 Mountain
7 Mountain
+81
View File
@@ -0,0 +1,81 @@
[metadata]
Name=Built From Scratch
[Commander]
1 Daretti, Scrap Savant
[Main]
1 Goblin Welder
1 Epochrasite
1 Myr Retriever
1 Myr Sire
1 Bottle Gnomes
1 Cathodion
1 Junk Diver
1 Palladium Myr
1 Pilgrim's Eye
1 Tuktuk the Explorer
1 Dualcaster Mage
1 Feldon of the Third Path
1 Solemn Simulacrum
1 Flametongue Kavu
1 Beetleback Chief
1 Ingot Chewer
1 Steel Hellkite
1 Wurmcoil Engine
1 Spitebellows
1 Hoard-Smelter Dragon
1 Warmonger Hellkite
1 Myr Battlesphere
1 Pentavus
1 Tyrant's Familiar
1 Bosh, Iron Golem
1 Bogardan Hellkite
1 Faithless Looting
1 Whipflare
1 Scrap Mastery
1 Incite Rebellion
1 Blasphemous Act
1 Impact Resonance
1 Chaos Warp
1 Volcanic Offering
1 Word of Seizing
1 Magmaquake
1 Starstorm
1 Everflowing Chalice
1 Panic Spellbomb
1 Sol Ring
1 Wayfarer's Bauble
1 Fire Diamond
1 Ichor Wellspring
1 Liquimetal Coating
1 Mind Stone
1 Mycosynth Wellspring
1 Ruby Medallion
1 Swiftfoot Boots
1 Commander's Sphere
1 Jalum Tome
1 Pristine Talisman
1 Unstable Obelisk
1 Trading Post
1 Caged Sun
1 Dreamstone Hedron
1 Loreseeker's Stone
1 Spine of Ish Sah
1 Darksteel Citadel
1 Great Furnace
1 Bitter Feud
1 Arcane Lighthouse
1 Buried Ruin
1 Dormant Volcano
1 Flamekin Village
1 Forgotten Cave
1 Ghost Quarter
1 Phyrexia's Core
1 Reliquary Tower
1 Smoldering Crater
1 Temple of the False God
5 Mountain
5 Mountain
5 Mountain
5 Mountain
5 Mountain
4 Mountain
+94
View File
@@ -0,0 +1,94 @@
[metadata]
Name=Cabaretti Cacophony
[Commander]
1 Kitt Kanto, Mayhem Diva
[Main]
1 Phabine, Boss's Confidant
1 Leafkin Druid
1 Sakura-Tribe Elder
1 Agitator Ant
1 Bess, Soul Nourisher
1 Champion of Lambholt
1 Magus of the Wheel
1 Orzhov Advokist
1 Rumor Gatherer
1 Scute Swarm
1 Selvala, Explorer Returned
1 Wood Elves
1 Zurzoth, Chaos Rider
1 Arasta of the Endless Web
1 Life of the Party
1 Master of Ceremonies
1 Rose Room Treasurer
1 Sizzling Soloist
1 Boss's Chauffeur
1 Gahiji, Honored One
1 Kazuul, Tyrant of the Cliffs
1 Thunderfoot Baloth
1 Cabaretti Confluence
1 Camaraderie
1 Cultivate
1 Fell the Mighty
1 Harmonize
1 Indulge // Excess
1 Martial Coup
1 Seize the Spotlight
1 Shamanic Revelation
1 Sylvan Offering
1 Vivien's Stampede
1 Artifact Mutation
1 Aura Mutation
1 Beast Within
1 Boros Charm
1 Cabaretti Charm
1 Crash the Party
1 Call the Coppercoats
1 Grand Crescendo
1 March of the Multitudes
1 Path to Exile
1 Sol Ring
1 Arcane Signet
1 Bloodthirsty Blade
1 Fellwar Stone
1 Idol of Oblivion
1 Commander's Sphere
1 Scepter of Celebration
1 False Floor
1 Intangible Virtue
1 Awakening Zone
1 Beastmaster Ascension
1 Duelist's Heritage
1 Killer Service
1 Prosperous Partnership
1 Felidar Retreat
1 Outpost Siege
1 Assemble the Legion
1 Sandwurm Convergence
1 Ash Barrens
1 Cabaretti Courtyard
1 Canopy Vista
1 Castle Ardenvale
1 Castle Embereth
1 Cinder Glade
1 Command Tower
1 Exotic Orchard
1 Fortified Village
1 Game Trail
1 Jungle Shrine
1 Mossfire Valley
1 Myriad Landscape
1 Naya Panorama
1 Path of Ancestry
1 Rugged Prairie
1 Sungrass Prairie
1 Temple of Triumph
1 Thriving Bluff
1 Thriving Grove
1 Thriving Heath
1 Windbrisk Heights
2 Plains
2 Plains
2 Mountain
2 Mountain
4 Forest
4 Forest
+88
View File
@@ -0,0 +1,88 @@
[metadata]
Name=Call the Spirits
[Commander]
1 Daxos the Returned
[Main]
1 Oreskos Explorer
1 Karlov of the Ghost Council
1 Underworld Coinsmith
1 Burnished Hart
1 Bastion Protector
1 Dawnglare Invoker
1 Ghostblade Eidolon
1 Kor Sanctifiers
1 Monk Idealist
1 Mesa Enchantress
1 Nighthowler
1 Corpse Augur
1 Fate Unraveler
1 Ajani's Chosen
1 Doomwake Giant
1 Dreadbringer Lampads
1 Celestial Ancient
1 Celestial Archon
1 Herald of the Host
1 Banshee of the Dread Choir
1 Thief of Blood
1 Treasury Thrull
1 Sandstone Oracle
1 Silent Sentinel
1 Teysa, Envoy of Ghosts
1 Ancient Craving
1 Gild
1 Dawn to Dusk
1 Righteous Confluence
1 Open the Vaults
1 Deadly Tempest
1 Death Grasp
1 Sol Ring
1 Wayfarer's Bauble
1 Lightning Greaves
1 Orzhov Signet
1 Thought Vessel
1 Crystal Chimes
1 Orzhov Cluestone
1 Phyrexian Reclamation
1 Seal of Cleansing
1 Grave Peril
1 Banishing Light
1 Cage of Hands
1 Karmic Justice
1 Vow of Duty
1 Fallen Ideal
1 Seal of Doom
1 Vow of Malice
1 Aura of Silence
1 Grasp of Fate
1 Shielded by Faith
1 Phyrexian Arena
1 Underworld Connections
1 Daxos's Torment
1 Marshal's Anthem
1 Dictate of Heliod
1 Sigil of the Empty Throne
1 Black Market
1 Necromancer's Covenant
1 Barren Moor
1 Command Tower
1 Evolving Wilds
1 Ghost Quarter
1 New Benalia
1 Orzhov Basilica
1 Orzhov Guildgate
1 Rogue's Passage
1 Scoured Barrens
1 Secluded Steppe
1 Tainted Field
1 Temple of the False God
1 Terramorphic Expanse
1 Vivid Marsh
1 Vivid Meadow
3 Plains
3 Plains
3 Plains
2 Plains
4 Swamp
3 Swamp
3 Swamp
3 Swamp
+83
View File
@@ -0,0 +1,83 @@
[metadata]
Name=Chaos Incarnate
[Commander]
1 Kardur, Doomscourge
[Main]
1 Archfiend of Depravity
1 Bloodgift Demon
1 Deadly Tempest
1 Dredge the Mire
1 Ob Nixilis Reignited
1 Profane Command
1 Rakshasa Debaser
1 Reign of the Pit
1 Sangromancer
1 Scythe Specter
1 Sepulchral Primordial
1 Soul Shatter
1 Titan Hunter
1 Blasphemous Act
1 Brash Taunter
1 Chaos Warp
1 Combustible Gearhulk
1 Dictate of the Twin Gods
1 Fiery Confluence
1 Geode Rager
1 Kazuul, Tyrant of the Cliffs
1 Magmatic Force
1 Sunbird's Invocation
1 Tectonic Giant
1 Wild Ricochet
1 Wildfire Devils
1 Kaervek the Merciless
1 Spiteful Visions
1 Stormfist Crusader
1 Theater of Horrors
1 Coveted Jewel
1 Solemn Simulacrum
1 Foreboding Ruins
1 Smoldering Marsh
1 Stensia Bloodhall
1 Temple of Malice
1 Ambition's Cost
1 Feed the Swarm
1 Indulgent Tormentor
1 Read the Bones
1 Sign in Blood
1 Syphon Mind
1 Vampire Nighthawk
1 Abrade
1 Explosion of Riches
1 Guttersnipe
1 Hate Mirage
1 Mana Geyser
1 Thermo-Alchemist
1 Breath of Malfegor
1 Rakdos Charm
1 Terminate
1 Unlicensed Disintegration
1 Arcane Signet
1 Burnished Hart
1 Commander's Sphere
1 Lightning Greaves
1 Nihil Spellbomb
1 Rakdos Signet
1 Sol Ring
1 Talisman of Indulgence
1 Wayfarer's Bauble
1 Worn Powerstone
1 Akoum Refuge
1 Bloodfell Caves
1 Cinder Barrens
1 Command Tower
1 Molten Slagheap
1 Myriad Landscape
1 Urborg Volcano
4 Swamp
4 Swamp
4 Swamp
3 Swamp
4 Mountain
4 Mountain
3 Mountain
3 Mountain
Loaded 100 of 364 files, more files were not shown because too many files have changed in this diff. Show more