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 = `
+
+
+
Headless 3-AI batch simulation — stress-test your decks at scale
+
+
+
+
Import Deck
+
+
+ 🔗 URL Import
+
+
+ 📊 Commander Meta
+
+
+ 📋 Text Import
+
+
+ 🎴 Precon Decks
+
+
+
+
+
+
+
+
+
+
+ Import
+
+
+
+ Supported: archidekt.com/decks/... · edhrec.com/average-decks/... · edhrec.com/commanders/...
+
+
+
+
+
+
+ ${[0, 1, 2, 3].map(i => `
+
+
Seat ${i + 1}
+
+ — Select Deck —
+
+
+
+
+ `).join('')}
+
+
+
+
+
+
Games:
+
+
+ ${PRESET_GAMES.map(n => `
+ ${n}
+ `).join('')}
+
+
+
+ Threads:
+
+ 1
+ 2
+ 4
+ 8
+ 16
+ 24
+ 32
+
+
+
+ Seed:
+
+
+
+ Engine:
+
+ Forge Heuristic
+ DeepSeek LLM
+
+
+
+ ▶
+
+ Run AI Sims
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Charts
+ Head-to-Head
+ Combat Stats
+ Game Log
+ Analytics
+ Trends
+
+
+
+
+
+
+
Win Rate Comparison
+
+
+
+
Win Conditions
+
+
+
+
Turn Distribution
+
+
+
+
Win Rate Over Time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select decks and run a batch to see per-deck analytics.
+
+
+
+
+
+
+
Run multiple batches to track win rate trends over time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Install All
+
+
+ Refresh Index
+
+
+
Loading...
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+ Commander override:
+
+
+
+ Import Deck
+
+
+
+
+
+
+ `;
+ }
+
+ 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 = '— Select Deck — ';
+
+ // 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 = '';
+ }
+ 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 `
+ `;
+ }).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 `
+
+
+
+
${colors}
+
${escapeHtml(p.name)}
+
${escapeHtml(p.commander)}
+
${escapeHtml(p.set)} (${p.year})
+ ${p.theme ? `
${escapeHtml(p.theme)}
` : ''}
+
+ ${installLabel}
+
+
+
`;
+ }).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 = '';
+
+ 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 = '';
+
+ 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 = '';
+ for (const data of results) {
+ const history = data.history || [];
+ if (history.length === 0) continue;
+ const latest = history[history.length - 1];
+ const best = Math.max(...history.map(h => h.winRate));
+ const avg = history.reduce((a, h) => a + h.winRate, 0) / history.length;
+
+ html += `
+
${escapeHtml(data.deckName)}
+
+
+ Latest
+ ${latest.winRate.toFixed(1)}%
+
+
+ Best
+ ${best.toFixed(1)}%
+
+
+ Average
+ ${avg.toFixed(1)}%
+
+
+ Batches
+ ${history.length}
+
+
+
`;
+ }
+ 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 = 'vs ';
+ decks.forEach((d, i) => {
+ const name = (d.commanderName || d.deckName || 'Deck ' + (i + 1)).substring(0, 18);
+ html += `${escapeHtml(name)} `;
+ });
+ html += '';
+
+ for (let a = 0; a < n; a++) {
+ const name = (decks[a].commanderName || decks[a].deckName || 'Deck ' + (a + 1)).substring(0, 18);
+ html += `${escapeHtml(name)} `;
+ 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 += `${rate}%${wins}/${total} `;
+ }
+ }
+ html += ' ';
+ }
+ html += '
';
+
+ 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 += `
+
+
+
+
+ ⚔️
+ 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 = `
+
+ #
+ Winner
+ Turns
+ Win Condition `;
+ // Life columns for each seat
+ if (display.length > 0 && display[0].players) {
+ display[0].players.forEach((p, i) => {
+ html += `${escapeHtml(p.name)} `;
+ });
+ }
+ html += ' ';
+
+ display.forEach(g => {
+ const winColor = g.winningSeat !== null && g.winningSeat !== undefined
+ ? SEAT_COLORS[g.winningSeat] : '#8b949e';
+ html += `
+ ${g.index}
+ ${escapeHtml(g.winner)}
+ ${g.turns}
+ ${formatWinCondition(g.winCondition)} `;
+ g.players.forEach(p => {
+ const lifeClass = p.life <= 0 ? 'lab-gl-dead' : p.isWinner ? 'lab-gl-alive' : '';
+ html += `${p.life} `;
+ });
+ html += ' ';
+ });
+ html += '
';
+
+ 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();
+}