diff --git a/ui/ai-lab.js b/ui/ai-lab.js index 7457b64..e625332 100644 --- a/ui/ai-lab.js +++ b/ui/ai-lab.js @@ -4,2574 +4,14 @@ * * v3 adds: * - URL import (Archidekt/EDHREC) — paste a URL to import a deck - * - 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 + * - 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) * - * 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 + * Depends on: lab_api.py (/api/lab/…) */ -const AiLab = (() => { - 'use strict'; - - // ── 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 artUrl = p.imageUrl - || `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`; - - return ` -
- ${escapeAttr(p.commander)} -
-
${colors}
-
${escapeHtml(p.name)}
-
${escapeHtml(p.commander)}
-
${escapeHtml(p.set)} (${p.year})
- ${p.theme ? `
${escapeHtml(p.theme)}
` : ''} - -
-
`; - }).join(''); - } - - 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(); -} +(function () { + 'use strict'; \ No newline at end of file