Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31b8467d0 |
No files matched your search
File diff suppressed because it is too large.
Load diff
-25612
File diff suppressed because it is too large.
Load diff
@@ -1,643 +0,0 @@
|
||||
<!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>↩</button>
|
||||
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>↪</button>
|
||||
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">❇</button>
|
||||
<button class="btn-util" onclick="openDiceModal()" title="Dice & Coins (G)">🎲</button>
|
||||
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
|
||||
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">⚙</button>
|
||||
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</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">−</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">📚</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">⬡</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">☠</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">🃏</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">👑</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">−</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">📚</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">⬡</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">☠</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">🃏</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">👑</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">👑</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">📚</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">⬡</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">☠</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">🃏</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">−</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">👑</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">📚</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">⬡</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">☠</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">🃏</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">−</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">✕</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">‹</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()">✕</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 & Coins</span>
|
||||
<button class="btn-close" onclick="closeDiceModal()">✕</button>
|
||||
</div>
|
||||
<div class="dice-result-display" id="dice-result-display">—</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()">✕</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()">✕</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()">✕</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()">✕</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()">🎲 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()">✕</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('👑 <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>
|
||||
@@ -1,270 +0,0 @@
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// 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 }
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
-2325
File diff suppressed because it is too large.
Load diff
+131
-139
@@ -1,139 +1,131 @@
|
||||
"""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 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 \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
|
||||
|
||||
|
||||
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},
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/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`);
|
||||
@@ -13,16 +13,6 @@ 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 = {
|
||||
@@ -207,7 +197,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 = str(_REPO_ROOT / "imported-decks")
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "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")
|
||||
@@ -219,19 +209,15 @@ def _find_dck_file(deck_name: str) -> Optional[Path]:
|
||||
"""Search known deck directories for a .dck file matching deck_name.
|
||||
|
||||
Resolution order:
|
||||
1. CFG.precon_dir (explicit CLI/env config)
|
||||
2. CFG.forge_decks_dir (Forge user decks)
|
||||
1. precon_dir (precon-decks/)
|
||||
2. 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: list[Path] = []
|
||||
|
||||
# CFG-configured paths (set via CLI args)
|
||||
search_dirs = []
|
||||
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):
|
||||
@@ -241,11 +227,6 @@ 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:
|
||||
@@ -322,9 +303,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,
|
||||
FORGE_DIR/res/decks/commander, and repo-relative precon-decks/
|
||||
sample-decks/ so precon decks work without any configuration.
|
||||
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.
|
||||
"""
|
||||
# -- 1. DB lookup --
|
||||
conn = _get_db_conn()
|
||||
|
||||
Reference in new issue
Block a user