diff --git a/ui/ai-lab.js b/ui/ai-lab.js index 2d85efa..bb0c9b5 100644 --- a/ui/ai-lab.js +++ b/ui/ai-lab.js @@ -4,20 +4,2590 @@ * * v3 adds: * - URL import (Archidekt/EDHREC) — paste a URL to import a deck - * - Commander meta picker with real Scryfall art - * - Precon browser with install & batch install - * - Deck list with delete / set-as-active / quick-sim - * - Import status toasts - * - Lab status bar (Forge ready/busy) + * - Commander meta picker — browse/search 15 built-in commanders, one-click EDHREC fetch + * - Text import modal — paste a card list directly + * - Source badges on result cards — shows where each deck came from + * - Imported deck info in deck selectors * - * Depends on: lab_api.py (/api/lab/…) + * Endpoints consumed: + * GET /api/lab/decks — List available Commander decks + * POST /api/lab/start — Start a batch run + * GET /api/lab/status — Poll progress + * GET /api/lab/result — Get completed results JSON + * GET /api/lab/log — Get live log output + * GET /api/lab/history — List past batch runs + * POST /api/lab/import/url — Import deck from Archidekt/EDHREC URL + * POST /api/lab/import/text — Import deck from card list text + * GET /api/lab/meta/commanders — List available commanders in meta mapping + * GET /api/lab/meta/search — Search commanders by name + * POST /api/lab/meta/fetch — Fetch EDHREC average deck for a commander */ -(function () { - 'use strict'; +const AiLab = (() => { + 'use strict'; - // NOTE: Full file content is in the patched file on disk. - // This commit was made via MCP tool - the actual full patched content - // needs to be applied locally via git pull + manual patch. - // See patch details in commit message. -} \ No newline at end of file + // ── State ────────────────────────────────────────────── + let isRunning = false; + let selectedDecks = [null, null, null, null]; + let lastResult = null; + let pollInterval = null; + let logInterval = null; + let backendAvailable = false; + let availableDecks = []; + let metaCommanders = []; + let preconDecks = []; // Loaded from /api/lab/precons + let importedDeckMeta = {}; // deckName → {source, sourceUrl, commander, archetype, colorIdentity} + + // ── Configuration ────────────────────────────────────── + const API_BASE = window.location.origin; + const PRESET_GAMES = [10, 100, 1000]; + const POLL_RATE_MS = 750; + const LOG_RATE_MS = 1000; + + const COLOR_MAP = { + W: { label: 'W', bg: '#f9faf4', color: '#3d3929' }, + U: { label: 'U', bg: '#0e67ab', color: '#fff' }, + B: { label: 'B', bg: '#2b2424', color: '#ccc' }, + R: { label: 'R', bg: '#d3202a', color: '#fff' }, + G: { label: 'G', bg: '#00733e', color: '#fff' }, + }; + + // ── Initialization ───────────────────────────────────── + + async function init() { + renderPanel(); + await detectBackend(); + await Promise.all([ + loadAvailableDecks(), + loadMetaCommanders(), + loadPrecons(), + ]); + bindEvents(); + console.log('[AI Lab] v3 Initialized — backend:', backendAvailable ? 'connected' : 'demo mode'); + } + + async function detectBackend() { + try { + const res = await fetch(`${API_BASE}/api/lab/decks`, { signal: AbortSignal.timeout(3000) }); + backendAvailable = res.ok; + } catch { + backendAvailable = false; + } + updateBackendIndicator(); + } + + // ── Render ───────────────────────────────────────────── + + function renderPanel() { + const container = document.getElementById('ai-lab-container'); + if (!container) return; + + container.innerHTML = ` +
+
+

Commander AI Lab

+
+ + Checking... +
+
+

Headless 3-AI batch simulation — stress-test your decks at scale

+ + +
+
Import Deck
+
+ + + + +
+
+ + +
+
+ Import from URL + +
+
+ + +
+
+ Supported: archidekt.com/decks/... · edhrec.com/average-decks/... · edhrec.com/commanders/... +
+
+
+ + +
+ ${[0, 1, 2, 3].map(i => ` +
+
Seat ${i + 1}
+ +
+
+
+ `).join('')} +
+ + +
+
+ + +
+ ${PRESET_GAMES.map(n => ` + + `).join('')} +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+ 0 / 0 games + +
+
+ + +
+
+ Live Output + +
+

+            
+ + +
+
+

Results

+
+ + +
+
+
+
+ + +
+ + +
+ + + + + + +
+ + +
+
+
+

Win Rate Comparison

+ +
+
+

Win Conditions

+ +
+
+

Turn Distribution

+ +
+
+

Win Rate Over Time

+ +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+

Select decks and run a batch to see per-deck analytics.

+
+
+ + + +
+ + +
+
+
+

Batch History

+ +
+
Loading...
+
+
+ + +
+
+
+

Commander Meta — EDHREC Average Decks

+ +
+
+ +
+
Loading...
+
+
+
+ + +
+
+
+

Precon Decks — Official Commander Precons

+ +
+
+ + + +
+
Loading...
+
+
+
+ + +
+
+
+

Import from Card List

+ +
+
+
+ Paste a card list (one card per line). Use 1 Card Name or just Card Name. + Add a [Commander] section header above the commander, or enter it below. +
+ +
+ + +
+ +
+
+
+
+
+ `; + } + + function updateBackendIndicator() { + const el = document.getElementById('lab-backend-status'); + if (!el) return; + if (backendAvailable) { + el.classList.add('connected'); + el.classList.remove('disconnected'); + el.querySelector('.status-text').textContent = 'Backend Connected'; + } else { + el.classList.add('disconnected'); + el.classList.remove('connected'); + el.querySelector('.status-text').textContent = 'Demo Mode'; + } + } + + // ── Deck Management ──────────────────────────────────── + + async function loadAvailableDecks() { + let decks = []; + + if (backendAvailable) { + try { + const res = await fetch(`${API_BASE}/api/lab/decks`); + if (res.ok) { + const data = await res.json(); + decks = (data.decks || []).map(d => ({ + name: d.name, + commander: d.commander || d.name.replace(/_/g, ' '), + })); + } + } catch (err) { + console.warn('[AI Lab] Failed to load decks from backend:', err); + } + } + + // Fallback to sample decks if backend returned nothing + if (decks.length === 0) { + decks = getSampleDecks(); + } + + availableDecks = decks; + refreshDeckSelectors(); + } + + function refreshDeckSelectors() { + for (let seat = 0; seat < 4; seat++) { + const select = document.getElementById(`lab-deck-select-${seat}`); + if (!select) continue; + const prev = select.value; + select.innerHTML = ''; + + // Group: imported decks first + const imported = availableDecks.filter(d => importedDeckMeta[d.name]); + const local = availableDecks.filter(d => !importedDeckMeta[d.name]); + + if (imported.length > 0) { + const group = document.createElement('optgroup'); + group.label = 'Imported Decks'; + imported.forEach(deck => { + const opt = document.createElement('option'); + opt.value = deck.name; + const meta = importedDeckMeta[deck.name]; + const srcTag = meta ? ` [${meta.source}]` : ''; + opt.textContent = deck.name + srcTag; + opt.dataset.commander = deck.commander || ''; + group.appendChild(opt); + }); + select.appendChild(group); + } + + if (local.length > 0) { + const group = document.createElement('optgroup'); + group.label = 'Local Decks'; + local.forEach(deck => { + const opt = document.createElement('option'); + opt.value = deck.name; + opt.textContent = deck.name; + opt.dataset.commander = deck.commander || ''; + group.appendChild(opt); + }); + select.appendChild(group); + } + + // Restore previous selection + if (prev) { + select.value = prev; + } + } + } + + function getSampleDecks() { + return [ + { name: 'Atraxa_Superfriends', commander: "Atraxa, Praetors' Voice" }, + { name: 'Korvold_Aristocrats', commander: 'Korvold, Fae-Cursed King' }, + { name: 'Muldrotha_Value', commander: 'Muldrotha, the Gravetide' }, + { name: 'Angels', commander: 'Angels' }, + { name: 'Edgar Markov', commander: 'Edgar Markov' }, + { name: 'Grimgrin', commander: 'Grimgrin, Corpse-Born' }, + { name: 'Xyris', commander: 'Xyris, the Writhing Storm' }, + { name: 'Control-Talrand', commander: 'Talrand, Sky Summoner' }, + { name: 'Ashling 2.0', commander: 'Ashling the Pilgrim' }, + ]; + } + + function selectDeck(seat, deckName) { + selectedDecks[seat] = deckName || null; + + const slot = document.getElementById(`lab-slot-${seat}`); + const cmdEl = document.getElementById(`lab-commander-${seat}`); + const select = document.getElementById(`lab-deck-select-${seat}`); + const badgeEl = document.getElementById(`lab-source-badge-${seat}`); + + if (deckName) { + slot.classList.add('filled'); + const opt = select.selectedOptions[0]; + cmdEl.textContent = opt?.dataset.commander || ''; + + // Show source badge if imported + const meta = importedDeckMeta[deckName]; + if (meta && badgeEl) { + badgeEl.innerHTML = renderSourceBadge(meta.source, meta.sourceUrl); + badgeEl.style.display = 'block'; + } else if (badgeEl) { + badgeEl.innerHTML = ''; + badgeEl.style.display = 'none'; + } + } else { + slot.classList.remove('filled'); + cmdEl.textContent = ''; + if (badgeEl) { + badgeEl.innerHTML = ''; + badgeEl.style.display = 'none'; + } + } + } + + function renderSourceBadge(source, sourceUrl) { + const sourceLabels = { + 'Archidekt': { icon: '🏗️', cls: 'badge-archidekt' }, + 'EDHREC Average': { icon: '📊', cls: 'badge-edhrec' }, + 'Text Import': { icon: '📋', cls: 'badge-text' }, + 'Precon': { icon: '🎴', cls: 'badge-precon' }, + }; + const info = sourceLabels[source] || { icon: '📦', cls: 'badge-default' }; + const linkHtml = sourceUrl + ? `${source}` + : `${source}`; + return `${info.icon} ${linkHtml}`; + } + + // ── v3: URL Import ──────────────────────────────────── + + function showUrlImport() { + const panel = document.getElementById('lab-url-import'); + if (panel) { + panel.classList.toggle('active'); + if (panel.classList.contains('active')) { + document.getElementById('lab-import-url-input')?.focus(); + } + } + } + + function hideUrlImport() { + const panel = document.getElementById('lab-url-import'); + if (panel) panel.classList.remove('active'); + } + + async function importFromUrl() { + const input = document.getElementById('lab-import-url-input'); + const statusEl = document.getElementById('lab-url-import-status'); + const btn = document.getElementById('lab-url-import-btn'); + const url = input?.value.trim(); + + if (!url) { + showImportStatus(statusEl, 'Please enter a URL.', 'error'); + return; + } + + if (!backendAvailable) { + showImportStatus(statusEl, 'URL import requires a running backend.', 'error'); + return; + } + + btn.disabled = true; + btn.textContent = 'Importing...'; + showImportStatus(statusEl, 'Fetching deck from URL...', 'loading'); + + try { + const res = await fetch(`${API_BASE}/api/lab/import/url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Import failed'); + } + + // Track imported deck metadata + importedDeckMeta[data.dckFile] = { + source: data.source, + sourceUrl: data.sourceUrl, + commander: data.commander, + archetype: data.archetype, + colorIdentity: data.colorIdentity || [], + }; + + // Add to available decks if not already present + if (!availableDecks.find(d => d.name === data.dckFile)) { + availableDecks.unshift({ + name: data.dckFile, + commander: data.commander, + }); + refreshDeckSelectors(); + } + + showImportStatus(statusEl, + `Imported "${data.deckName}" (${data.totalCards} cards) from ${data.source}`, + 'success' + ); + input.value = ''; + + } catch (err) { + showImportStatus(statusEl, `Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Import'; + } + } + + // ── v3: Commander Meta Picker ───────────────────────── + + async function loadMetaCommanders() { + if (!backendAvailable) return; + try { + const res = await fetch(`${API_BASE}/api/lab/meta/commanders`); + if (res.ok) { + const data = await res.json(); + metaCommanders = data.commanders || []; + } + } catch (err) { + console.warn('[AI Lab] Failed to load meta commanders:', err); + } + } + + async function showMetaPicker() { + const modal = document.getElementById('lab-meta-modal'); + if (!modal) return; + modal.classList.add('active'); + + if (!backendAvailable) { + const listEl = document.getElementById('lab-meta-list'); + if (listEl) { + listEl.innerHTML = '
' + + '

⚠️ Backend not connected.

' + + '

Commander Meta requires a running backend to browse and fetch EDHREC average decks.

' + + '
'; + } + return; + } + + // Lazy-load: if we haven't fetched commanders yet, do it now + if (metaCommanders.length === 0) { + const listEl = document.getElementById('lab-meta-list'); + if (listEl) listEl.innerHTML = '
Loading commanders...
'; + await loadMetaCommanders(); + } + + renderMetaList(metaCommanders); + document.getElementById('lab-meta-search')?.focus(); + } + + function closeMetaPicker() { + const modal = document.getElementById('lab-meta-modal'); + if (modal) modal.classList.remove('active'); + const statusEl = document.getElementById('lab-meta-import-status'); + if (statusEl) statusEl.innerHTML = ''; + } + + async function filterMetaCommanders(query) { + if (!query) { + renderMetaList(metaCommanders); + return; + } + + // When backend is available, use the live search endpoint + if (backendAvailable) { + try { + const res = await fetch(`${API_BASE}/api/lab/meta/search?q=${encodeURIComponent(query)}`); + if (res.ok) { + const data = await res.json(); + renderMetaList(data.results || []); + return; + } + } catch (err) { + console.warn('[AI Lab] Meta search failed, falling back to local filter:', err); + } + } + + // Fallback: local filter + const q = query.toLowerCase(); + const filtered = metaCommanders.filter(c => c.name.toLowerCase().includes(q)); + renderMetaList(filtered); + } + + function renderMetaList(commanders) { + const listEl = document.getElementById('lab-meta-list'); + if (!listEl) return; + + if (commanders.length === 0) { + listEl.innerHTML = '
No commanders found.
'; + return; + } + + listEl.innerHTML = commanders.map(c => { + const colors = (c.colorIdentity || []).map(col => { + const ci = COLOR_MAP[col]; + return ci ? `${ci.label}` : ''; + }).join(''); + + const archBadge = c.archetype + ? `${c.archetype}` + : ''; + + return ` +
+
+
${escapeHtml(c.name)}
+
${colors} ${archBadge}
+
+
+ +
+
`; + }).join(''); + } + + async function fetchMetaDeck(commanderName) { + const statusEl = document.getElementById('lab-meta-import-status'); + + if (!backendAvailable) { + showImportStatus(statusEl, 'Meta fetch requires a running backend.', 'error'); + return; + } + + showImportStatus(statusEl, `Fetching EDHREC average deck for ${commanderName}...`, 'loading'); + + try { + const res = await fetch(`${API_BASE}/api/lab/meta/fetch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ commander: commanderName }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Fetch failed'); + } + + importedDeckMeta[data.dckFile] = { + source: 'EDHREC Average', + sourceUrl: data.sourceUrl, + commander: data.commander, + colorIdentity: data.colorIdentity || [], + sampleSize: data.sampleSize, + }; + + if (!availableDecks.find(d => d.name === data.dckFile)) { + availableDecks.unshift({ + name: data.dckFile, + commander: data.commander, + }); + refreshDeckSelectors(); + } + + const sampleInfo = data.sampleSize ? ` (${data.sampleSize} decks sampled)` : ''; + showImportStatus(statusEl, + `Imported "${data.deckName}" (${data.totalCards} cards)${sampleInfo}`, + 'success' + ); + + } catch (err) { + showImportStatus(statusEl, `Error: ${err.message}`, 'error'); + } + } + + // ── v3: Precon Decks ────────────────────────────────── + + async function loadPrecons() { + if (!backendAvailable) return; + try { + const res = await fetch(`${API_BASE}/api/lab/precons`); + if (res.ok) { + const data = await res.json(); + preconDecks = data.precons || []; + } + } catch (err) { + console.warn('[AI Lab] Failed to load precons:', err); + } + } + + function showPreconPicker() { + const modal = document.getElementById('lab-precon-modal'); + if (modal) { + modal.classList.add('active'); + renderPreconGrid(preconDecks); + document.getElementById('lab-precon-search')?.focus(); + } + } + + function closePreconPicker() { + const modal = document.getElementById('lab-precon-modal'); + if (modal) modal.classList.remove('active'); + const statusEl = document.getElementById('lab-precon-status'); + if (statusEl) statusEl.innerHTML = ''; + } + + function filterPrecons(query) { + if (!query) { + renderPreconGrid(preconDecks); + return; + } + const q = query.toLowerCase(); + const filtered = preconDecks.filter(p => + p.name.toLowerCase().includes(q) || + p.commander.toLowerCase().includes(q) || + p.set.toLowerCase().includes(q) || + (p.theme && p.theme.toLowerCase().includes(q)) + ); + renderPreconGrid(filtered); + } + + function renderPreconGrid(precons) { + const gridEl = document.getElementById('lab-precon-grid'); + if (!gridEl) return; + + if (precons.length === 0) { + gridEl.innerHTML = '
No precon decks found.
'; + return; + } + + gridEl.innerHTML = precons.map(p => { + const colors = (p.colors || []).map(col => { + if (col === 'C') return 'C'; + const ci = COLOR_MAP[col]; + return ci ? `${ci.label}` : ''; + }).join(''); + + const isInstalled = availableDecks.some(d => + d.name === p.fileName.replace('.dck', '') || d.name === p.name.replace(/ /g, '_') + ); + const installLabel = isInstalled ? 'Installed' : 'Install & Use'; + const installClass = isInstalled ? 'lab-precon-card-installed' : ''; + + // Commander art from Scryfall — use imageUrl if backend provided it, + // otherwise build a fuzzy lookup URL from the commander name + const scryfallFallback = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`; + const artUrl = p.imageUrl || scryfallFallback; + + return ` +
+ ${escapeAttr(p.commander)} +
+
${colors}
+
${escapeHtml(p.name)}
+
${escapeHtml(p.commander)}
+
${escapeHtml(p.set)} (${p.year})
+ ${p.theme ? `
${escapeHtml(p.theme)}
` : ''} + +
+
`; + }).join(''); + loadPreconArt(gridEl); + } + + async function loadPreconArt(gridEl) { + const imgs = gridEl.querySelectorAll('img.precon-card-art[data-commander]'); + for (const img of imgs) { + const commander = img.dataset.commander; + if (!commander) continue; + try { + const res = await fetch(`${API_BASE}/card-image/${encodeURIComponent(commander)}`); + if (res.ok) { + const data = await res.json(); + if (data.art_url) { img.src = data.art_url; img.style.display = ''; } + } + } catch (_) { /* keep Scryfall fallback */ } + } + } + + async function installPrecon(fileName) { + const statusEl = document.getElementById('lab-precon-status'); + + if (!backendAvailable) { + showImportStatus(statusEl, 'Precon install requires a running backend.', 'error'); + return; + } + + const precon = preconDecks.find(p => p.fileName === fileName); + const displayName = precon ? precon.name : fileName; + showImportStatus(statusEl, `Installing ${displayName}...`, 'loading'); + + try { + const res = await fetch(`${API_BASE}/api/lab/precons/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Install failed'); + } + + const deckName = data.deckName; + + importedDeckMeta[deckName] = { + source: 'Precon', + sourceUrl: null, + commander: precon ? precon.commander : null, + archetype: precon ? precon.theme : null, + colorIdentity: precon ? precon.colors : [], + }; + + if (!availableDecks.find(d => d.name === deckName)) { + availableDecks.unshift({ + name: deckName, + commander: precon ? precon.commander : deckName, + }); + refreshDeckSelectors(); + } + + // Auto-assign to first empty seat + const emptySeat = selectedDecks.indexOf(null); + if (emptySeat >= 0) { + const sel = document.getElementById(`lab-deck-select-${emptySeat}`); + if (sel) { + sel.value = deckName; + selectDeck(emptySeat, deckName); + } + } + + showImportStatus(statusEl, `Installed "${displayName}" \u2014 added to deck list`, 'success'); + renderPreconGrid(preconDecks); + + } catch (err) { + showImportStatus(statusEl, `Error: ${err.message}`, 'error'); + } + } + + async function installAllPrecons() { + const statusEl = document.getElementById('lab-precon-status'); + + if (!backendAvailable) { + showImportStatus(statusEl, 'Precon install requires a running backend.', 'error'); + return; + } + + showImportStatus(statusEl, `Installing all ${preconDecks.length} precon decks...`, 'loading'); + + try { + const fileNames = preconDecks.map(p => p.fileName); + const res = await fetch(`${API_BASE}/api/lab/precons/install-batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileNames }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Batch install failed'); + } + + let installed = 0; + for (const r of (data.results || [])) { + if (r.installed) { + installed++; + const deckName = r.deckName; + const precon = preconDecks.find(p => p.fileName === r.fileName); + + importedDeckMeta[deckName] = { + source: 'Precon', + sourceUrl: null, + commander: precon ? precon.commander : null, + archetype: precon ? precon.theme : null, + colorIdentity: precon ? precon.colors : [], + }; + + if (!availableDecks.find(d => d.name === deckName)) { + availableDecks.push({ + name: deckName, + commander: precon ? precon.commander : deckName, + }); + } + } + } + + refreshDeckSelectors(); + renderPreconGrid(preconDecks); + showImportStatus(statusEl, `Installed ${installed} of ${fileNames.length} precon decks`, 'success'); + + } catch (err) { + showImportStatus(statusEl, `Error: ${err.message}`, 'error'); + } + } + + // ── v3: Text Import ─────────────────────────────────── + + function showTextImport() { + const modal = document.getElementById('lab-text-modal'); + if (modal) { + modal.classList.add('active'); + document.getElementById('lab-text-import-area')?.focus(); + } + } + + function closeTextImport() { + const modal = document.getElementById('lab-text-modal'); + if (modal) modal.classList.remove('active'); + const statusEl = document.getElementById('lab-text-import-status'); + if (statusEl) statusEl.innerHTML = ''; + } + + async function importFromText() { + const textArea = document.getElementById('lab-text-import-area'); + const commanderInput = document.getElementById('lab-text-commander-override'); + const statusEl = document.getElementById('lab-text-import-status'); + + const text = textArea?.value.trim(); + const commander = commanderInput?.value.trim() || null; + + if (!text) { + showImportStatus(statusEl, 'Please paste a card list.', 'error'); + return; + } + + if (!backendAvailable) { + showImportStatus(statusEl, 'Text import requires a running backend.', 'error'); + return; + } + + showImportStatus(statusEl, 'Parsing card list...', 'loading'); + + try { + const res = await fetch(`${API_BASE}/api/lab/import/text`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, commander }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Import failed'); + } + + importedDeckMeta[data.dckFile] = { + source: 'Text Import', + sourceUrl: null, + commander: data.commander, + }; + + if (!availableDecks.find(d => d.name === data.dckFile)) { + availableDecks.unshift({ + name: data.dckFile, + commander: data.commander, + }); + refreshDeckSelectors(); + } + + showImportStatus(statusEl, + `Imported "${data.deckName}" (${data.totalCards} cards)`, + 'success' + ); + textArea.value = ''; + if (commanderInput) commanderInput.value = ''; + + } catch (err) { + showImportStatus(statusEl, `Error: ${err.message}`, 'error'); + } + } + + // ── Import Status Helper ────────────────────────────── + + function showImportStatus(el, message, type) { + if (!el) return; + el.className = 'lab-import-status'; + if (type) el.classList.add(`status-${type}`); + el.innerHTML = message; + el.style.display = 'block'; + + if (type === 'success') { + setTimeout(() => { + if (el.innerHTML === message) el.style.display = 'none'; + }, 8000); + } + } + + // ── Controls ─────────────────────────────────────────── + + function setGameCount(n) { + document.getElementById('lab-game-count').value = n; + document.querySelectorAll('.lab-preset-btn').forEach(btn => { + btn.classList.toggle('active', parseInt(btn.textContent) === n); + }); + } + + // ── Batch Execution ──────────────────────────────────── + + async function startBatch() { + const engine = document.getElementById('lab-engine').value; + + if (engine === 'deepseek') { + // DeepSeek: at least 1 deck required (not all 4) + const filledDecks = selectedDecks.filter(d => d); + if (filledDecks.length === 0) { + alert('Select at least 1 deck.'); + return; + } + + const numGames = parseInt(document.getElementById('lab-game-count').value) || 30; + + setRunning(true); + clearLog(); + showProgress(); + appendLog('[DeepSeek Engine] Starting batch with LLM opponent...'); + + await startDeepSeekBatch(filledDecks, numGames); + } else { + // Java Forge: requires all 4 decks + if (selectedDecks.some(d => !d)) { + alert('Select a deck for all 4 seats.'); + return; + } + + const numGames = parseInt(document.getElementById('lab-game-count').value) || 100; + const threads = parseInt(document.getElementById('lab-threads').value) || 4; + const seedInput = document.getElementById('lab-seed').value.trim(); + const seed = seedInput ? parseInt(seedInput) : null; + + const deckSources = selectedDecks.map(name => { + const meta = importedDeckMeta[name]; + return meta ? { + source: meta.source, + sourceUrl: meta.sourceUrl || null, + commander: meta.commander || null, + archetype: meta.archetype || null, + } : {}; + }); + + setRunning(true); + clearLog(); + showProgress(); + + if (backendAvailable) { + await startBackendBatch(numGames, threads, seed, deckSources); + } else { + console.warn('[AI Lab] No backend — running local simulation'); + runLocalSimulation(numGames, seed); + } + } + } + + async function startDeepSeekBatch(deckNames, numGames) { + try { + const res = await fetch(`${API_BASE}/api/lab/start-deepseek`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + decks: deckNames, + numGames, + }), + }); + + if (res.ok) { + const data = await res.json(); + appendLog(`DeepSeek batch ${data.batchId} started: ${numGames} games across ${deckNames.length} decks`); + startPolling(data.batchId); + startLogPolling(data.batchId); + } else { + const err = await res.text(); + appendLog(`ERROR: ${err}`); + setRunning(false); + } + } catch (err) { + appendLog(`ERROR: ${err.message}`); + setRunning(false); + } + } + + async function startBackendBatch(numGames, threads, seed, deckSources) { + try { + const res = await fetch(`${API_BASE}/api/lab/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + decks: selectedDecks, + numGames, + threads, + seed, + deckSources, + }), + }); + + if (res.ok) { + const data = await res.json(); + appendLog(`Batch ${data.batchId} started: ${numGames} games, ${threads} threads`); + startPolling(data.batchId); + startLogPolling(data.batchId); + } else { + const err = await res.text(); + appendLog(`ERROR: ${err}`); + setRunning(false); + } + } catch (err) { + appendLog(`Connection error: ${err.message}`); + console.warn('[AI Lab] Backend error, falling back to local sim:', err); + backendAvailable = false; + updateBackendIndicator(); + runLocalSimulation(numGames, null); + } + } + + /** + * Local simulation fallback — generates synthetic results + * for testing the UI without a running Forge backend. + */ + function runLocalSimulation(numGames, seed) { + const rng = seed !== null ? seededRandom(seed) : Math.random; + + const games = []; + let startTime = performance.now(); + let completed = 0; + + const batchInterval = setInterval(() => { + const chunk = Math.min(Math.ceil(numGames / 20), numGames - completed); + for (let i = 0; i < chunk && completed < numGames; i++) { + games.push(simulateOneGame(completed, rng)); + completed++; + } + updateProgress(completed, numGames, performance.now() - startTime); + appendLog(`[Game ${completed}/${numGames}] simulated locally`); + + if (completed >= numGames) { + clearInterval(batchInterval); + const elapsed = performance.now() - startTime; + finalizeBatch(games, elapsed, numGames, seed); + } + }, 50); + } + + function simulateOneGame(index, rng) { + const totalTurns = Math.floor(rng() * 20) + 5; + const winningSeat = rng() < 0.05 ? null : Math.floor(rng() * 4); + + const winConditions = ['combat_damage', 'commander_damage', 'combo_alt_win', 'life_drain']; + const winCondition = winningSeat !== null + ? winConditions[Math.floor(rng() * winConditions.length)] + : 'timeout'; + + const playerResults = [0, 1, 2, 3].map(seat => ({ + seatIndex: seat, + finalLife: seat === winningSeat ? Math.floor(rng() * 30) + 10 : Math.floor(rng() * 20) - 10, + mulligans: rng() < 0.3 ? 1 : 0, + isWinner: seat === winningSeat, + commanderDamageDealt: Math.floor(rng() * 15), + commanderCasts: Math.floor(rng() * 3), + landsPlayed: Math.floor(totalTurns * 0.6), + spellsCast: Math.floor(totalTurns * 1.5), + creaturesDestroyed: Math.floor(rng() * 5), + })); + + return { + gameIndex: index, + winningSeat, + totalTurns, + winCondition, + gameSeed: Math.floor(rng() * 1000000), + elapsedMs: Math.floor(rng() * 3000) + 500, + playerResults, + }; + } + + function finalizeBatch(games, elapsedMs, numGames, seed) { + const summary = computeLocalSummary(games, elapsedMs); + + lastResult = { + metadata: { + schemaVersion: '1.0.0', + batchId: crypto.randomUUID?.() || 'local-' + Date.now(), + timestamp: new Date().toISOString(), + totalGames: numGames, + completedGames: games.length, + format: 'commander', + podSize: 4, + engineVersion: backendAvailable ? 'forge-2.0.12-SNAPSHOT' : 'local-sim-1.0', + masterSeed: seed, + threads: 1, + elapsedMs: Math.round(elapsedMs), + }, + decks: selectedDecks.map((name, i) => { + const meta = importedDeckMeta[name]; + return { + seatIndex: i, + deckName: name.replace('.dck', ''), + commanderName: meta?.commander || name.replace('.dck', '').replace(/_/g, ' '), + deckFile: name, + colorIdentity: meta?.colorIdentity || [], + cardCount: 100, + source: meta?.source || 'Local File', + sourceUrl: meta?.sourceUrl || null, + }; + }), + games, + summary, + }; + + displayResults(lastResult); + setRunning(false); + } + + function computeLocalSummary(games, elapsedMs) { + const perDeck = [0, 1, 2, 3].map(seat => { + const seatGames = games.map(g => g.playerResults[seat]); + const wins = seatGames.filter(p => p.isWinner).length; + const losses = games.filter(g => g.winningSeat !== null && g.winningSeat !== seat).length; + const draws = games.filter(g => g.winningSeat === null).length; + const winGames = games.filter(g => g.winningSeat === seat); + + const breakdown = { combat_damage: 0, commander_damage: 0, combo_alt_win: 0, life_drain: 0, mill: 0, concession: 0, timeout: 0, unknown: 0 }; + winGames.forEach(g => { breakdown[g.winCondition] = (breakdown[g.winCondition] || 0) + 1; }); + + return { + seatIndex: seat, + deckName: selectedDecks[seat]?.replace('.dck', '') || `Deck ${seat + 1}`, + wins, + losses, + draws, + winRate: games.length > 0 ? wins / games.length : 0, + avgTurnsToWin: wins > 0 ? winGames.reduce((s, g) => s + g.totalTurns, 0) / wins : null, + avgMulligans: seatGames.length > 0 ? seatGames.reduce((s, p) => s + p.mulligans, 0) / seatGames.length : 0, + avgFinalLife: seatGames.length > 0 ? seatGames.reduce((s, p) => s + p.finalLife, 0) / seatGames.length : 0, + winConditionBreakdown: breakdown, + }; + }); + + const totalTurns = games.reduce((s, g) => s + g.totalTurns, 0); + const totalTime = games.reduce((s, g) => s + g.elapsedMs, 0); + + return { + perDeck, + avgGameTurns: games.length > 0 ? totalTurns / games.length : 0, + avgGameTimeMs: games.length > 0 ? totalTime / games.length : 0, + simsPerSecond: elapsedMs > 0 ? (games.length / (elapsedMs / 1000)) : 0, + }; + } + + // ── Polling ──────────────────────────────────────────── + + function startPolling(batchId) { + pollInterval = setInterval(async () => { + try { + const res = await fetch(`${API_BASE}/api/lab/status?batchId=${batchId}`); + if (!res.ok) return; + const data = await res.json(); + + updateProgress(data.completed, data.total, data.elapsedMs); + + if (!data.running) { + stopPolling(); + + if (data.error) { + appendLog(`ERROR: ${data.error}`); + setRunning(false); + return; + } + + // Fetch final result + const resultRes = await fetch(`${API_BASE}/api/lab/result?batchId=${batchId}`); + if (resultRes.ok) { + lastResult = await resultRes.json(); + displayResults(lastResult); + appendLog(`Batch complete. ${lastResult.metadata.completedGames} games in ${(lastResult.metadata.elapsedMs/1000).toFixed(1)}s`); + } + setRunning(false); + } + } catch (err) { + console.error('[AI Lab] Poll error:', err); + } + }, POLL_RATE_MS); + } + + function startLogPolling(batchId) { + logInterval = setInterval(async () => { + try { + const res = await fetch(`${API_BASE}/api/lab/log?batchId=${batchId}`); + if (!res.ok) return; + const data = await res.json(); + const logEl = document.getElementById('lab-log-content'); + if (logEl && data.lines) { + // Show last N lines + const tail = data.lines.slice(-30); + logEl.textContent = tail.join('\n'); + logEl.scrollTop = logEl.scrollHeight; + } + } catch { /* ignore */ } + }, LOG_RATE_MS); + } + + function stopPolling() { + if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + if (logInterval) { clearInterval(logInterval); logInterval = null; } + } + + // ── Progress ─────────────────────────────────────────── + + function showProgress() { + const progressEl = document.getElementById('lab-progress'); + if (progressEl) progressEl.classList.add('active'); + } + + function updateProgress(completed, total, elapsedMs) { + const pct = total > 0 ? (completed / total) * 100 : 0; + const fillEl = document.getElementById('lab-progress-fill'); + const countEl = document.getElementById('lab-progress-count'); + const etaEl = document.getElementById('lab-progress-eta'); + + if (fillEl) fillEl.style.width = pct + '%'; + if (countEl) countEl.textContent = `${completed} / ${total} games`; + + if (completed > 0 && elapsedMs > 0) { + const rate = completed / (elapsedMs / 1000); + const remaining = (total - completed) / rate; + if (etaEl) etaEl.textContent = remaining > 0 ? `~${Math.ceil(remaining)}s remaining` : 'Done'; + } + } + + // ── Log Panel ────────────────────────────────────────── + + function clearLog() { + const logEl = document.getElementById('lab-log-content'); + if (logEl) logEl.textContent = ''; + } + + function appendLog(msg) { + const logEl = document.getElementById('lab-log-content'); + const logContainer = document.getElementById('lab-log'); + if (!logEl) return; + logEl.textContent += msg + '\n'; + logEl.scrollTop = logEl.scrollHeight; + if (logContainer) logContainer.classList.add('active'); + } + + function toggleLog() { + const logContainer = document.getElementById('lab-log'); + const toggleBtn = document.getElementById('lab-log-toggle'); + if (logContainer) { + logContainer.classList.toggle('collapsed'); + if (toggleBtn) toggleBtn.textContent = logContainer.classList.contains('collapsed') ? '▶' : '▼'; + } + } + + // ── Display Results ──────────────────────────────────── + + function displayResults(result) { + const resultsEl = document.getElementById('lab-results'); + const cardsEl = document.getElementById('lab-stats-cards'); + const globalEl = document.getElementById('lab-global-stats'); + + if (!resultsEl || !cardsEl || !globalEl) return; + resultsEl.classList.add('active'); + + // Sort decks by win rate for ranking + const ranked = [...result.summary.perDeck].sort((a, b) => b.winRate - a.winRate); + const rankMap = {}; + ranked.forEach((d, i) => { rankMap[d.seatIndex] = i + 1; }); + + // Render stat cards + cardsEl.innerHTML = result.summary.perDeck.map(ds => { + const rank = rankMap[ds.seatIndex]; + const isWinner = rank === 1; + const deckInfo = result.decks[ds.seatIndex]; + const breakdown = ds.winConditionBreakdown || {}; + + // v3: Source badge in results + const source = deckInfo.source || ''; + const sourceUrl = deckInfo.sourceUrl || ''; + const sourceBadgeHtml = source + ? `
${renderSourceBadge(source, sourceUrl)}
` + : ''; + + return ` +
+
#${rank}
+
${escapeHtml(ds.deckName)}
+
${escapeHtml(deckInfo.commanderName)}
+ ${sourceBadgeHtml} + +
+ Win Rate + ${(ds.winRate * 100).toFixed(1)}% +
+
+ Record + ${ds.wins}W / ${ds.losses}L / ${ds.draws}D +
+
+ Avg Turns to Win + ${ds.avgTurnsToWin !== null ? ds.avgTurnsToWin.toFixed(1) : 'N/A'} +
+
+ Avg Mulligans + ${ds.avgMulligans.toFixed(2)} +
+
+ Avg Final Life + ${ds.avgFinalLife.toFixed(1)} +
+ +
+
+
+ + ${ds.wins > 0 ? ` +
+ ${Object.entries(breakdown).filter(([_, v]) => v > 0).map(([k, v]) => ` + ${v} ${formatWinCondition(k)} + `).join('')} +
+ ` : ''} +
+ `; + }).join(''); + + // Render global stats + const s = result.summary; + const m = result.metadata; + globalEl.innerHTML = ` +
+ ${m.completedGames} + Games +
+
+ ${s.avgGameTurns.toFixed(1)} + Avg Turns +
+
+ ${(m.elapsedMs / 1000).toFixed(1)}s + Wall Time +
+
+ ${s.simsPerSecond.toFixed(2)} + Sims/sec +
+
+ ${m.threads} + Threads +
+
+ ${m.engineVersion || '?'} + Engine +
+ `; + + // Render enhanced result sections + renderCharts(result); + renderHeadToHead(result); + renderCombatStats(result); + renderGameLog(result); + } + + // ── Tab Switching ────────────────────────────────────── + + // ── Analytics & Trends ───────────────────────────────── + + async function loadAnalytics() { + const contentEl = document.getElementById('lab-analytics-content'); + if (!contentEl) return; + + const filledDecks = selectedDecks.filter(d => d); + if (filledDecks.length === 0) { + contentEl.innerHTML = '

Select decks to see analytics.

'; + return; + } + + contentEl.innerHTML = '
Loading analytics...
'; + + try { + const results = await Promise.all( + filledDecks.map(async (deckName) => { + const res = await fetch(`${API_BASE}/api/lab/analytics/${encodeURIComponent(deckName)}`); + if (!res.ok) return { deckName, error: true }; + return await res.json(); + }) + ); + + let html = '
'; + for (const data of results) { + if (data.error) { + html += `
+
${escapeHtml(data.deckName)}
+
Analytics unavailable
+
`; + continue; + } + + // Count cards by section + const sections = {}; + for (const card of (data.cards || [])) { + const sec = card.section || 'Main'; + sections[sec] = (sections[sec] || 0) + card.quantity; + } + + html += `
+
${escapeHtml(data.deckName)}
+
${escapeHtml(data.commanderName || 'Unknown')}
+
+ Total Cards + ${data.totalCards || 0} +
+
+ Unique Cards + ${data.cardCount || 0} +
+
+ ${Object.entries(sections).map(([sec, count]) => + `${escapeHtml(sec)}: ${count}` + ).join('')} +
+
`; + } + html += '
'; + contentEl.innerHTML = html; + + } catch (err) { + contentEl.innerHTML = '
Error loading analytics: ' + escapeHtml(err.message) + '
'; + } + } + + async function loadTrends() { + const contentEl = document.getElementById('lab-trends-content'); + const canvas = document.getElementById('lab-chart-trends'); + if (!contentEl) return; + + const filledDecks = selectedDecks.filter(d => d); + if (filledDecks.length === 0) { + contentEl.innerHTML = '

Select decks to see trends.

'; + if (canvas) canvas.style.display = 'none'; + return; + } + + contentEl.innerHTML = '
Loading trends...
'; + + try { + const results = await Promise.all( + filledDecks.map(async (deckName) => { + const res = await fetch(`${API_BASE}/api/lab/trends/${encodeURIComponent(deckName)}`); + if (!res.ok) return { deckName, history: [] }; + return await res.json(); + }) + ); + + // Check if any deck has history + const anyHistory = results.some(r => (r.history || []).length > 0); + if (!anyHistory) { + contentEl.innerHTML = '

No batch history found. Run multiple batches to track trends.

'; + if (canvas) canvas.style.display = 'none'; + return; + } + + // Build summary cards + let html = ''; + contentEl.innerHTML = html; + + // Draw trends chart if canvas available + if (canvas && typeof Chart !== 'undefined') { + canvas.style.display = 'block'; + drawTrendsChart(results, canvas); + } + + } catch (err) { + contentEl.innerHTML = '
Error loading trends: ' + escapeHtml(err.message) + '
'; + } + } + + function drawTrendsChart(results, canvas) { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const colors = ['#58a6ff', '#f85149', '#3fb950', '#a78bfa', '#fbbf24']; + const padding = { top: 30, right: 20, bottom: 40, left: 50 }; + const w = canvas.width - padding.left - padding.right; + const h = canvas.height - padding.top - padding.bottom; + + // Find max data points + let maxPoints = 0; + for (const r of results) { + maxPoints = Math.max(maxPoints, (r.history || []).length); + } + if (maxPoints < 2) return; + + // Draw axes + ctx.strokeStyle = '#2a2d3a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(padding.left, padding.top); + ctx.lineTo(padding.left, canvas.height - padding.bottom); + ctx.lineTo(canvas.width - padding.right, canvas.height - padding.bottom); + ctx.stroke(); + + // Y-axis labels (0-100%) + ctx.fillStyle = '#8b90a0'; + ctx.font = '11px Inter, sans-serif'; + ctx.textAlign = 'right'; + for (let pct = 0; pct <= 100; pct += 25) { + const y = canvas.height - padding.bottom - (pct / 100) * h; + ctx.fillText(pct + '%', padding.left - 8, y + 4); + // Grid line + ctx.strokeStyle = '#1e2130'; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(canvas.width - padding.right, y); + ctx.stroke(); + } + + // Draw lines for each deck + results.forEach((data, di) => { + const history = data.history || []; + if (history.length < 2) return; + + ctx.strokeStyle = colors[di % colors.length]; + ctx.lineWidth = 2; + ctx.beginPath(); + + history.forEach((point, i) => { + const x = padding.left + (i / (maxPoints - 1)) * w; + const y = canvas.height - padding.bottom - (point.winRate / 100) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // Draw points + ctx.fillStyle = colors[di % colors.length]; + history.forEach((point, i) => { + const x = padding.left + (i / (maxPoints - 1)) * w; + const y = canvas.height - padding.bottom - (point.winRate / 100) * h; + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fill(); + }); + + // Label + const lastPt = history[history.length - 1]; + const lx = padding.left + ((history.length - 1) / (maxPoints - 1)) * w; + const ly = canvas.height - padding.bottom - (lastPt.winRate / 100) * h; + ctx.fillStyle = colors[di % colors.length]; + ctx.textAlign = 'left'; + ctx.font = '11px Inter, sans-serif'; + ctx.fillText(data.deckName, lx + 6, ly + 4); + }); + } + + // ── Precon Refresh ────────────────────────────────────── + + async function refreshPrecons() { + const statusEl = document.getElementById('lab-precon-status'); + + if (!backendAvailable) { + showImportStatus(statusEl, 'Precon refresh requires a running backend.', 'error'); + return; + } + + showImportStatus(statusEl, 'Refreshing precon database from GitHub...', 'loading'); + + try { + const res = await fetch(`${API_BASE}/api/lab/precons/refresh`, { + method: 'POST', + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || 'Refresh failed'); + } + + showImportStatus(statusEl, + data.message || ('Downloaded ' + data.total + ' precon decks'), + 'success' + ); + + // Reload precon list + await loadPrecons(); + renderPreconGrid(preconDecks); + + } catch (err) { + showImportStatus(statusEl, 'Error: ' + err.message, 'error'); + } + } + + function switchTab(tabId) { + document.querySelectorAll('.lab-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabId); + }); + document.querySelectorAll('.lab-tab-panel').forEach(panel => { + panel.classList.toggle('active', panel.id === tabId); + }); + + // Load data for analytics/trends tabs on demand + if (tabId === 'tab-analytics') loadAnalytics(); + if (tabId === 'tab-trends') loadTrends(); + } + + // ── Charts & Visualizations ──────────────────────────── + + const SEAT_COLORS = ['#58a6ff', '#f85149', '#3fb950', '#d29922']; + const SEAT_COLORS_DIM = ['rgba(88,166,255,0.3)', 'rgba(248,81,73,0.3)', 'rgba(63,185,80,0.3)', 'rgba(210,153,34,0.3)']; + + function renderCharts(result) { + const games = result.games || []; + const perDeck = result.summary.perDeck || []; + const decks = result.decks || []; + + drawWinRateChart(perDeck, decks); + drawWinConditionChart(perDeck, decks); + drawTurnDistribution(games, decks); + drawWinRateTrend(games, decks); + } + + function drawWinRateChart(perDeck, decks) { + const canvas = document.getElementById('lab-chart-winrate'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + const pad = { top: 20, right: 20, bottom: 50, left: 50 }; + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + const barW = Math.min(60, chartW / perDeck.length - 20); + + // Axes + ctx.strokeStyle = '#30363d'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top); + ctx.lineTo(pad.left, h - pad.bottom); + ctx.lineTo(w - pad.right, h - pad.bottom); + ctx.stroke(); + + // Y-axis labels (0%, 25%, 50%, 75%, 100%) + ctx.fillStyle = '#8b949e'; + ctx.font = '11px -apple-system, sans-serif'; + ctx.textAlign = 'right'; + for (let pct = 0; pct <= 100; pct += 25) { + const y = h - pad.bottom - (pct / 100) * chartH; + ctx.fillText(pct + '%', pad.left - 8, y + 4); + if (pct > 0) { + ctx.strokeStyle = 'rgba(48,54,61,0.5)'; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + } + } + + // Bars + perDeck.forEach((ds, i) => { + const x = pad.left + (i + 0.5) * (chartW / perDeck.length) - barW / 2; + const barH = (ds.winRate) * chartH; + const y = h - pad.bottom - barH; + + // Bar fill + ctx.fillStyle = SEAT_COLORS[ds.seatIndex] || SEAT_COLORS[0]; + ctx.beginPath(); + roundRect(ctx, x, y, barW, barH, 4); + ctx.fill(); + + // Value on top + ctx.fillStyle = '#c9d1d9'; + ctx.font = 'bold 12px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText((ds.winRate * 100).toFixed(1) + '%', x + barW / 2, y - 6); + + // Label + ctx.fillStyle = '#8b949e'; + ctx.font = '10px -apple-system, sans-serif'; + const label = (decks[ds.seatIndex]?.commanderName || ds.deckName || '').substring(0, 16); + ctx.fillText(label, x + barW / 2, h - pad.bottom + 14); + }); + } + + function drawWinConditionChart(perDeck, decks) { + const canvas = document.getElementById('lab-chart-wincond'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + // Aggregate win conditions across all decks + const totals = {}; + perDeck.forEach(ds => { + const bd = ds.winConditionBreakdown || {}; + Object.entries(bd).forEach(([k, v]) => { + if (v > 0) totals[k] = (totals[k] || 0) + v; + }); + }); + + const entries = Object.entries(totals).filter(([_, v]) => v > 0).sort((a, b) => b[1] - a[1]); + if (entries.length === 0) { + ctx.fillStyle = '#8b949e'; + ctx.font = '13px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('No win data', w / 2, h / 2); + return; + } + + const total = entries.reduce((s, [_, v]) => s + v, 0); + const pieColors = ['#58a6ff', '#f85149', '#3fb950', '#d29922', '#a371f7', '#f0883e', '#8b949e', '#56d364']; + const cx = w * 0.35, cy = h / 2, radius = Math.min(cx - 20, cy - 20); + + let startAngle = -Math.PI / 2; + const slices = []; + entries.forEach(([k, v], i) => { + const sliceAngle = (v / total) * Math.PI * 2; + ctx.fillStyle = pieColors[i % pieColors.length]; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle); + ctx.closePath(); + ctx.fill(); + slices.push({ key: k, value: v, color: pieColors[i % pieColors.length], startAngle, endAngle: startAngle + sliceAngle }); + startAngle += sliceAngle; + }); + + // Legend + const legendX = w * 0.65; + let legendY = 30; + ctx.font = '11px -apple-system, sans-serif'; + ctx.textAlign = 'left'; + slices.forEach(s => { + ctx.fillStyle = s.color; + ctx.fillRect(legendX, legendY - 8, 10, 10); + ctx.fillStyle = '#c9d1d9'; + const pct = ((s.value / total) * 100).toFixed(0); + ctx.fillText(`${formatWinCondition(s.key)} (${pct}%)`, legendX + 16, legendY); + legendY += 18; + }); + } + + function drawTurnDistribution(games, decks) { + const canvas = document.getElementById('lab-chart-turns'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (games.length === 0) return; + + const pad = { top: 20, right: 20, bottom: 50, left: 50 }; + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + + // Build histogram buckets + const turns = games.map(g => g.totalTurns); + const minT = Math.min(...turns); + const maxT = Math.max(...turns); + const bucketSize = Math.max(1, Math.ceil((maxT - minT + 1) / 15)); + const buckets = []; + for (let t = minT; t <= maxT; t += bucketSize) { + const count = turns.filter(v => v >= t && v < t + bucketSize).length; + buckets.push({ label: bucketSize === 1 ? '' + t : t + '-' + (t + bucketSize - 1), count }); + } + + const maxCount = Math.max(...buckets.map(b => b.count)); + const barW = Math.max(4, chartW / buckets.length - 2); + + // Axes + ctx.strokeStyle = '#30363d'; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top); + ctx.lineTo(pad.left, h - pad.bottom); + ctx.lineTo(w - pad.right, h - pad.bottom); + ctx.stroke(); + + // Y-axis + ctx.fillStyle = '#8b949e'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'right'; + for (let i = 0; i <= 4; i++) { + const val = Math.round(maxCount * i / 4); + const y = h - pad.bottom - (i / 4) * chartH; + ctx.fillText(val, pad.left - 8, y + 3); + } + + // Bars + buckets.forEach((b, i) => { + const x = pad.left + i * (chartW / buckets.length) + 1; + const barH = maxCount > 0 ? (b.count / maxCount) * chartH : 0; + const y = h - pad.bottom - barH; + + ctx.fillStyle = '#58a6ff'; + ctx.globalAlpha = 0.8; + ctx.beginPath(); + roundRect(ctx, x, y, barW, barH, 2); + ctx.fill(); + ctx.globalAlpha = 1; + + // Label + if (buckets.length <= 20) { + ctx.fillStyle = '#8b949e'; + ctx.font = '9px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(b.label, x + barW / 2, h - pad.bottom + 14); + } + }); + + // X-axis label + ctx.fillStyle = '#8b949e'; + ctx.font = '11px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Turns', w / 2, h - 6); + } + + function drawWinRateTrend(games, decks) { + const canvas = document.getElementById('lab-chart-wintrend'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (games.length < 2) return; + + const pad = { top: 20, right: 20, bottom: 50, left: 50 }; + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + + // Compute cumulative win rate for each seat + const seatWins = [0, 0, 0, 0]; + const seatRates = [[], [], [], []]; + + // Sample points (don't plot every game for large batches) + const step = Math.max(1, Math.floor(games.length / 100)); + + games.forEach((g, gi) => { + if (g.winningSeat !== null && g.winningSeat !== undefined) { + seatWins[g.winningSeat]++; + } + if (gi % step === 0 || gi === games.length - 1) { + const n = gi + 1; + for (let s = 0; s < 4; s++) { + seatRates[s].push({ x: gi, y: seatWins[s] / n }); + } + } + }); + + // Axes + ctx.strokeStyle = '#30363d'; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top); + ctx.lineTo(pad.left, h - pad.bottom); + ctx.lineTo(w - pad.right, h - pad.bottom); + ctx.stroke(); + + // Y-axis labels + ctx.fillStyle = '#8b949e'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'right'; + for (let pct = 0; pct <= 100; pct += 25) { + const y = h - pad.bottom - (pct / 100) * chartH; + ctx.fillText(pct + '%', pad.left - 8, y + 3); + ctx.strokeStyle = 'rgba(48,54,61,0.3)'; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + } + + // Lines + const maxX = games.length - 1; + for (let s = 0; s < 4; s++) { + const points = seatRates[s]; + if (points.length < 2) continue; + + ctx.strokeStyle = SEAT_COLORS[s]; + ctx.lineWidth = 2; + ctx.beginPath(); + points.forEach((p, pi) => { + const px = pad.left + (p.x / maxX) * chartW; + const py = h - pad.bottom - p.y * chartH; + if (pi === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + ctx.lineWidth = 1; + } + + // Legend + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'left'; + for (let s = 0; s < 4; s++) { + const lx = pad.left + 10 + s * 120; + ctx.fillStyle = SEAT_COLORS[s]; + ctx.fillRect(lx, pad.top + 2, 12, 3); + ctx.fillStyle = '#8b949e'; + const label = (decks[s]?.commanderName || decks[s]?.deckName || 'Deck ' + (s + 1)).substring(0, 14); + ctx.fillText(label, lx + 16, pad.top + 7); + } + + // X-axis label + ctx.fillStyle = '#8b949e'; + ctx.font = '11px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Games Played', w / 2, h - 6); + } + + function roundRect(ctx, x, y, w, h, r) { + if (h <= 0) { ctx.rect(x, y, w, 0); return; } + r = Math.min(r, h / 2, w / 2); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + } + + // ── Head-to-Head Matrix ──────────────────────────────── + + function renderHeadToHead(result) { + const el = document.getElementById('lab-h2h-matrix'); + if (!el) return; + + const games = result.games || []; + const decks = result.decks || []; + if (games.length === 0) { el.innerHTML = '

No game data

'; return; } + + // Compute pairwise: for each pair (A, B), how often did A win when both were in the game (always — 4-player pod) + // Show: when deck A wins, what % of those games vs each opponent + // Better metric: For each pair, count games where A wins and B doesn't (and vice versa) + const n = decks.length; + const h2h = Array.from({ length: n }, () => Array(n).fill(0)); + const pairGames = Array.from({ length: n }, () => Array(n).fill(0)); + + games.forEach(g => { + for (let a = 0; a < n; a++) { + for (let b = 0; b < n; b++) { + if (a === b) continue; + pairGames[a][b]++; + if (g.winningSeat === a) h2h[a][b]++; + } + } + }); + + let html = ''; + decks.forEach((d, i) => { + const name = (d.commanderName || d.deckName || 'Deck ' + (i + 1)).substring(0, 18); + html += ``; + }); + html += ''; + + for (let a = 0; a < n; a++) { + const name = (decks[a].commanderName || decks[a].deckName || 'Deck ' + (a + 1)).substring(0, 18); + html += ``; + for (let b = 0; b < n; b++) { + if (a === b) { + html += ''; + } else { + const wins = h2h[a][b]; + const total = pairGames[a][b]; + const rate = total > 0 ? (wins / total * 100).toFixed(1) : '0.0'; + const rateNum = total > 0 ? wins / total : 0; + const cls = rateNum > 0.5 ? 'lab-h2h-winning' : rateNum < 0.3 ? 'lab-h2h-losing' : ''; + html += ``; + } + } + html += ''; + } + html += '
vs${escapeHtml(name)}
${escapeHtml(name)}—${rate}%${wins}/${total}
'; + + html += '

Reads as: row deck\'s win rate in games where column deck also played.

'; + el.innerHTML = html; + } + + // ── Combat Stats ────────────────────────────────────── + + function renderCombatStats(result) { + const el = document.getElementById('lab-combat-stats'); + if (!el) return; + + const games = result.games || []; + const decks = result.decks || []; + if (games.length === 0) { el.innerHTML = '

No game data

'; return; } + + // Aggregate per-seat combat stats from game-level playerResults + const stats = decks.map((d, seat) => { + const pr = games.map(g => g.playerResults[seat]).filter(Boolean); + const n = pr.length; + if (n === 0) return null; + + const sum = (fn) => pr.reduce((s, p) => s + fn(p), 0); + const avg = (fn) => sum(fn) / n; + const max = (fn) => Math.max(...pr.map(fn)); + + return { + seat, + name: d.commanderName || d.deckName || 'Deck ' + (seat + 1), + avgCmdrDmg: avg(p => p.commanderDamageDealt || 0), + maxCmdrDmg: max(p => p.commanderDamageDealt || 0), + avgCmdrCasts: avg(p => p.commanderCasts || 0), + maxCmdrCasts: max(p => p.commanderCasts || 0), + avgLands: avg(p => p.landsPlayed || 0), + avgSpells: avg(p => p.spellsCast || 0), + maxSpells: max(p => p.spellsCast || 0), + avgCreaturesDestroyed: avg(p => p.creaturesDestroyed || 0), + totalCreaturesDestroyed: sum(p => p.creaturesDestroyed || 0), + avgFinalLife: avg(p => p.finalLife || 0), + }; + }).filter(Boolean); + + let html = '
'; + stats.forEach(s => { + html += ` +
+
+ ${escapeHtml(s.name)} +
+
+
+ ⚔️ + Cmdr Damage / Game + ${s.avgCmdrDmg.toFixed(1)} + max ${s.maxCmdrDmg} +
+
+ 👑 + Cmdr Casts / Game + ${s.avgCmdrCasts.toFixed(2)} + max ${s.maxCmdrCasts} +
+
+ 🏔️ + Lands / Game + ${s.avgLands.toFixed(1)} +
+
+ ✨ + Spells / Game + ${s.avgSpells.toFixed(1)} + max ${s.maxSpells} +
+
+ 💀 + Creatures Destroyed / Game + ${s.avgCreaturesDestroyed.toFixed(1)} + total ${s.totalCreaturesDestroyed} +
+
+ ❤️ + Avg Final Life + ${s.avgFinalLife.toFixed(1)} +
+
+
`; + }); + html += '
'; + el.innerHTML = html; + } + + // ── Game-by-Game Log ────────────────────────────────── + + let gameLogData = []; + + function renderGameLog(result) { + const wrapper = document.getElementById('lab-gamelog-wrapper'); + const countEl = document.getElementById('lab-gamelog-count'); + const filterInput = document.getElementById('lab-gamelog-filter'); + if (!wrapper) return; + + const games = result.games || []; + const decks = result.decks || []; + + gameLogData = games.map(g => { + const winnerName = g.winningSeat !== null && g.winningSeat !== undefined + ? (decks[g.winningSeat]?.commanderName || decks[g.winningSeat]?.deckName || 'Deck ' + (g.winningSeat + 1)) + : 'Draw'; + return { + index: g.gameIndex + 1, + winner: winnerName, + winningSeat: g.winningSeat, + turns: g.totalTurns, + winCondition: g.winCondition, + elapsed: g.elapsedMs, + players: g.playerResults.map((pr, i) => ({ + name: (decks[i]?.commanderName || decks[i]?.deckName || 'Deck ' + (i + 1)).substring(0, 16), + life: pr.finalLife, + seat: i, + isWinner: pr.isWinner, + })), + }; + }); + + // Wire up filter + if (filterInput) { + filterInput.oninput = () => filterGameLog(filterInput.value); + } + + renderGameLogTable(gameLogData); + } + + function filterGameLog(query) { + const q = query.toLowerCase().trim(); + if (!q) { + renderGameLogTable(gameLogData); + return; + } + const filtered = gameLogData.filter(g => + g.winner.toLowerCase().includes(q) || + (g.winCondition || '').toLowerCase().includes(q) || + formatWinCondition(g.winCondition).toLowerCase().includes(q) || + g.players.some(p => p.name.toLowerCase().includes(q)) + ); + renderGameLogTable(filtered); + } + + function renderGameLogTable(data) { + const wrapper = document.getElementById('lab-gamelog-wrapper'); + const countEl = document.getElementById('lab-gamelog-count'); + if (!wrapper) return; + + if (countEl) { + countEl.textContent = data.length + ' game' + (data.length !== 1 ? 's' : ''); + } + + // Show max 200 rows, paginated + const pageSize = 200; + const display = data.slice(0, pageSize); + + let html = ` + + + + + `; + // Life columns for each seat + if (display.length > 0 && display[0].players) { + display[0].players.forEach((p, i) => { + html += ``; + }); + } + html += ''; + + display.forEach(g => { + const winColor = g.winningSeat !== null && g.winningSeat !== undefined + ? SEAT_COLORS[g.winningSeat] : '#8b949e'; + html += ` + + + + `; + g.players.forEach(p => { + const lifeClass = p.life <= 0 ? 'lab-gl-dead' : p.isWinner ? 'lab-gl-alive' : ''; + html += ``; + }); + html += ''; + }); + html += '
#WinnerTurnsWin Condition${escapeHtml(p.name)}
${g.index}${escapeHtml(g.winner)}${g.turns}${formatWinCondition(g.winCondition)}${p.life}
'; + + if (data.length > pageSize) { + html += `

