/** * 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) => '' + escHtml(name) + '×' + '' ).join(''); container.querySelectorAll('.dg-chip-x').forEach(el => { el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10))); }); } const $ = (id) => document.getElementById(id); // ── Init ──────────────────────────────────────────────── async function init() { await checkV3Status(); bindEvents(); checkQueryParams(); } async function checkV3Status() { try { const resp = await fetch(API_BASE + '/api/deck/v3/status'); const data = await resp.json(); state.v3Ready = data.initialized; if (!state.v3Ready) { // Warn but do not block — local/Ollama mode may still work console.warn('V3 status: not initialized. Error:', data.error || 'unknown'); toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning'); } } catch (e) { console.warn('V3 status check failed:', e); } } function bindEvents() { // Commander search const input = $('dg-commander-input'); input.addEventListener('input', () => { clearTimeout(searchTimeout); const q = input.value.trim(); if (q.length < 2) { hideDropdown(); return; } searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS); }); input.addEventListener('focus', () => { const q = input.value.trim(); if (q.length >= 2) searchCommander(q); }); document.addEventListener('click', (e) => { const wrap = document.querySelector('.dg-commander-search-wrap'); if (wrap && !wrap.contains(e.target)) hideDropdown(); }); $('dg-commander-clear').addEventListener('click', clearCommander); // Bracket buttons document.querySelectorAll('.dg-bracket-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active')); btn.classList.add('dg-bracket-active'); state.targetBracket = parseInt(btn.dataset.bracket, 10); }); }); // Generate & save $('dg-preview-btn').addEventListener('click', generateDeck); $('dg-save-btn').addEventListener('click', commitDeck); $('dg-regenerate-btn').addEventListener('click', generateDeck); // Export buttons $('dg-export-csv').addEventListener('click', () => exportDeck('csv')); $('dg-export-dck').addEventListener('click', () => exportDeck('dck')); $('dg-export-moxfield').addEventListener('click', () => exportMoxfield()); $('dg-export-shopping').addEventListener('click', () => exportShopping()); // Omit cards chips input const omitInput = $('dg-omit-input'); if (omitInput) { omitInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addOmitCard(omitInput.value); omitInput.value = ''; } }); } const omitWrap = $('dg-omit-wrap'); if (omitWrap) { omitWrap.addEventListener('click', () => omitInput && omitInput.focus()); } } function checkQueryParams() { const params = new URLSearchParams(window.location.search); const name = params.get('commander'); if (name) { $('dg-commander-input').value = name; searchCommander(name); } } // ── Build Request Body ────────────────────────────────── function buildRequestBody() { const budget = parseFloat($('dg-budget').value) || null; return { commander_name: state.commander.name, strategy: $('dg-strategy').value.trim(), target_bracket: state.targetBracket, budget_usd: budget, budget_mode: $('dg-budget-mode').value, omit_cards: omitCards.slice(), use_collection: $('dg-use-collection').checked, run_substitution: $('dg-substitution').checked, model: $('dg-model').value, deck_name: $('dg-deck-name').value.trim() || '', }; } // ── Commander Search ──────────────────────────────────── function positionDropdown() { const input = $('dg-commander-input'); const dropdown = $('dg-commander-dropdown'); const rect = input.getBoundingClientRect(); dropdown.style.top = (rect.bottom + 4) + 'px'; dropdown.style.left = rect.left + 'px'; dropdown.style.width = rect.width + 'px'; } async function searchCommander(q) { const dropdown = $('dg-commander-dropdown'); dropdown.innerHTML = '
Searching...
'; 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 = '
Search failed
'; } } function renderDropdown(results) { const dropdown = $('dg-commander-dropdown'); if (results.length === 0) { dropdown.innerHTML = '
No legendary creatures found
'; return; } dropdown.innerHTML = results.map((r, idx) => { const colors = (r.color_identity || []) .map(c => COLOR_MAP[c] ? '' + COLOR_MAP[c].label + '' : '') .join(''); const badge = r.in_collection ? 'Owned' : 'Scryfall'; const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : ''; const imgTag = imgSrc ? '' : '
'; return '
' + imgTag + '
' + '
' + escHtml(r.name) + '
' + '
' + escHtml(r.type_line || '') + '
' + '
' + '
' + colors + '
' + badge + '
'; }).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] ? '' + COLOR_MAP[c].label + '' : '') .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 = '[' + ts + '] ' + msg; log.appendChild(entry); log.scrollTop = log.scrollHeight; } function clearLog() { const log = $('dg-conversation-log'); if (!log) return; log.innerHTML = ''; } // ── Generate Deck ─────────────────────────────────────── async function generateDeck() { clearLog(); logMessage('Connecting to Claude AI...', 'info'); if (!state.commander) { toast('Select a commander first', 'error'); return; } // Non-blocking warning only — local Ollama mode does not require v3Ready if (!state.v3Ready) { logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning'); } logMessage('Building request body with commander: ' + state.commander.name + '', '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: ' + cardCount + ' cards | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success'); stopProgress(true); renderResults(state.previewResult); toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success'); } catch (e) { stopProgress(false); toast(e.message || 'Generation failed', 'error'); logMessage('Error: ' + (e.message || 'Unknown error'), 'error'); setLoading(false); } } function setLoading(loading, text) { state.isLoading = loading; $('dg-empty-state').style.display = 'none'; $('dg-loading').style.display = loading ? 'flex' : 'none'; $('dg-results').style.display = loading ? 'none' : 'block'; $('dg-preview-btn').disabled = loading || !state.commander; if (text) $('dg-loading-text').textContent = text; } // ── Render Results ────────────────────────────────────── function renderResults(result) { $('dg-loading').style.display = 'none'; $('dg-results').style.display = 'block'; $('dg-preview-btn').disabled = false; renderStrategyBar(result); renderStatsBar(result); renderSubstitutionSummary(result); renderCardGroups(result); renderUseInGameButton(result); } function renderUseInGameButton(result) { // Only show when running inside the LAN client iframe if (window.parent === window) return; var existing = document.getElementById('dg-export-game-btn'); if (existing) existing.remove(); var btn = document.createElement('button'); btn.id = 'dg-export-game-btn'; btn.className = 'dg-btn dg-btn-preview'; btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);'; btn.innerHTML = '🎮 Use in Game'; btn.addEventListener('click', function() { var cards = (result.cards || []).map(function(c) { return { name: c.name, quantity: c.count || 1, is_commander: false }; }); if (result.commander) { cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true }); } window.parent.postMessage({ type: 'DECK_BUILDER_EXPORT', deck: cards, deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck', commander: result.commander ? result.commander.name : '', }, '*'); toast('Deck sent to game setup!', 'success'); }); $('dg-results').appendChild(btn); } function renderStrategyBar(result) { const bar = $('dg-strategy-bar'); if (!bar) return; const bracket = result.bracket || {}; const archetype = result.archetype || ''; const strategy = result.strategy_summary || ''; bar.innerHTML = '
' + 'B' + (bracket.level || '?') + '' + '' + escHtml(archetype) + '' + '' + escHtml(strategy) + '' + '
' + (bracket.reasoning ? '
' + escHtml(bracket.reasoning) + '
' : '') + (bracket.game_changers && bracket.game_changers.length ? '
Game Changers: ' + bracket.game_changers.map(c => '' + escHtml(c) + '').join('') + '
' : ''); } function renderStatsBar(result) { const stats = result.stats || {}; const container = $('dg-stats-bar'); if (!container) return; const chips = [ chipHtml('Total', stats.total_cards || 0, ''), chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'), chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'), chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'), chipHtml('Lands', stats.land_count || 0, ''), chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'), ]; container.innerHTML = chips.join(''); } function chipHtml(label, num, cls) { return '
' + '' + num + '' + '' + label + '' + '
'; } 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 = '
' + '' + sub.owned + ' owned' + '' + sub.substituted + ' substituted' + '' + sub.missing + ' still missing' + '
'; } function renderCardGroups(result) { const container = $('dg-card-groups'); if (!container) return; const cards = result.cards || []; const commander = result.commander; const groups = {}; for (const card of cards) { const cat = card.category || 'Other'; if (!groups[cat]) groups[cat] = []; groups[cat].push(card); } const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0); for (const t of Object.keys(groups)) { if (!sortedTypes.includes(t)) sortedTypes.push(t); } let html = ''; if (commander) { html += '
' + '
' + '⚔ Commander' + '1' + '
' + '
' + '
' + '' + escHtml(commander.name) + '' + 'Commander' + '
' + '
'; } 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 += '
' + '
' + '' + icon + ' ' + escHtml(type) + '' + '' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '' + '
' + '
'; for (const card of groupCards) { html += renderCardRow(card); } html += '
'; } 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 = '' + statusInfo.label + ''; const roleTags = (card.role_tags || []).slice(0, 3) .map(r => '' + escHtml(r) + '') .join(''); let subHtml = ''; if (card.status === 'substituted' && card.selected_substitute) { subHtml = '
' + '→ ' + '' + escHtml(card.selected_substitute) + '' + '
'; } else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) { subHtml = '
' + 'Alternatives: ' + card.alternatives.slice(0, 3).map(a => '' + escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)' + '' ).join('') + '
'; } const price = card.estimated_price_usd ? '$' + card.estimated_price_usd.toFixed(2) + '' : ''; return '
' + '
' + '' + ((card.count || 1) > 1 ? '' + card.count + 'x ' : '') + escHtml(card.name) + '' + '' + roleTags + '' + statusBadge + price + '
' + (card.reason ? '
' + escHtml(card.reason) + '
' : '') + subHtml + '
'; } // ── 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 = '💾 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, '>'); } return { init }; })(); document.addEventListener('DOMContentLoaded', () => { DeckGenerator.init(); });