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 = `
-
-
-
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 artUrl = p.imageUrl
- || `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
-
- return `
-
-
-
-
${colors}
-
${escapeHtml(p.name)}
-
${escapeHtml(p.commander)}
-
${escapeHtml(p.set)} (${p.year})
- ${p.theme ? `
${escapeHtml(p.theme)}
` : ''}
-
- ${installLabel}
-
-
-
`;
- }).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 = '';
-
- 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();
-}
+(function () {
+ 'use strict';
\ No newline at end of file