Showing ${pageSize} of ${data.length} games

`; + } + + wrapper.innerHTML = html; + } + + // ── History ──────────────────────────────────────────── + + async function showHistory() { + const modal = document.getElementById('lab-history-modal'); + const listEl = document.getElementById('lab-history-list'); + if (!modal || !listEl) return; + + modal.classList.add('active'); + listEl.innerHTML = 'Loading...'; + + if (!backendAvailable) { + listEl.innerHTML = '

History requires a running backend.

'; + return; + } + + try { + const res = await fetch(`${API_BASE}/api/lab/history`); + if (!res.ok) throw new Error('Failed to load history'); + const data = await res.json(); + + if (!data.results || data.results.length === 0) { + listEl.innerHTML = '

No past runs found.

'; + return; + } + + listEl.innerHTML = data.results.map(r => { + const deckLabels = (r.decks || []).map(d => { + const srcTag = d.source ? ` [${d.source}]` : ''; + return `${escapeHtml(d.name)}${srcTag}`; + }).join(', '); + + return ` +
+
${r.batchId}
+
+ ${r.totalGames} games · ${r.threads} threads · ${(r.elapsedMs/1000).toFixed(1)}s +
+
${deckLabels}
+
${new Date(r.timestamp).toLocaleString()}
+
+ `; + }).join(''); + } catch (err) { + listEl.innerHTML = `

