feat: AI bug fixes, deck/collection migration, and enrichment pipeline

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rgriffithandClaude Opus 4.6 committed 2026-04-05 00:05:23 -04:00
1 parent 30c3ab5a89
commit 409aba26f4
18 files changed
+16726 -36

No files matched your search

+16 -7
View File
@@ -366,7 +366,11 @@ class GameStateBridge {
seat_type: p.isAI
? this._mapAiProfile(gameState.aiDifficulty)
: 'Human',
deck_name: p.commanderCardName || '',
deck_name: p.deckFileName || (
p.commanderCardName
? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
: ''
),
player_name: p.name,
}));
@@ -394,11 +398,13 @@ class GameStateBridge {
*/
_mapAiProfile(difficulty) {
const map = {
easy: 'AI',
normal: 'AI Aggro',
hard: 'AI Control',
easy: 'AI',
normal: 'AI Midrange',
hard: 'AI Control',
combo: 'AI Combo',
aggro: 'AI Aggro',
};
return map[difficulty] || 'AI';
return map[difficulty] || 'AI Midrange';
}
// ── Game State Serialization ──────────────────────────────
@@ -451,7 +457,10 @@ class GameStateBridge {
// Zone contents — card names
hand: p.zones.hand.map(c => c.name || 'Unknown'),
hand_count: p.zones.hand.length,
battlefield: bfCards.map(c => ({
// Flat string list for the state encoder
battlefield: bfCards.map(c => c.name || 'Unknown'),
// Structured data for backend logic that needs detail
battlefield_detail: bfCards.map(c => ({
name: c.name || 'Unknown',
type: c.typeLine || '',
power: c.power || '0',
@@ -488,7 +497,7 @@ class GameStateBridge {
return {
game_id: this._sessionGameId || '',
turn: gameState.turnCounter || 1,
turn: gameState.turnNumber || gameState.turnCounter || 1,
phase: phaseMap[phaseId] || 'main_1',
active_player: playerIdx,
players: players,
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Collection</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="collection.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
<div id="collection-container"></div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="collection.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Collection.init();
});
</script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+495
View File
@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Deck Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckbuilder.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
<div id="deck-builder-page">
<!-- TOP BAR -->
<header class="deck-topbar">
<div class="deck-topbar-commander">
<div class="deck-commander-portrait" id="deck-commander-portrait">
<span class="deck-commander-portrait-placeholder">⚔</span>
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
</div>
<div class="deck-name-block">
<input
type="text"
class="deck-name-input"
id="deck-name-input"
placeholder="Untitled Deck"
value=""
/>
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
</div>
<div class="deck-color-chips" id="deck-color-chips"></div>
</div>
<div class="deck-topbar-center">
<div class="deck-count-display">
<span class="deck-count-num" id="deck-total-count">0</span>
<span class="deck-count-sep">/</span>
<span class="deck-count-target">100</span>
<span class="deck-count-label">cards</span>
</div>
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
<div class="deck-selector-wrap">
<select class="deck-selector" id="deck-selector">
<option value="">— Select Deck —</option>
</select>
</div>
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
</div>
<div class="deck-topbar-actions">
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
<span>⬆</span> Import
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
<span>⬇</span> Export .dck
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
<span>&#x1F4E4;</span> Export to Sim
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
<span>&#x1F3AE;</span> Use in Game
</button>
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
<span class="deck-btn-icon">▶</span> Run Simulation
</button>
</div>
</header>
<!-- MAIN 3-COLUMN LAYOUT -->
<main class="deck-main">
<!-- LEFT COLUMN: Collection Search -->
<aside class="deck-col-left">
<div class="deck-panel deck-panel-search">
<div class="deck-panel-header">
<span class="deck-panel-title">Collection Search</span>
<span class="deck-search-count" id="deck-search-count">0 results</span>
</div>
<div class="deck-search-bar-wrap">
<input
type="search"
class="deck-search-bar"
id="deck-search-bar"
placeholder="Search cards..."
autocomplete="off"
/>
<span class="deck-search-icon">🔍</span>
</div>
<div class="deck-filter-row">
<select class="deck-filter-select" id="deck-type-filter">
<option value="">All Types</option>
<option value="Land">Land</option>
<option value="Creature">Creature</option>
<option value="Instant">Instant</option>
<option value="Sorcery">Sorcery</option>
<option value="Artifact">Artifact</option>
<option value="Enchantment">Enchantment</option>
<option value="Planeswalker">Planeswalker</option>
</select>
<div class="deck-cmc-filter">
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
<span class="deck-cmc-sep">–</span>
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
</div>
</div>
<div class="deck-color-filter-row" id="deck-color-filter">
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
</div>
<!-- Results list -->
<div class="deck-search-results" id="deck-search-results">
<div class="deck-empty-state">
<div class="deck-empty-icon">🃏</div>
<div class="deck-empty-text">Search your collection to add cards</div>
</div>
</div>
<div class="deck-search-footer">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
Add All Matching (Owned)
</button>
</div>
</div>
</aside>
<!-- CENTER COLUMN: Deck Card List -->
<section class="deck-col-center">
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
<div class="deck-panel-header deck-card-list-header">
<span class="deck-panel-title">Deck Contents</span>
<div class="deck-card-list-actions">
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
</div>
</div>
<!-- Card groups by type -->
<div class="deck-card-groups" id="deck-card-groups">
<div class="deck-empty-state deck-empty-state-lg">
<div class="deck-empty-icon">⚔</div>
<div class="deck-empty-text">Create or select a deck to start building</div>
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
</div>
</div>
<!-- Quick add by name -->
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
<div class="deck-quickadd-wrap">
<input
type="text"
class="deck-quickadd-input"
id="deck-quickadd-input"
placeholder="Add card by name..."
autocomplete="off"
/>
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
</div>
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
</div>
<!-- Deck stats footer -->
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
<span class="deck-stat-pill-label">total</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
<span class="deck-stat-pill-label">owned</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
<span class="deck-stat-pill-label">avg CMC</span>
</div>
</div>
</div>
</section>
<!-- RIGHT COLUMN: EDH Advisor -->
<aside class="deck-col-right">
<!-- Ratio Dashboard -->
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
<span class="deck-panel-title">📊 Ratio Dashboard</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ratio-body">
<div class="deck-ratio-bars" id="deck-ratio-bars">
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
</div>
</div>
</div>
<!-- Fix My Ratios -->
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
<span class="deck-panel-title">🔧 Fix My Ratios</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-fix-body">
<div class="deck-shortfalls" id="deck-shortfalls">
<div class="deck-ratio-placeholder">No analysis available</div>
</div>
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
⚡ Auto-suggest from Collection
</button>
</div>
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
</div>
</div>
<!-- EDHREC Recommendations -->
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
<span class="deck-panel-title">✨ EDHREC Recs</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-edhrec-body">
<div class="deck-edhrec-controls">
<label class="deck-toggle-label">
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Owned Only
</label>
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
↺ Refresh
</button>
</div>
<div class="deck-edhrec-list" id="deck-edhrec-list">
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
</div>
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
Add All Owned (respect ratios)
</button>
</div>
</div>
</div>
<!-- AI Research (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
<span class="deck-panel-title">🧠 AI Research</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-research-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Analysis Goal</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
placeholder="Identify weaknesses and suggest upgrades" />
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
placeholder="Dockside Extortionist, Rhystic Study" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
🔍 Analyze Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Analyzing with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
</div>
</div>
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
<span class="deck-panel-title">✨ AI Generate Deck</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-generate-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Commander</label>
<div class="deck-ai-commander-wrap">
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
placeholder="Search for a commander..." autocomplete="off" />
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
</div>
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
placeholder="Rhystic Study, Cyclonic Rift" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
✨ Generate Full Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Generating deck with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
</div>
</div>
</aside>
</main>
<!-- CARD PREVIEW TOOLTIP -->
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
</div>
<!-- TOAST CONTAINER -->
<div class="deck-toast-container" id="deck-toast-container"></div>
<!-- CONFIRM MODAL -->
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
</div>
<div class="deck-modal-body">
<p class="deck-confirm-message" id="deck-confirm-message"></p>
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- NEW DECK MODAL -->
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Create New Deck</h3>
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-form-field">
<label class="deck-form-label">Deck Name</label>
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
</div>
<div class="deck-form-field">
<label class="deck-form-label">Commander (optional)</label>
<div class="deck-commander-search-wrap">
<input
type="text"
class="deck-form-input"
id="deck-new-commander-search"
placeholder="Search Scryfall for commander..."
autocomplete="off"
/>
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
</div>
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
</div>
</div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
</div>
</div>
</div>
<!-- IMPORT MODAL -->
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
<div class="deck-modal deck-modal-wide">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Import Decklist</h3>
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-import-tabs">
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
<button class="deck-import-tab" data-tab="file">Upload File</button>
</div>
<div id="deck-import-paste-panel" class="deck-import-panel">
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
Supported formats:
[Commander]
1 Korvold, Fae-Cursed King
[Deck]
1 Sol Ring
1 Cultivate
37 Forest
Or plain list:
Sol Ring
Cultivate
Forest"></textarea>
</div>
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
<div class="deck-import-dropzone" id="deck-import-dropzone">
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
</div>
<div class="deck-import-file-info" id="deck-import-file-name"></div>
</div>
<div class="deck-import-options">
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="new" checked /> Create new deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="add" /> Add to current deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
</label>
</div>
<div id="deck-import-new-name-row" class="deck-import-name-row">
<label>Deck name (optional):</label>
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
</div>
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
<div class="deck-import-progress-track">
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
</div>
</div>
<div id="deck-import-result" style="display:none"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
</div>
</div>
</div>
</div><!-- /#deck-builder-page -->
<script>
// Set API base URL — when served from a different host, override this value
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckbuilder.js"></script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Auto Deck Generator V3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckgenerator.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
<div id="deckgen-page">
<!-- MAIN LAYOUT -->
<main class="dg-main">
<!-- LEFT: Configuration -->
<section class="dg-col-config">
<!-- Commander Search -->
<div class="dg-panel dg-panel-commander">
<div class="dg-panel-header">
<span class="dg-panel-title">Commander</span>
</div>
<div class="dg-panel-body">
<div class="dg-commander-search-wrap">
<input
type="text"
class="dg-input"
id="dg-commander-input"
placeholder="Search for a legendary creature..."
autocomplete="off"
/>
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
</div>
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
<div class="dg-commander-card">
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">&times;</button>
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
<div class="dg-commander-info">
<div class="dg-commander-name" id="dg-commander-name"></div>
<div class="dg-commander-type" id="dg-commander-type"></div>
<div class="dg-commander-colors" id="dg-commander-colors"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Strategy & Bracket -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Strategy &amp; Power</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<div class="dg-bracket-btns" id="dg-bracket-btns">
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
</div>
</div>
</div>
</div>
<!-- Deck Name -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Deck Name</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-deck-name"
placeholder="Auto-generated from commander name"
/>
</div>
</div>
<!-- Budget -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Budget</span>
</div>
<div class="dg-panel-body">
<div class="dg-budget-row">
<input
type="number"
class="dg-input dg-input-narrow"
id="dg-budget"
placeholder="No limit"
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
</div>
</div>
</div>
<!-- Options -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Options</span>
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
</label>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
</div>
</div>
</div>
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">&#9889;</span> Generate Deck
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
</section>
<!-- RIGHT: Preview Results -->
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
</div>
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
</section>
</main>
<!-- TOAST CONTAINER -->
<div class="dg-toast-container" id="dg-toast-container"></div>
</div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckgenerator.js"></script>
</body>
</html>
+792
View File
@@ -0,0 +1,792 @@
/**
* Commander AI Lab — Auto Deck Generator V3 UI
* ═══════════════════════════════════════════════
*
* V3 Endpoints:
* GET /api/deck/v3/status — check V3 generator status
* GET /api/deck-generator/commander-search — autocomplete commanders
* POST /api/deck/v3/generate — generate deck (local AI structured output)
* POST /api/deck/v3/commit — generate + save to Deck Builder
* POST /api/deck/v3/export/csv — export as CSV
* POST /api/deck/v3/export/dck — export as Forge .dck
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
* POST /api/deck/v3/export/shopping — shopping list of missing cards
*/
const DeckGenerator = (() => {
'use strict';
const API_BASE = window.LAB_API_BASE || window.location.origin;
const DEBOUNCE_MS = 350;
const COLOR_MAP = {
W: { label: 'W', cls: 'dg-pip-W' },
U: { label: 'U', cls: 'dg-pip-U' },
B: { label: 'B', cls: 'dg-pip-B' },
R: { label: 'R', cls: 'dg-pip-R' },
G: { label: 'G', cls: 'dg-pip-G' },
C: { label: 'C', cls: 'dg-pip-C' },
};
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
const TYPE_ICONS = {
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
'Other': '🃏',
};
const STATUS_BADGES = {
owned: { label: 'Owned', cls: 'dg-badge-owned' },
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
missing: { label: 'Missing', cls: 'dg-badge-missing' },
};
// ── State ──────────────────────────────────────────────
let state = {
commander: null,
targetBracket: 3,
previewResult: null,
lastRequestBody: null,
isLoading: false,
v3Ready: false,
};
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
async function init() {
await checkV3Status();
bindEvents();
checkQueryParams();
}
async function checkV3Status() {
try {
const resp = await fetch(API_BASE + '/api/deck/v3/status');
const data = await resp.json();
state.v3Ready = data.initialized;
if (!state.v3Ready) {
// Warn but do not block — local/Ollama mode may still work
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
}
}
function bindEvents() {
// Commander search
const input = $('dg-commander-input');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = input.value.trim();
if (q.length < 2) { hideDropdown(); return; }
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
});
input.addEventListener('focus', () => {
const q = input.value.trim();
if (q.length >= 2) searchCommander(q);
});
document.addEventListener('click', (e) => {
const wrap = document.querySelector('.dg-commander-search-wrap');
if (wrap && !wrap.contains(e.target)) hideDropdown();
});
$('dg-commander-clear').addEventListener('click', clearCommander);
// Bracket buttons
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
btn.classList.add('dg-bracket-active');
state.targetBracket = parseInt(btn.dataset.bracket, 10);
});
});
// Generate & save
$('dg-preview-btn').addEventListener('click', generateDeck);
$('dg-save-btn').addEventListener('click', commitDeck);
$('dg-regenerate-btn').addEventListener('click', generateDeck);
// Export buttons
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
const params = new URLSearchParams(window.location.search);
const name = params.get('commander');
if (name) {
$('dg-commander-input').value = name;
searchCommander(name);
}
}
// ── Build Request Body ──────────────────────────────────
function buildRequestBody() {
const budget = parseFloat($('dg-budget').value) || null;
return {
commander_name: state.commander.name,
strategy: $('dg-strategy').value.trim(),
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: omitCards.slice(),
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
deck_name: $('dg-deck-name').value.trim() || '',
};
}
// ── Commander Search ────────────────────────────────────
function positionDropdown() {
const input = $('dg-commander-input');
const dropdown = $('dg-commander-dropdown');
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
}
async function searchCommander(q) {
const dropdown = $('dg-commander-dropdown');
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
dropdown.classList.add('open');
positionDropdown();
try {
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
const data = await resp.json();
renderDropdown(data.results || []);
} catch (e) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
}
}
function renderDropdown(results) {
const dropdown = $('dg-commander-dropdown');
if (results.length === 0) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
return;
}
dropdown.innerHTML = results.map((r, idx) => {
const colors = (r.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const badge = r.in_collection
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
const imgTag = imgSrc
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
+ imgTag
+ '<div class="dg-dropdown-item-info">'
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
+ '</div>'
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
+ badge
+ '</div>';
}).join('');
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
el.addEventListener('click', () => {
selectCommander(results[parseInt(el.dataset.idx, 10)]);
hideDropdown();
});
});
}
function selectCommander(cmdr) {
state.commander = cmdr;
$('dg-commander-input').style.display = 'none';
$('dg-commander-selected').style.display = 'block';
$('dg-commander-name').textContent = cmdr.name;
$('dg-commander-type').textContent = cmdr.type_line || '';
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const img = $('dg-commander-img');
if (cmdr.image_url) {
var imgUrl = cmdr.image_url;
if (imgUrl.indexOf('version=small') !== -1) {
imgUrl = imgUrl.replace('version=small', 'version=normal');
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
imgUrl += '&version=normal';
} else if (imgUrl.indexOf('version=') === -1) {
imgUrl += '?version=normal';
}
img.src = imgUrl;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
state.commander = null;
$('dg-commander-input').style.display = '';
$('dg-commander-input').value = '';
$('dg-commander-selected').style.display = 'none';
$('dg-preview-btn').disabled = true;
}
function hideDropdown() {
$('dg-commander-dropdown').classList.remove('open');
}
// -- Conversation Log Helper --
function logMessage(msg, type = 'info') {
const log = $('dg-conversation-log');
if (!log) return;
const entry = document.createElement('div');
entry.className = 'dg-log-entry dg-log-' + type;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
const log = $('dg-conversation-log');
if (!log) return;
log.innerHTML = '';
}
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info');
if (!state.commander) { toast('Select a commander first', 'error'); return; }
// Non-blocking warning only — local Ollama mode does not require v3Ready
if (!state.v3Ready) {
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
}
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
state.lastRequestBody = body;
try {
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Generation failed');
}
state.previewResult = await resp.json();
logMessage('AI response received! Processing deck data...', 'success');
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
}
}
function setLoading(loading, text) {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'none';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
// ── Render Results ──────────────────────────────────────
function renderResults(result) {
$('dg-loading').style.display = 'none';
$('dg-results').style.display = 'block';
$('dg-preview-btn').disabled = false;
renderStrategyBar(result);
renderStatsBar(result);
renderSubstitutionSummary(result);
renderCardGroups(result);
renderUseInGameButton(result);
}
function renderUseInGameButton(result) {
// Only show when running inside the LAN client iframe
if (window.parent === window) return;
var existing = document.getElementById('dg-export-game-btn');
if (existing) existing.remove();
var btn = document.createElement('button');
btn.id = 'dg-export-game-btn';
btn.className = 'dg-btn dg-btn-preview';
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
btn.innerHTML = '<span class="dg-btn-icon">&#x1F3AE;</span> Use in Game';
btn.addEventListener('click', function() {
var cards = (result.cards || []).map(function(c) {
return { name: c.name, quantity: c.count || 1, is_commander: false };
});
if (result.commander) {
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
}
window.parent.postMessage({
type: 'DECK_BUILDER_EXPORT',
deck: cards,
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
commander: result.commander ? result.commander.name : '',
}, '*');
toast('Deck sent to game setup!', 'success');
});
$('dg-results').appendChild(btn);
}
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
bar.innerHTML = '<div class="dg-strategy-info">'
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
+ '</div>'
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
+ (bracket.game_changers && bracket.game_changers.length
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
: '');
}
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
chipHtml('Lands', stats.land_count || 0, ''),
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
];
container.innerHTML = chips.join('');
}
function chipHtml(label, num, cls) {
return '<div class="dg-stat-chip ' + cls + '">'
+ '<span class="dg-stat-num">' + num + '</span>'
+ '<span class="dg-stat-label">' + label + '</span>'
+ '</div>';
}
function renderSubstitutionSummary(result) {
const el = $('dg-sub-summary');
const sub = result.substitution_stats;
if (!sub) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '<div class="dg-sub-bar">'
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
+ '</div>';
}
function renderCardGroups(result) {
const container = $('dg-card-groups');
const cards = result.cards || [];
const commander = result.commander;
const groups = {};
for (const card of cards) {
const cat = card.category || 'Other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(card);
}
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
for (const t of Object.keys(groups)) {
if (!sortedTypes.includes(t)) sortedTypes.push(t);
}
let html = '';
if (commander) {
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header">'
+ '<span class="dg-group-title">⚔ Commander</span>'
+ '<span class="dg-group-count">1</span>'
+ '</div>'
+ '<div class="dg-group-body">'
+ '<div class="dg-card-row dg-card-row-commander">'
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
+ '</div>'
+ '</div></div>';
}
for (const type of sortedTypes) {
const groupCards = groups[type];
if (!groupCards || groupCards.length === 0) continue;
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const icon = TYPE_ICONS[type] || '🃏';
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header" data-type="' + type + '">'
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
+ '</div>'
+ '<div class="dg-group-body" data-group="' + type + '">';
for (const card of groupCards) {
html += renderCardRow(card);
}
html += '</div></div>';
}
container.innerHTML = html;
container.querySelectorAll('.dg-group-header').forEach(header => {
header.addEventListener('click', () => {
const type = header.dataset.type;
if (!type) return;
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
if (body) body.classList.toggle('collapsed');
});
});
}
function renderCardRow(card) {
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
const roleTags = (card.role_tags || []).slice(0, 3)
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
.join('');
let subHtml = '';
if (card.status === 'substituted' && card.selected_substitute) {
subHtml = '<div class="dg-card-sub-line">'
+ '<span class="dg-sub-arrow">→</span> '
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
+ '</div>';
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
+ '<span class="dg-sub-label">Alternatives:</span> '
+ card.alternatives.slice(0, 3).map(a =>
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
+ '</span>'
).join('')
+ '</div>';
}
const price = card.estimated_price_usd
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
: '';
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
+ '<div class="dg-card-row-main">'
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
+ statusBadge
+ price
+ '</div>'
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
+ subHtml
+ '</div>';
}
// ── Commit Deck ─────────────────────────────────────────
async function commitDeck() {
if (!state.commander) { toast('No commander selected', 'error'); return; }
$('dg-save-btn').disabled = true;
$('dg-save-btn').textContent = 'Saving...';
try {
const body = state.lastRequestBody || buildRequestBody();
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Save failed');
}
const result = await resp.json();
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
setTimeout(() => {
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
}, 1200);
} catch (e) {
toast(e.message || 'Save failed', 'error');
} finally {
$('dg-save-btn').disabled = false;
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
}
}
// ── Exports ─────────────────────────────────────────────
async function exportDeck(format) {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
a.click();
URL.revokeObjectURL(url);
toast('Exported ' + format.toUpperCase(), 'success');
} catch (e) {
toast(e.message || 'Export failed', 'error');
}
}
async function exportMoxfield() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.content) {
await navigator.clipboard.writeText(data.content);
toast('Moxfield deck copied to clipboard', 'success');
}
} catch (e) {
toast('Moxfield export failed', 'error');
}
}
async function exportShopping() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
const list = data.shopping_list || [];
if (list.length === 0) {
toast('All cards are owned — nothing to buy', 'success');
return;
}
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
for (const item of list) {
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
}
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
await navigator.clipboard.writeText(lines.join('\n'));
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
} catch (e) {
toast('Shopping list failed', 'error');
}
}
// ── Toast ───────────────────────────────────────────────
function toast(msg, type) {
type = type || 'info';
const container = $('dg-toast-container');
const el = document.createElement('div');
el.className = 'dg-toast dg-toast-' + type;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Helpers ─────────────────────────────────────────────
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return { init };
})();
document.addEventListener('DOMContentLoaded', () => {
DeckGenerator.init();
});
+83 -8
View File
@@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) {
// Helper: find the card on battlefield for counter placement
function findCardOnBattlefield(cardName, ownerIdx) {
var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; });
for (var i = 0; i < bf.length; i++) {
if (bf[i].name === cardName) return bf[i];
}
@@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) {
var opps4 = getOpponents(oi);
var placed = false;
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; });
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
cmBf[cmj].counters = cmBf[cmj].counters || {};
@@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) {
case 'counter_all': {
// +1/+1 counter on each creature you control
var caBf = (gameState.players[oi] || {}).battlefield || [];
var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var caCount = 0;
for (var cai = 0; cai < caBf.length; cai++) {
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
@@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) {
}
case 'untap_all': {
var uaBf = (gameState.players[oi] || {}).battlefield || [];
var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var uaCount = 0;
for (var uai = 0; uai < uaBf.length; uai++) {
if (uaBf[uai].tapped) {
@@ -12779,6 +12779,9 @@ async function loadPreconForPlayer(playerIdx, preconId) {
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
if (precon.fileName) {
player.deckFileName = precon.fileName;
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
@@ -16225,6 +16228,72 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
return assignments;
}
// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
function aiDeclareAttacks(playerIdx) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player || player.eliminated) return;
var difficulty = gameState.aiDifficulty || 'easy';
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) return;
var attackerCards = [];
if (difficulty === 'easy') {
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
}
}
}
if (attackerCards.length === 0) return;
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
fireBattlefieldTriggers('attack');
}
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
@@ -16593,7 +16662,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
if (difficulty !== 'easy') {
{
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
@@ -17205,12 +17274,12 @@ function _runLocalAiTurn() {
}
// ========================================
// MAIN PHASE 2: Post-combat spell casting (normal & hard)
// MAIN PHASE 2: Post-combat spell casting
// Hard: play held-back removal + instants + any remaining affordable spells
// Normal: try to play any remaining affordable spells
// Normal/Easy: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
if (difficulty !== 'easy') {
{
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this
var bluffReserve = 0;
@@ -25634,3 +25703,9 @@ function _safeRender(fn, ...args) {
try { window[fn](...args); } catch(e) {}
}
}
// Expose functions needed by ai-bridge.js
window.aiTryCastCommander = aiTryCastCommander;
window.aiDeclareAttacks = aiDeclareAttacks;
window.playCardFromHand = playCardFromHand;
window.aiCountUntappedLands = aiCountUntappedLands;
+955
View File
@@ -0,0 +1,955 @@
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Global Design System
Dark & Polished · Premium Dashboard Aesthetic
═══════════════════════════════════════════════════════════ */
/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ──────────────────────────────────────── */
:root {
/* Surfaces — layered dark with subtle warmth */
--bg: #0c0e14;
--surface-0: #12141c;
--surface-1: #171a24;
--surface-2: #1c1f2b;
--surface-3: #222533;
--surface-hover: #252839;
/* Borders */
--border: #2a2d3a;
--border-subtle: #1e2130;
--border-focus: #4a6fa5;
/* Text */
--text: #e1e4eb;
--text-secondary:#8b90a0;
--text-tertiary: #5c6070;
--text-inverse: #0c0e14;
/* Accent — cool blue with subtle depth */
--accent: #5b9ef0;
--accent-hover: #7ab4ff;
--accent-dim: rgba(91, 158, 240, 0.12);
--accent-glow: rgba(91, 158, 240, 0.25);
/* Semantic */
--success: #4ade80;
--success-dim: rgba(74, 222, 128, 0.12);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--gold: #fcd34d;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
--shadow-glow: 0 0 20px var(--accent-dim);
/* Transitions */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 180ms;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
--sp-16: 64px;
/* Layout */
--content-max: 1280px;
--sidebar-width: 280px;
--nav-height: 56px;
}
/* ── Base ───────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
h1, h2, h3, h4, h5, h6 {
color: #fff;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 24px; }
h2 { font-size: 18px; }
h3 { font-size: 16px; }
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
a:hover { color: var(--accent-hover); }
img, svg { display: block; max-width: 100%; }
button { cursor: pointer; font: inherit; }
input, select, textarea { font: inherit; color: inherit; }
table { border-collapse: collapse; width: 100%; }
::selection {
background: var(--accent-dim);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* ── Navigation ─────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: var(--nav-height);
padding: 0 var(--sp-6);
background: rgba(12, 14, 20, 0.85);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
}
.nav-brand svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.nav-links {
display: flex;
align-items: center;
gap: var(--sp-1);
overflow-x: auto;
scrollbar-width: none;
}
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--duration) var(--ease);
text-decoration: none;
}
.nav-links a:hover {
color: var(--text);
background: var(--surface-2);
}
.nav-links a.active {
color: #fff;
background: var(--accent-dim);
}
.nav-links a .nav-icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.nav-links a.active .nav-icon,
.nav-links a:hover .nav-icon {
opacity: 1;
}
/* ── Page Layout ────────────────────────────────────────── */
.page {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-8) var(--sp-6);
}
.page-header {
margin-bottom: var(--sp-8);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
}
/* ── Cards / Panels ─────────────────────────────────────── */
.panel {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-6);
margin-bottom: var(--sp-6);
transition: border-color var(--duration) var(--ease);
}
.panel:hover {
border-color: var(--border-focus);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Stat Cards ─────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-6);
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-5);
transition: all var(--duration) var(--ease);
}
.stat-card:hover {
border-color: var(--border-focus);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: var(--sp-1);
}
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all var(--duration) var(--ease);
white-space: nowrap;
line-height: 1.4;
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 14px var(--accent-glow);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--text-tertiary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px 12px;
}
.btn-ghost:hover {
color: var(--text);
background: var(--surface-2);
}
.btn-success { background: var(--success); color: var(--text-inverse); }
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
.btn-purple { background: var(--purple); color: var(--text-inverse); }
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-lg { padding: 12px 24px; font-size: 15px; }
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
/* ── Forms ──────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 9px 14px;
font-size: 14px;
color: var(--text);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all var(--duration) var(--ease);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
outline: none;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--text-tertiary);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-4);
}
/* ── Progress Bar ───────────────────────────────────────── */
.progress-container {
margin-top: var(--sp-4);
padding: var(--sp-4);
background: var(--surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.progress-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.progress-track {
height: 6px;
background: var(--surface-3);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
border-radius: 3px;
transition: width 0.3s var(--ease);
}
.progress-detail {
margin-top: var(--sp-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
line-height: 1.6;
}
/* ── Phase Badge ────────────────────────────────────────── */
.badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
.badge-idle::before { background: var(--text-tertiary); }
.badge-running { background: var(--accent-dim); color: var(--accent); }
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-success::before { background: var(--success); }
.badge-error { background: var(--error-dim); color: var(--error); }
.badge-error::before { background: var(--error); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-warning::before { background: var(--warning); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Empty State ────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-12) var(--sp-6);
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--sp-4);
opacity: 0.3;
}
.empty-state p {
font-size: 14px;
margin-bottom: var(--sp-3);
}
/* ── Tables ─────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
background: var(--surface-0);
}
.data-table tbody td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.data-table tbody tr:hover {
background: var(--surface-2);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* ── Tags ───────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-blue { background: var(--accent-dim); color: var(--accent); }
.tag-green { background: var(--success-dim); color: var(--success); }
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
.tag-red { background: var(--error-dim); color: var(--error); }
.tag-purple { background: var(--purple-dim); color: var(--purple); }
/* ── Toast / Notification ───────────────────────────────── */
.toast {
position: fixed;
bottom: var(--sp-6);
right: var(--sp-6);
padding: var(--sp-3) var(--sp-5);
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 13px;
color: var(--text);
z-index: 1000;
animation: toast-in 0.3s var(--ease);
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--error); }
.toast-warning { border-left: 3px solid var(--warning); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading Spinner ────────────────────────────────────── */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Divider ────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--border);
margin: var(--sp-5) 0;
}
/* ── Utility Classes ────────────────────────────────────── */
.hidden { display: none !important; }
.text-accent { color: var(--accent); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-error { color: var(--error); }
.text-muted { color: var(--text-secondary); }
.text-faint { color: var(--text-tertiary); }
.text-mono { font-family: var(--font-mono); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-6 { margin-bottom: var(--sp-6); }
.gap-2 { gap: var(--sp-2); }
.gap-4 { gap: var(--sp-4); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.grid { display: grid; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: var(--sp-5) var(--sp-4);
}
.nav {
padding: 0 var(--sp-4);
}
.nav-brand {
margin-right: var(--sp-4);
font-size: 13px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.stat-grid {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════
PREMIUM UPGRADE LAYER
═══════════════════════════════════════════════════════════ */
/* ── Ambient background glow (body) ─────────────────────── */
body {
background-image:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
background-attachment: fixed;
}
/* ── Page title: subtle gradient text ───────────────────── */
.page-header h1 {
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Nav: stronger glass + accent underline ─────────────── */
.nav {
background: rgba(9,11,16,0.80);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
border-bottom-color: rgba(255,255,255,0.06);
}
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
/* ── Panels: gradient border on hover ───────────────────── */
.panel {
position: relative;
transition: border-color var(--duration) var(--ease),
box-shadow var(--duration) var(--ease);
}
.panel::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration) var(--ease);
}
.panel:hover::before { opacity: 1; }
.panel:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
}
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
.stat-card {
position: relative;
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
pointer-events: none;
}
.stat-card:hover {
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
transform: translateY(-2px);
}
/* ── Buttons: gradient fills + inner highlight ───────────── */
.btn { position: relative; overflow: hidden; }
.btn::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
border-radius: inherit;
pointer-events: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #4a8de0);
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
}
.btn-success {
background: linear-gradient(135deg, var(--success), #38c46c);
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
}
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
.btn-purple {
background: linear-gradient(135deg, var(--purple), #9171f0);
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
}
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
/* ── Tags: subtle colored border ────────────────────────── */
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
/* ── Progress fill: glow ─────────────────────────────────── */
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
/* ── Badge running: glow dot ─────────────────────────────── */
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
/* ── Toast: glassmorphism + colored glow ─────────────────── */
.toast { backdrop-filter: blur(12px); }
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
/* ── Divider: fade-out gradient ──────────────────────────── */
.divider {
background: linear-gradient(90deg, transparent, var(--border), transparent);
}
/* ── Inputs: inset shadow for depth ─────────────────────── */
.form-input, .form-select, .form-textarea {
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
}
/* ══════════════════════════════════════════════════════════════
Tooltip System (.tip-icon + data-tip)
Shared across all pages — do not duplicate in page-specific CSS
══════════════════════════════════════════════════════════════ */
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
font-style: normal;
cursor: default;
position: relative;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tip-icon:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tip-icon::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-0, #1a1a2e);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #333);
border-radius: var(--radius-md, 6px);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
width: 240px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
text-align: left;
text-transform: none;
letter-spacing: 0;
}
.tip-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border, #333);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
}
.tip-icon.tip-below::after {
bottom: auto;
top: calc(100% + 8px);
}
.tip-icon.tip-below::before {
bottom: auto;
top: calc(100% + 3px);
border-top-color: transparent;
border-bottom-color: var(--border, #333);
}
+115
View File
@@ -51,6 +51,9 @@
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</button>
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">&#x1F0CF;</button>
<button class="btn-util" id="btn-deckgen-toggle" onclick="toggleDeckGenPanel()" title="Auto Deck Generator">&#x26A1;</button>
<button class="btn-util" id="btn-collection-toggle" onclick="toggleCollectionPanel()" title="Collection">&#x1F4DA;</button>
</div>
<!-- Storm counter -->
@@ -688,10 +691,122 @@ document.addEventListener('DOMContentLoaded', function() {
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
// Wire up WebSocket push events from backend
AIBridge.connector.on('ai:decision', function(msg) {
if (!gameState || !msg.action) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (player && player.isAI) {
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
}
});
AIBridge.connector.on('state:snapshot', function(msg) {
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
console.log('[WS] State snapshot received from backend');
}
});
});
}
});
</script>
<!-- ====================================================
DECK BUILDER PANEL (iframe)
==================================================== -->
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Deck Builder</span>
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">&times;</button>
</div>
<iframe src="deckbuilder.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckbuilder-iframe">
</iframe>
</div>
<div id="panel-collection" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Collection</span>
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">&times;</button>
</div>
<iframe src="collection.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="collection-iframe">
</iframe>
</div>
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Auto Deck Generator</span>
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">&times;</button>
</div>
<iframe src="deckgenerator.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckgenerator-iframe">
</iframe>
</div>
<script>
function toggleDeckBuilderPanel() {
var panel = document.getElementById('panel-deckbuilder');
if (panel) panel.classList.toggle('hidden');
}
function toggleDeckGenPanel() {
var panel = document.getElementById('panel-deckgenerator');
if (panel) panel.classList.toggle('hidden');
}
function toggleCollectionPanel() {
var panel = document.getElementById('panel-collection');
if (panel) panel.classList.toggle('hidden');
}
// Listen for deck exports from the deck builder iframe
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
var deckData = e.data;
// Build a decklist text string compatible with importDeckForPlayer()
var lines = [];
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
if (commanders.length > 0) {
lines.push('[Commander]');
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
lines.push('');
}
lines.push('[Deck]');
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
// Store on window for the setup screen to pick up
window._pendingDeckImport = {
text: lines.join('\n'),
deckName: deckData.deckName || 'Imported Deck',
commander: deckData.commander || '',
};
// If a game is in progress, import into current player's slot
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
var playerIdx = gameState.currentPlayerIndex || 0;
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
if (typeof renderHandArea === 'function') renderHandArea();
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof addLogEntry === 'function') {
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
}
});
} else if (typeof addLogEntry === 'function') {
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
}
// Close the deck builder panel
toggleDeckBuilderPanel();
});
</script>
</body>
</html>
+17 -3
View File
@@ -214,6 +214,7 @@ async function _fetchScryfallBatch(names) {
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
cmc: card.cmc || 0,
power: card.power || '',
toughness: card.toughness || '',
};
@@ -221,7 +222,7 @@ async function _fetchScryfallBatch(names) {
// Mark not-found cards so we don't re-fetch them on next enrichment
for (const name of names) {
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', power: '', toughness: '' };
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
}
}
@@ -236,8 +237,15 @@ function _applyScryfallDataToAllCards() {
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag roles from oracle text so aiCategorizeHand works
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
@@ -246,11 +254,17 @@ function _applyScryfallDataToAllCards() {
for (const card of gameState.battlefieldCards) {
if (card.ownerIndex !== player.id) continue;
const data = _scryfallCache[card.name];
if (data && !card.imageUrl) {
card.imageUrl = data.imageUrl || '';
if (data) {
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
+32
View File
@@ -3541,3 +3541,35 @@ kbd {
border-color: rgba(255,255,255,0.2);
color: var(--color-text-muted);
}
/* ── Fullscreen Panel (Deck Builder / Deck Generator) ──── */
.fullscreen-panel {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9000;
background: #0a0a14;
display: flex;
flex-direction: column;
}
.fullscreen-panel.hidden {
display: none;
}
.fullscreen-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
background: rgba(20,20,35,0.95);
border-bottom: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.8);
font-size: 13px;
font-weight: 600;
}
.fullscreen-panel-header .btn-util {
font-size: 18px;
line-height: 1;
}
+56 -14
View File
@@ -150,23 +150,13 @@ function updateZoneCountsForPlayer(playerIdx) {
const libCount = document.getElementById('lib-count-' + pNum);
if (libCount) libCount.textContent = player.zones.library.length;
// Update exile count
const exCount = document.getElementById('exile-count-' + pNum);
if (exCount) {
exCount.textContent = player.zones.exile.length;
exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none';
}
// Update graveyard count
const gvCount = document.getElementById('gv-count-' + pNum);
if (gvCount) {
gvCount.textContent = player.zones.graveyard.length;
gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none';
}
// Update hand count
const handCount = document.getElementById('hand-count-' + pNum);
if (handCount) handCount.textContent = player.zones.hand.length;
// Exile + Graveyard: render last card image face-up
renderLastCardInZone(playerIdx, 'exile');
renderLastCardInZone(playerIdx, 'graveyard');
}
function updatePlayerZones() {
@@ -217,6 +207,50 @@ function renderCommanderZone(playerIdx) {
}
}
// ============================================================
// EXILE / GRAVEYARD — Show last card image face-up
// ============================================================
function renderLastCardInZone(playerIdx, zoneName) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
const zoneIdPrefix = (zoneName === 'exile') ? 'exile-zone-' : 'gv-zone-';
const zoneEl = document.getElementById(zoneIdPrefix + pNum);
if (!zoneEl) return;
const cards = player.zones[zoneName] || [];
const lastCard = cards[cards.length - 1]; // most recently added card
if (lastCard && lastCard.imageUrl) {
const safeImg = (lastCard.imageUrl || '').replace(/'/g, "\\'");
const safeName = (lastCard.name || '').replace(/'/g, "\\'");
const safeType = (lastCard.typeLine || '').replace(/'/g, "\\'");
zoneEl.innerHTML =
'<img src="' + safeImg + '" alt="' + (lastCard.name || zoneName) + '" ' +
'style="width:100%;height:100%;object-fit:cover;border-radius:6px;" ' +
'onmouseenter="showFloatingCardPreview(\'' + safeImg + '\', event, {name:\'' + safeName + '\', typeLine:\'' + safeType + '\'})" ' +
'onmousemove="positionFloatingPreview(event)" ' +
'onmouseleave="hideFloatingCardPreview()" ' +
'draggable="false" />';
// Always show count badge on top
if (cards.length > 0) {
zoneEl.innerHTML += '<span class="card-count" id="' + (zoneName === 'exile' ? 'exile' : 'gv') + '-count-' + pNum + '" ' +
'style="display:block;">' + cards.length + '</span>';
}
} else {
// Fallback: icon + name + count (original look)
const icon = (zoneName === 'exile') ? '&#x2B21;' : '&#x2620;';
const label = (zoneName === 'exile') ? 'Exile' : 'Grave';
const countId = (zoneName === 'exile') ? 'exile-count-' : 'gv-count-';
zoneEl.innerHTML =
'<span class="card-count" id="' + countId + pNum + '" style="' + (cards.length > 0 ? 'display:block;' : 'display:none;') + '">' + cards.length + '</span>' +
'<span class="zone-icon">' + icon + '</span>' +
'<span class="zone-name">' + label + '</span>';
}
}
// ============================================================
// HAND ZONE RENDERING (thumbnails in zone.hand)
// ============================================================
@@ -1055,6 +1089,8 @@ function initSetupScreen() {
'<button class="ai-diff-btn active" onclick="window._setAiDiff(this,\'easy\')" data-diff="easy" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:rgba(91,141,217,0.2);color:rgba(255,255,255,0.7);cursor:pointer;">Easy</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'normal\')" data-diff="normal" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Normal</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'hard\')" data-diff="hard" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Hard</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'aggro\')" data-diff="aggro" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Aggro</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'combo\')" data-diff="combo" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Combo</button>' +
'</div>' +
'</div>' +
@@ -1075,6 +1111,12 @@ function initSetupScreen() {
'<div id="deck-assignment-grid" class="deck-assignment-grid"></div>' +
'</div>' +
'<div class="setup-section" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;">' +
'<button class="setup-tool-btn" onclick="toggleDeckBuilderPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F0CF; Deck Builder</button>' +
'<button class="setup-tool-btn" onclick="toggleDeckGenPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x26A1; Auto Gen</button>' +
'<button class="setup-tool-btn" onclick="toggleCollectionPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F4DA; Collection</button>' +
'</div>' +
'<button class="btn-start" onclick="startGame()">Begin Battle</button>' +
'</div>';