Compare commits

...
Author SHA1 Message Date
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
17 changed files with 65422 additions and 138 deletions

No files matched your search

+25492
View File
File diff suppressed because it is too large. Load diff
+634
View File
@@ -0,0 +1,634 @@
<!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>
</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>
<!-- CENTER EMBLEM -->
<div class="center-emblem">
<svg viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
</svg>
</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>
<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 class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
<!-- 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="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 class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
<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 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 class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</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 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 class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div><!-- /.battlefield -->
<!-- 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>
<!-- MANA POOL DISPLAY (floating, bottom center) -->
<div id="mana-pool-display" style="display:none;">
<span class="mana-pool-label">MANA:</span>
<span class="mana-empty">Empty</span>
</div>
<!-- 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>
</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="clearAllManaPools()">Clear All Mana</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
==================================================== -->
<script src="precons.js?v=20260403_0300"></script>
<script src="engine.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)
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+25612
View File
File diff suppressed because it is too large. Load diff
+643
View File
@@ -0,0 +1,643 @@
<!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>
</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>
<!-- CENTER EMBLEM -->
<div class="center-emblem">
<svg viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
</svg>
</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>
<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 class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
<!-- 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="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 class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
<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 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 class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</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 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 class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div><!-- /.battlefield -->
<!-- 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>
</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
==================================================== -->
<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="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');
}
});
}
});
</script>
</body>
</html>
+270
View File
@@ -0,0 +1,270 @@
// 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();
}
/**
* 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 = 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';
}
}
+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}
];
+3439
View File
File diff suppressed because it is too large. Load diff
+2325
View File
File diff suppressed because it is too large. Load diff
+154 -131
View File
@@ -1,131 +1,154 @@
"""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
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 = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
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)
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
# ══════════════════════════════════════════════════════════════
# 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 \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 = ""
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", "")
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},
}
+4
View File
@@ -0,0 +1,4 @@
unzip mtg-commander-lan.zip
cd mtg-commander-lan
npm install
node server.js
+6
View File
@@ -0,0 +1,6 @@
{
"name": "MTG Commander (HTML)",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Generate lan-client/precons.js from precon-decks/precon-index.json
* Usage: node scripts/generate-precons-js.js
*/
const fs = require('fs');
const path = require('path');
const indexPath = path.join(__dirname, '..', 'precon-decks', 'precon-index.json');
const outputPath = path.join(__dirname, '..', 'lan-client', 'precons.js');
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
let js = '// Auto-generated from precon-decks/precon-index.json\n';
js += '// Run: node scripts/generate-precons-js.js to regenerate\n';
js += 'var PRECON_DATA = [\n';
index.forEach(function(p, i) {
const entry = {
id: (p.fileName || p.name || '').replace('.dck', ''),
name: p.name || '',
commander: p.commander || (p.commanders && p.commanders[0]) || '',
set: p.set || '',
fileName: p.fileName || '',
colors: p.colors || [],
year: p.year || 0,
};
js += ' ' + JSON.stringify(entry);
js += (i < index.length - 1) ? ',\n' : '\n';
});
js += '];\n';
fs.writeFileSync(outputPath, js);
console.log(`Generated ${outputPath} with ${index.length} precon entries`);
+26 -7
View File
@@ -13,6 +13,16 @@ from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
# Repo root = two levels up from this file (services/deck_service.py)
_REPO_ROOT = Path(__file__).parent.parent
# Repo-relative deck dirs always searched as fallbacks (zero-config support)
_BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
]
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
@@ -197,7 +207,7 @@ def _save_profile_to_dck(profile: dict) -> Path:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
save_dir = str(_REPO_ROOT / "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
@@ -209,15 +219,19 @@ def _find_dck_file(deck_name: str) -> Optional[Path]:
"""Search known deck directories for a .dck file matching deck_name.
Resolution order:
1. precon_dir (precon-decks/)
2. forge_decks_dir (Forge user decks)
1. CFG.precon_dir (explicit CLI/env config)
2. CFG.forge_decks_dir (Forge user decks)
3. FORGE_DIR/res/decks/commander (Forge bundled commander decks)
4. Repo-relative fallbacks: precon-decks/, sample-decks/, imported-decks/
(always searched so precon decks work with zero configuration)
Within each directory tries:
a. Exact stem match (Elven_Empire.dck)
b. Case-insensitive fuzzy match stripping underscores/spaces
"""
search_dirs = []
search_dirs: list[Path] = []
# CFG-configured paths (set via CLI args)
if CFG.precon_dir and os.path.isdir(CFG.precon_dir):
search_dirs.append(Path(CFG.precon_dir))
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
@@ -227,6 +241,11 @@ def _find_dck_file(deck_name: str) -> Optional[Path]:
if forge_cmdr.is_dir():
search_dirs.append(forge_cmdr)
# Repo-relative fallbacks -- always active so precon decks need no config
for builtin_dir in _BUILTIN_DECK_DIRS:
if builtin_dir.is_dir() and builtin_dir not in search_dirs:
search_dirs.append(builtin_dir)
target_norm = deck_name.lower().replace("_", "").replace(" ", "")
for d in search_dirs:
@@ -303,9 +322,9 @@ def _load_deck_cards_by_name(deck_name: str) -> list:
Resolution order:
1. SQLite DB (decks built/imported via the Deck Builder UI)
2. .dck file fallback -- searches precon_dir, forge_decks_dir, and
FORGE_DIR/res/decks/commander so that precon decks work without
needing to be manually imported into the DB first.
2. .dck file fallback -- searches precon_dir, forge_decks_dir,
FORGE_DIR/res/decks/commander, and repo-relative precon-decks/
sample-decks/ so precon decks work without any configuration.
"""
# -- 1. DB lookup --
conn = _get_db_conn()
+3028
View File
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 Battlefield</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--felt: #2a2a2a;
--felt-light: #333333;
--felt-dark: #1e1e1e;
--felt-line: rgba(255,255,255,0.08);
--felt-line-strong: rgba(255,255,255,0.15);
--p1: #c0392b; --p1-soft: rgba(192,57,43,0.15); --p1-glow: rgba(192,57,43,0.35); --p1-text: #e74c3c;
--p2: #2980b9; --p2-soft: rgba(41,128,185,0.15); --p2-glow: rgba(41,128,185,0.35); --p2-text: #3498db;
--p3: #27ae60; --p3-soft: rgba(39,174,96,0.15); --p3-glow: rgba(39,174,96,0.35); --p3-text: #2ecc71;
--p4: #d4930d; --p4-soft: rgba(212,147,13,0.15); --p4-glow: rgba(212,147,13,0.35); --p4-text: #f1c40f;
--card-w: 63px;
--card-h: 88px;
--zone-radius: 8px;
}
html, body {
width: 100%; height: 100%;
overflow: hidden;
font-family: 'Inter', sans-serif;
background: var(--felt-dark);
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.table-surface {
position: relative;
width: 100vw;
height: 100vh;
background: radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 50%, var(--felt-dark) 100%);
overflow: hidden;
}
.table-surface::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E");
background-size: 200px 200px;
pointer-events: none;
z-index: 1;
opacity: 0.7;
}
.table-surface::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.45) 100%);
pointer-events: none;
z-index: 2;
}
.battlefield {
position: absolute;
inset: 10px;
z-index: 5;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0;
}
.divider-h, .divider-v {
position: absolute;
z-index: 10;
pointer-events: none;
}
.divider-h {
top: 50%; left: 10px; right: 10px;
height: 2px;
transform: translateY(-50%);
background: var(--felt-line-strong);
box-shadow: 0 0 12px rgba(255,255,255,0.04);
}
.divider-v {
left: 50%; top: 10px; bottom: 10px;
width: 2px;
transform: translateX(-50%);
background: var(--felt-line-strong);
box-shadow: 0 0 12px rgba(255,255,255,0.04);
}
.center-emblem {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
z-index: 15;
width: 76px; height: 76px;
border-radius: 50%;
background: radial-gradient(circle, rgba(55,55,55,0.95), rgba(25,25,25,0.98));
border: 2px solid rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 30px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.3);
}
.center-emblem svg { width: 40px; height: 40px; }
.player-area {
position: relative;
display: flex;
flex-direction: column;
padding: 8px;
overflow: hidden;
}
.player-area.p1 { grid-area: 1 / 1 / 2 / 2; }
.player-area.p2 { grid-area: 1 / 2 / 2 / 3; }
.player-area.p3 { grid-area: 2 / 1 / 3 / 2; }
.player-area.p4 { grid-area: 2 / 2 / 3 / 3; }
.p1, .p2 { justify-content: flex-start; }
.p3, .p4 { justify-content: flex-end; }
.player-label {
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 4px;
position: absolute;
z-index: 20;
white-space: nowrap;
}
.p1 .player-label { top: 8px; left: 8px; color: var(--p1-text); background: var(--p1-soft); }
.p2 .player-label { top: 8px; right: 8px; color: var(--p2-text); background: var(--p2-soft); }
.p3 .player-label { bottom: 8px; left: 8px; color: var(--p3-text); background: var(--p3-soft); }
.p4 .player-label { bottom: 8px; right: 8px; color: var(--p4-text); background: var(--p4-soft); }
.life-counter {
position: absolute;
z-index: 20;
display: flex;
align-items: center;
gap: 5px;
font-family: 'Cinzel', serif;
font-size: 20px;
font-weight: 700;
}
.life-counter button {
width: 22px; height: 22px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.45);
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-family: 'Inter', sans-serif;
line-height: 1;
}
.life-counter button:hover {
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.8);
}
.p1 .life-counter { top: 8px; right: 8px; color: var(--p1-text); }
.p2 .life-counter { top: 8px; left: 8px; color: var(--p2-text); }
.p3 .life-counter { bottom: 8px; right: 8px; color: var(--p3-text); }
.p4 .life-counter { bottom: 8px; left: 8px; color: var(--p4-text); }
.zone-row {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.p1 .zone-row, .p3 .zone-row { flex-direction: row; }
.p2 .zone-row, .p4 .zone-row { flex-direction: row-reverse; }
.commander-row {
display: flex;
flex-shrink: 0;
margin: 4px 0;
}
.p1 .commander-row, .p3 .commander-row { justify-content: flex-start; }
.p2 .commander-row, .p4 .commander-row { justify-content: flex-end; }
.battle-zone {
flex: 1;
position: relative;
border-radius: var(--zone-radius);
min-height: 50px;
}
.battle-zone-inner {
position: absolute;
inset: 4px;
border-radius: var(--zone-radius);
border: 1px dashed;
opacity: 0.12;
pointer-events: none;
}
.p1 .battle-zone-inner { border-color: var(--p1); }
.p2 .battle-zone-inner { border-color: var(--p2); }
.p3 .battle-zone-inner { border-color: var(--p3); }
.p4 .battle-zone-inner { border-color: var(--p4); }
.battle-zone-label {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 4px;
text-transform: uppercase;
opacity: 0.06;
pointer-events: none;
white-space: nowrap;
color: #fff;
}
.zone {
position: relative;
border-radius: var(--zone-radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
transition: all 0.25s ease;
border: 1px solid rgba(255,255,255,0.05);
background: rgba(0,0,0,0.18);
overflow: hidden;
}
.zone:hover {
border-color: rgba(255,255,255,0.12);
background: rgba(0,0,0,0.28);
}
.zone-icon {
font-size: 16px;
opacity: 0.55;
margin-bottom: 2px;
pointer-events: none;
}
.zone-name {
font-size: 7.5px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: rgba(255,255,255,0.3);
pointer-events: none;
}
.zone.library, .zone.exile, .zone.graveyard {
width: var(--card-w);
height: var(--card-h);
}
.zone.hand {
flex: 1;
height: var(--card-h);
min-width: 80px;
flex-direction: row;
gap: 4px;
}
.zone.hand .zone-icon { margin-bottom: 0; margin-right: 3px; }
.zone.commander {
width: calc(var(--card-w) + 14px);
height: calc(var(--card-h) + 6px);
border-width: 2px;
}
.p1 .zone { border-color: rgba(192,57,43,0.12); }
.p1 .zone:hover { border-color: rgba(192,57,43,0.3); box-shadow: 0 0 12px var(--p1-soft); }
.p1 .zone.commander { border-color: rgba(192,57,43,0.25); background: var(--p1-soft); }
.p1 .zone-icon { color: var(--p1-text); }
.p2 .zone { border-color: rgba(41,128,185,0.12); }
.p2 .zone:hover { border-color: rgba(41,128,185,0.3); box-shadow: 0 0 12px var(--p2-soft); }
.p2 .zone.commander { border-color: rgba(41,128,185,0.25); background: var(--p2-soft); }
.p2 .zone-icon { color: var(--p2-text); }
.p3 .zone { border-color: rgba(39,174,96,0.12); }
.p3 .zone:hover { border-color: rgba(39,174,96,0.3); box-shadow: 0 0 12px var(--p3-soft); }
.p3 .zone.commander { border-color: rgba(39,174,96,0.25); background: var(--p3-soft); }
.p3 .zone-icon { color: var(--p3-text); }
.p4 .zone { border-color: rgba(212,147,13,0.12); }
.p4 .zone:hover { border-color: rgba(212,147,13,0.3); box-shadow: 0 0 12px var(--p4-soft); }
.p4 .zone.commander { border-color: rgba(212,147,13,0.25); background: var(--p4-soft); }
.p4 .zone-icon { color: var(--p4-text); }
.zone[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.88);
color: #ccc;
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
z-index: 50;
pointer-events: none;
}
.card-count {
position: absolute;
top: 3px;
right: 4px;
font-size: 8px;
font-weight: 600;
color: rgba(255,255,255,0.25);
background: rgba(0,0,0,0.3);
padding: 1px 4px;
border-radius: 8px;
}
.title-bar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 30;
text-align: center;
padding: 5px;
pointer-events: none;
}
.title-bar h1 {
font-family: 'Cinzel', serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 5px;
text-transform: uppercase;
color: rgba(255,255,255,0.15);
}
.corner-accent {
position: absolute;
width: 24px; height: 24px;
z-index: 6;
pointer-events: none;
opacity: 0.12;
}
.corner-accent.tl { top: 6px; left: 6px; border-top: 2px solid var(--p1); border-left: 2px solid var(--p1); }
.corner-accent.tr { top: 6px; right: 6px; border-top: 2px solid var(--p2); border-right: 2px solid var(--p2); }
.corner-accent.bl { bottom: 6px; left: 6px; border-bottom: 2px solid var(--p3); border-left: 2px solid var(--p3); }
.corner-accent.br { bottom: 6px; right: 6px; border-bottom: 2px solid var(--p4); border-right: 2px solid var(--p4); }
@media (max-width: 768px) {
:root { --card-w: 42px; --card-h: 58px; }
.player-label { font-size: 7px; letter-spacing: 1px; padding: 2px 6px; }
.life-counter { font-size: 15px; }
.life-counter button { width: 18px; height: 18px; font-size: 11px; }
.zone-name { font-size: 6px; }
.zone-icon { font-size: 12px; }
.center-emblem { width: 46px; height: 46px; }
.center-emblem svg { width: 26px; height: 26px; }
.battle-zone-label { font-size: 8px; letter-spacing: 2px; }
.zone-row { gap: 3px; }
}
</style>
</head>
<body>
<div class="table-surface">
<div class="title-bar"><h1>Commander Battlefield</h1></div>
<div class="corner-accent tl"></div>
<div class="corner-accent tr"></div>
<div class="corner-accent bl"></div>
<div class="corner-accent br"></div>
<div class="divider-h"></div>
<div class="divider-v"></div>
<div class="center-emblem">
<svg viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
</svg>
</div>
<div class="battlefield">
<!-- PLAYER 1: Top-Left (Crimson) -->
<div class="player-area p1">
<span class="player-label">Player 1</span>
<div class="life-counter">
<button onclick="changeLife(1,-1)" aria-label="Decrease life">−</button>
<span id="life1">40</span>
<button onclick="changeLife(1,1)" aria-label="Increase life">+</button>
</div>
<div class="zone-row">
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
</div>
<div class="commander-row">
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
</div>
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
</div>
<!-- PLAYER 2: Top-Right (Sapphire) -->
<div class="player-area p2">
<span class="player-label">Player 2</span>
<div class="life-counter">
<button onclick="changeLife(2,-1)" aria-label="Decrease life">−</button>
<span id="life2">40</span>
<button onclick="changeLife(2,1)" aria-label="Increase life">+</button>
</div>
<div class="zone-row">
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
</div>
<div class="commander-row">
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
</div>
<div class="battle-zone"><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">
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
<div class="commander-row">
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
</div>
<div class="zone-row">
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
</div>
<span class="player-label">Player 3</span>
<div class="life-counter">
<button onclick="changeLife(3,-1)" aria-label="Decrease life">−</button>
<span id="life3">40</span>
<button onclick="changeLife(3,1)" aria-label="Increase life">+</button>
</div>
</div>
<!-- PLAYER 4: Bottom-Right (Gold) -->
<div class="player-area p4">
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
<div class="commander-row">
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
</div>
<div class="zone-row">
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
</div>
<span class="player-label">Player 4</span>
<div class="life-counter">
<button onclick="changeLife(4,-1)" aria-label="Decrease life">−</button>
<span id="life4">40</span>
<button onclick="changeLife(4,1)" aria-label="Increase life">+</button>
</div>
</div>
</div>
</div>
<script>
const lives = { 1: 40, 2: 40, 3: 40, 4: 40 };
function changeLife(p, d) {
lives[p] = Math.max(0, lives[p] + d);
const el = document.getElementById('life' + p);
el.textContent = lives[p];
el.style.transform = 'scale(1.25)';
el.style.transition = 'transform 0.15s ease';
setTimeout(() => { el.style.transform = 'scale(1)'; }, 150);
}
</script>
</body>
</html>
+2068
View File
File diff suppressed because it is too large. Load diff