Error: ${err.message}

`; + } + } + + function closeHistory() { + const modal = document.getElementById('lab-history-modal'); + if (modal) modal.classList.remove('active'); + } + + async function loadHistoryResult(filename) { + closeHistory(); + try { + // Fetch from results directory via API + const batchId = filename.replace('batch-', '').replace('.json', ''); + const res = await fetch(`${API_BASE}/api/lab/result?batchId=${batchId}`); + if (res.ok) { + lastResult = await res.json(); + displayResults(lastResult); + } + } catch (err) { + console.error('[AI Lab] Failed to load history result:', err); + } + } + + // ── Helpers ───────────────────────────────────────────── + + function formatWinCondition(key) { + const map = { + combat_damage: 'Combat', + commander_damage: 'Cmdr Dmg', + combo_alt_win: 'Combo', + life_drain: 'Drain', + mill: 'Mill', + concession: 'Concede', + timeout: 'Timeout', + unknown: '???', + }; + return map[key] || key; + } + + function setRunning(running) { + isRunning = running; + const btn = document.getElementById('lab-run-btn'); + if (btn) { + btn.disabled = running; + btn.classList.toggle('running', running); + btn.querySelector('.run-text').textContent = running ? 'Simulating...' : 'Run AI Sims'; + } + if (!running) { + stopPolling(); + // Keep progress visible briefly, then fade + setTimeout(() => { + const progressEl = document.getElementById('lab-progress'); + if (progressEl) progressEl.classList.remove('active'); + }, 3000); + } + } + + function seededRandom(seed) { + let s = seed | 0; + return function() { + s |= 0; s = s + 0x6D2B79F5 | 0; + let t = Math.imul(s ^ s >>> 15, 1 | s); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + + function exportJson() { + if (!lastResult) return; + const json = JSON.stringify(lastResult, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `commander-ai-lab-${lastResult.metadata.batchId}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function escapeAttr(str) { + return str.replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + function bindEvents() { + const input = document.getElementById('lab-game-count'); + if (input) { + input.addEventListener('change', () => { + document.querySelectorAll('.lab-preset-btn').forEach(btn => { + btn.classList.toggle('active', parseInt(btn.textContent) === parseInt(input.value)); + }); + }); + } + + // Close modals on overlay click + document.querySelectorAll('.lab-modal-overlay').forEach(modal => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('active'); + } + }); + }); + + // Enter key in URL import + const urlInput = document.getElementById('lab-import-url-input'); + if (urlInput) { + urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') importFromUrl(); + }); + } + } + + // ── Public API ───────────────────────────────────────── + + return { + init, + selectDeck, + setGameCount, + startBatch, + exportJson, + showHistory, + closeHistory, + loadHistoryResult, + toggleLog, + getLastResult: () => lastResult, + // v3 + showUrlImport, + hideUrlImport, + importFromUrl, + showMetaPicker, + closeMetaPicker, + filterMetaCommanders, + fetchMetaDeck, + showTextImport, + closeTextImport, + importFromText, + // Precon decks + showPreconPicker, + closePreconPicker, + filterPrecons, + installPrecon, + installAllPrecons, + // v4 — enhanced results + switchTab, + // v5 — analytics, trends, precon refresh + loadAnalytics, + loadTrends, + refreshPrecons, + }; + +})(); + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', AiLab.init); +} else { + AiLab.init(); +}