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:
1 parent
30c3ab5a89
commit
409aba26f4
18 files changed
+16726
-36
No files matched your search
+16
-7
@@ -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
@@ -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
@@ -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>📤</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>🎮</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
@@ -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">×</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 & 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">⚡</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">⚡</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>
|
||||
@@ -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 + '">×</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">🎮</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, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
DeckGenerator.init();
|
||||
});
|
||||
+83
-8
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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">⚙</button>
|
||||
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</button>
|
||||
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">🃏</button>
|
||||
<button class="btn-util" id="btn-deckgen-toggle" onclick="toggleDeckGenPanel()" title="Auto Deck Generator">⚡</button>
|
||||
<button class="btn-util" id="btn-collection-toggle" onclick="toggleCollectionPanel()" title="Collection">📚</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">×</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">×</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">×</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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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') ? '⬡' : '☠';
|
||||
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;">🃏 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;">⚡ 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;">📚 Collection</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<button class="btn-start" onclick="startGame()">Begin Battle</button>' +
|
||||
'</div>';
|
||||
|
||||
|
||||
@@ -141,8 +141,17 @@ class CardEmbeddingIndex:
|
||||
logger.error("Auto-download failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_embedding(self, card_name: str) -> np.ndarray:
|
||||
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
||||
def get_embedding(self, card_name) -> np.ndarray:
|
||||
"""Get embedding vector for a card name. Returns zero vector if not found.
|
||||
|
||||
Accepts either a plain string or a dict with a 'name' key (defensive
|
||||
against the LAN client sending structured battlefield objects).
|
||||
"""
|
||||
# Handle dict objects from the LAN client's battlefield_detail
|
||||
if isinstance(card_name, dict):
|
||||
card_name = card_name.get('name', '')
|
||||
if not card_name or not isinstance(card_name, str):
|
||||
return self._zero_vec
|
||||
if not self._loaded:
|
||||
return self._zero_vec
|
||||
idx = self._name_to_idx.get(card_name.lower())
|
||||
@@ -155,8 +164,11 @@ class CardEmbeddingIndex:
|
||||
return self._embeddings[idx]
|
||||
return self._zero_vec
|
||||
|
||||
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
|
||||
"""Mean-pool embeddings for a list of card names (a game zone)."""
|
||||
def mean_pool_zone(self, card_names: list) -> np.ndarray:
|
||||
"""Mean-pool embeddings for a list of card names (a game zone).
|
||||
|
||||
Each entry may be a plain string or a dict with a 'name' key.
|
||||
"""
|
||||
if not card_names:
|
||||
return self._zero_vec
|
||||
vectors = [self.get_embedding(name) for name in card_names]
|
||||
|
||||
@@ -245,6 +245,20 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
|
||||
).fetchone()
|
||||
card_name = ce_row["name"] if ce_row else (req.card_name or "")
|
||||
|
||||
# Ensure card_records has type_line for this card (Scryfall lookup if missing)
|
||||
if card_name:
|
||||
existing_cr = conn.execute(
|
||||
"SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,)
|
||||
).fetchone()
|
||||
if not existing_cr or not existing_cr["type_line"]:
|
||||
enriched = _enrich_from_scryfall(card_name)
|
||||
if enriched and enriched.get("type_line"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)",
|
||||
(card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0))
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(deck_id, req.scryfall_id)
|
||||
|
||||
Reference in new issue
Block a user