diff --git a/.env.example b/.env.example index 156cbf7..947b890 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,5 @@ FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui # PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server. PRECON_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks -FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks +USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks +FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks diff --git a/.gitignore b/.gitignore index 80ccef7..fc828ea 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ embeddings/ checkpoints/ imported-decks/ +# User-created decks (generated at runtime, not committed) +user-decks/*.dck + # OS .DS_Store Thumbs.db diff --git a/coach/config.py b/coach/config.py index 2485551..8729589 100644 --- a/coach/config.py +++ b/coach/config.py @@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren # ── Ollama (OpenAI-compatible API) ──────────────────────────── LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1") -LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b") +LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514") LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds LLM_MAX_RETRIES = 3 diff --git a/lab_api.py b/lab_api.py index 7ee13c4..e5b30cc 100644 --- a/lab_api.py +++ b/lab_api.py @@ -170,6 +170,7 @@ from pathlib import Path as _Path _legacy_ui_dir = _Path(__file__).parent / "ui" _lan_client_dir = _Path(__file__).parent / "lan-client" _precon_decks_dir = _Path(__file__).parent / "precon-decks" +_user_decks_dir = _Path(__file__).parent / "user-decks" _spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist" # Mount LAN client at /lan (served before the root catch-all) @@ -180,6 +181,10 @@ if _lan_client_dir.exists(): if _precon_decks_dir.exists(): app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks") +# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck +if _user_decks_dir.exists(): + app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks") + if _legacy_ui_dir.exists(): app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui") elif _spa_dir.exists(): diff --git a/lan-client/deckgenerator.html b/lan-client/deckgenerator.html index ee89c05..8fcf180 100644 --- a/lan-client/deckgenerator.html +++ b/lan-client/deckgenerator.html @@ -146,11 +146,10 @@
- + + claude-opus-4-20250514
@@ -200,7 +199,7 @@ - - + + diff --git a/lan-client/deckgenerator.js b/lan-client/deckgenerator.js index 711cdf2..ab45b53 100644 --- a/lan-client/deckgenerator.js +++ b/lan-client/deckgenerator.js @@ -159,7 +159,7 @@ const DeckGenerator = (() => { if (!state.v3Ready) { // Warn but do not block — local/Ollama mode may still work console.warn('V3 status: not initialized. Error:', data.error || 'unknown'); - toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning'); + toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning'); } } catch (e) { console.warn('V3 status check failed:', e); @@ -386,7 +386,7 @@ const DeckGenerator = (() => { // ── Generate Deck ─────────────────────────────────────── async function generateDeck() { clearLog(); - logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info'); + logMessage('Connecting to Claude AI...', 'info'); if (!state.commander) { toast('Select a commander first', 'error'); return; } // Non-blocking warning only — local Ollama mode does not require v3Ready if (!state.v3Ready) { @@ -435,7 +435,7 @@ const DeckGenerator = (() => { state.isLoading = loading; $('dg-empty-state').style.display = 'none'; $('dg-loading').style.display = loading ? 'flex' : 'none'; - $('dg-results').style.display = loading ? 'none' : 'none'; + $('dg-results').style.display = loading ? 'none' : 'block'; $('dg-preview-btn').disabled = loading || !state.commander; if (text) $('dg-loading-text').textContent = text; } @@ -486,6 +486,7 @@ const DeckGenerator = (() => { function renderStrategyBar(result) { const bar = $('dg-strategy-bar'); + if (!bar) return; const bracket = result.bracket || {}; const archetype = result.archetype || ''; const strategy = result.strategy_summary || ''; @@ -504,6 +505,7 @@ const DeckGenerator = (() => { function renderStatsBar(result) { const stats = result.stats || {}; const container = $('dg-stats-bar'); + if (!container) return; const chips = [ chipHtml('Total', stats.total_cards || 0, ''), @@ -539,6 +541,7 @@ const DeckGenerator = (() => { function renderCardGroups(result) { const container = $('dg-card-groups'); + if (!container) return; const cards = result.cards || []; const commander = result.commander; diff --git a/lan-client/engine.js b/lan-client/engine.js index e35af64..c9d87b7 100644 --- a/lan-client/engine.js +++ b/lan-client/engine.js @@ -12556,32 +12556,6 @@ function renderDeckSelectionGrid() { '
AI Deck (auto-generated)
'; } else { var idx = i; - // Build saved decks options - var savedDecks = (typeof getDecks === 'function') ? getDecks() : []; - var savedDecksHtml = ''; - if (savedDecks.length === 0) { - savedDecksHtml = '
No saved decks. Build one in Deck Builder first.
'; - } else { - savedDecksHtml = '
'; - for (var di = 0; di < savedDecks.length; di++) { - var sd = savedDecks[di]; - var cardCount = 0; - if (sd.cards) sd.cards.forEach(function(c) { cardCount += (c.quantity || 1); }); - if (sd.commander) cardCount++; - var deckColors = ''; - if (sd.commander && sd.commander.colorIdentity && sd.commander.colorIdentity.length) { - var manaSymbols = { W: '\u2600', U: '\uD83D\uDCA7', B: '\uD83D\uDC80', R: '\uD83D\uDD25', G: '\uD83C\uDF33' }; - sd.commander.colorIdentity.forEach(function(c) { deckColors += (manaSymbols[c] || c) + ' '; }); - } - savedDecksHtml += ''; - } - savedDecksHtml += '
'; - } - panel.innerHTML = '
' + '
' + @@ -12591,7 +12565,6 @@ function renderDeckSelectionGrid() { '
' + '' + '' + - '' + '
' + '
' + '' + '
' + '
' + - savedDecksHtml + - '
' + - '
' + - '' + - '' + + '
' + + '
Loading user decks...
' + + '
' + '
'; } grid.appendChild(panel); } + + // Fetch user-created decks from the API and populate My Decks tabs + _loadUserDecksForSetup(); +} + +function _loadUserDecksForSetup() { + var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; + fetch(apiBase + '/api/lab/decks') + .then(function(res) { return res.json(); }) + .then(function(data) { + var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; }); + for (var i = 0; i < setupPlayerCount; i++) { + if (setupAiPlayers.has(i)) continue; + var listEl = document.getElementById('setup-custom-deck-list-' + i); + if (!listEl) continue; + + if (decks.length === 0) { + listEl.innerHTML = '
No saved decks. Build one in Deck Builder first.
'; + continue; + } + + var html = ''; + for (var di = 0; di < decks.length; di++) { + var d = decks[di]; + var cardCount = d.card_count || d.total_cards || 0; + var cmdr = d.commander || ''; + html += ''; + } + listEl.innerHTML = html; + } + }) + .catch(function(e) { + console.warn('[Setup] Could not load user decks:', e); + for (var i = 0; i < setupPlayerCount; i++) { + if (setupAiPlayers.has(i)) continue; + var listEl = document.getElementById('setup-custom-deck-list-' + i); + if (listEl) listEl.innerHTML = '
Could not load decks. Is the server running?
'; + } + }); +} + +function selectSetupUserDeck(playerIdx, deckId, deckName) { + var statusEl = document.getElementById('setup-deck-status-' + playerIdx); + + // Highlight selected button + var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx); + if (listEl) { + listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); }); + } + // Find clicked button and highlight it + var allBtns = listEl ? listEl.querySelectorAll('.setup-custom-deck-btn') : []; + allBtns.forEach(function(b) { + if (b.textContent.indexOf(deckName) !== -1) b.classList.add('selected'); + }); + + // Export the deck to a .dck file, then store it as a precon-style choice + var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; + if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; } + + fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' }) + .then(function(res) { return res.json(); }) + .then(function(result) { + if (result.success) { + setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' }; + if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; } + } else { + if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; } + } + }) + .catch(function(e) { + console.error('[Setup] Export failed:', e); + if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; } + }); } function switchSetupDeckTab(playerIdx, tab) { - var tabs = ['precon', 'mydecks', 'import']; + var tabs = ['precon', 'mydecks']; tabs.forEach(function(t) { var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx); var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx); @@ -12641,51 +12689,6 @@ function selectSetupPrecon(playerIdx) { if (statusEl) { statusEl.textContent = 'Precon: ' + preconName; statusEl.className = 'deck-select-status loaded'; } } -function importSetupDeck(playerIdx) { - var textarea = document.getElementById('setup-deck-import-text-' + playerIdx); - var statusEl = document.getElementById('setup-deck-status-' + playerIdx); - if (!textarea) return; - var text = textarea.value.trim(); - if (!text) { - if (statusEl) { statusEl.textContent = 'Paste a deck list first.'; statusEl.className = 'deck-select-status'; } - return; - } - var parsed = parseDeckList(text); - if (!parsed.length) { - if (statusEl) { statusEl.textContent = 'Could not parse cards from list.'; statusEl.className = 'deck-select-status'; } - return; - } - var total = parsed.reduce(function(sum, e) { return sum + e.qty; }, 0); - setupDeckChoices[playerIdx] = { type: 'import', text: text }; - if (statusEl) { statusEl.textContent = 'Deck ready (' + total + ' cards)'; statusEl.className = 'deck-select-status loaded'; } -} - -function selectSetupCustomDeck(playerIdx, deckId) { - var decks = (typeof getDecks === 'function') ? getDecks() : []; - var deck = decks.find(function(d) { return d.id === deckId; }); - if (!deck) return; - var statusEl = document.getElementById('setup-deck-status-' + playerIdx); - - // Highlight selected button, unhighlight others - var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx); - if (listEl) { - listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); }); - } - var btns = document.querySelectorAll('[id^="setup-custom-deck-btn-' + playerIdx + '-"]'); - btns.forEach(function(b) { b.classList.remove('selected'); }); - // Find the right button by matching deckId in onclick - btns.forEach(function(b) { - if (b.getAttribute('onclick') && b.getAttribute('onclick').indexOf(deckId) !== -1) { - b.classList.add('selected'); - } - }); - - setupDeckChoices[playerIdx] = { type: 'custom', deckId: deckId }; - var cardCount = 0; - if (deck.cards) deck.cards.forEach(function(c) { cardCount += (c.quantity || 1); }); - if (deck.commander) cardCount++; - if (statusEl) { statusEl.textContent = deck.name + ' (' + cardCount + ' cards)'; statusEl.className = 'deck-select-status loaded'; } -} async function loadCustomDeckForPlayer(playerIdx, deckId) { var decks = (typeof getDecks === 'function') ? getDecks() : []; @@ -12800,6 +12803,28 @@ async function loadPreconForPlayer(playerIdx, preconId) { addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)'); } +async function loadUserDeckForPlayer(playerIdx, deckName) { + var player = gameState.players[playerIdx]; + if (!player || !deckName) return; + + // Fetch the .dck file content from user-decks/ via static mount or API + var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; + var deckText = ''; + try { + var resp = await fetch(apiBase + '/user-decks/' + encodeURIComponent(deckName) + '.dck'); + if (resp.ok) deckText = await resp.text(); + } catch (e) { + console.warn('[loadUserDeckForPlayer] Could not fetch .dck file:', e); + } + if (!deckText) { + addLogEntry('\u26A0 Could not load user deck "' + deckName + '"'); + return; + } + + await importDeckForPlayer(playerIdx, deckText); + player.deckFileName = deckName + '.dck'; +} + async function importDeckForPlayer(playerIdx, deckText) { var player = gameState.players[playerIdx]; if (!player || !deckText.trim()) return; @@ -12899,8 +12924,8 @@ async function startGameWithDecks() { await loadPreconForPlayer(hd.idx, hd.choice.preconId); } else if (hd.choice.type === 'custom') { await loadCustomDeckForPlayer(hd.idx, hd.choice.deckId); - } else if (hd.choice.type === 'import') { - await importDeckForPlayer(hd.idx, hd.choice.text); + } else if (hd.choice.type === 'user-deck') { + await loadUserDeckForPlayer(hd.idx, hd.choice.deckName); } } } catch (err) { diff --git a/lan-client/ui.js b/lan-client/ui.js index 87b27a2..57eae6b 100644 --- a/lan-client/ui.js +++ b/lan-client/ui.js @@ -1407,18 +1407,83 @@ function switchDeckTab(playerIdx, tab) { case 'mydecks': content.innerHTML = '
' + - '
' + - '
Import a decklist:
' + - '' + - '' + + '
' + + '
Loading user decks...
' + '
' + '
'; + _loadMyDecksTab(playerIdx); break; } } +function _loadMyDecksTab(playerIdx) { + var listEl = document.getElementById('mydecks-list-' + playerIdx); + if (!listEl) return; + + var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; + fetch(apiBase + '/api/lab/decks') + .then(function(res) { return res.json(); }) + .then(function(data) { + var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; }); + if (decks.length === 0) { + listEl.innerHTML = '
No saved decks. Use the Deck Builder or Auto Gen to create one.
'; + return; + } + + var html = ''; + decks.forEach(function(d) { + var name = d.name || 'Untitled'; + var cmdr = d.commander || d.commander_name || ''; + var count = d.card_count || d.total_cards || 0; + html += ''; + }); + listEl.innerHTML = html; + }) + .catch(function(e) { + console.warn('[_loadMyDecksTab] Error:', e); + listEl.innerHTML = '
Could not load decks. Is the server running?
'; + }); +} + +function selectUserDeck(playerIdx, deckId, deckName) { + var statusEl = document.getElementById('deck-status-' + playerIdx); + if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; } + + // Highlight selected button + var listEl = document.getElementById('mydecks-list-' + playerIdx); + if (listEl) { + listEl.querySelectorAll('.deck-precon-item').forEach(function(b) { b.classList.remove('selected'); }); + // Find the clicked button + listEl.querySelectorAll('.deck-precon-item').forEach(function(b) { + if (b.querySelector('.deck-precon-name') && b.querySelector('.deck-precon-name').textContent === deckName) { + b.classList.add('selected'); + } + }); + } + + // Export to .dck then store the choice + var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; + fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' }) + .then(function(res) { return res.json(); }) + .then(function(result) { + if (result.success) { + if (typeof setupDeckChoices !== 'undefined') { + setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' }; + } + if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; } + } else { + if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; } + } + }) + .catch(function(e) { + console.error('[selectUserDeck] Export failed:', e); + if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; } + }); +} + function filterPrecons(playerIdx, query) { var list = document.getElementById('precon-list-' + playerIdx); if (!list) return; diff --git a/models/state.py b/models/state.py index ad16e69..2bbd288 100644 --- a/models/state.py +++ b/models/state.py @@ -35,6 +35,7 @@ class Config: forge_jar: str = "" forge_dir: str = "" forge_decks_dir: str = "" + user_decks_dir: str = "" lab_jar: str = "" precon_dir: str = "" results_dir: str = "results" @@ -57,6 +58,10 @@ CFG.lab_jar = os.environ.get("LAB_JAR", "") # PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "") CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "") +CFG.user_decks_dir = os.environ.get( + "USER_DECKS_DIR", + os.path.join(os.path.dirname(__file__), "..", "user-decks") +) def validate_forge_config() -> None: diff --git a/routes/deckbuilder.py b/routes/deckbuilder.py index 6c8384a..33e8fbf 100644 --- a/routes/deckbuilder.py +++ b/routes/deckbuilder.py @@ -796,10 +796,12 @@ async def export_deck_to_sim(deck_id: int): if not safe_name: safe_name = f"deck_{deck_id}" - save_dir = CFG.forge_decks_dir - if not save_dir or not os.path.isdir(save_dir): - save_dir = os.path.join(os.path.dirname(__file__), "exported-decks") - os.makedirs(save_dir, exist_ok=True) + save_dir = ( + CFG.user_decks_dir + or CFG.forge_decks_dir + or os.path.join(os.path.dirname(__file__), "..", "user-decks") + ) + os.makedirs(save_dir, exist_ok=True) out_path = Path(save_dir) / f"{safe_name}.dck" out_path.write_text(content, encoding="utf-8") diff --git a/routes/deckgen.py b/routes/deckgen.py index 6ccdf91..87b98df 100644 --- a/routes/deckgen.py +++ b/routes/deckgen.py @@ -1320,7 +1320,7 @@ async def deck_gen_v3_status(): return { 'initialized': _coach._deck_gen_v3 is not None, 'anthropic_configured': bool(CFG.anthropic_api_key), - 'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None, + 'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'claude-opus-4-20250514')) if _coach._deck_gen_v3 else None, 'embeddings_loaded': False, 'embedding_cards': 0, 'error': _coach._deck_gen_v3_error, @@ -1341,7 +1341,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request): 6. Return complete deck with substitution data """ if _coach._deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.') + raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure the Anthropic API key is configured.') if not req.commander_name or len(req.commander_name.strip()) < 2: raise HTTPException(400, 'Commander name is required (min 2 chars)') diff --git a/routes/lab.py b/routes/lab.py index 7c42c02..96ce72e 100644 --- a/routes/lab.py +++ b/routes/lab.py @@ -259,7 +259,14 @@ async def list_decks(): try: conn = _get_db_conn() rows = conn.execute( - "SELECT id, name, commander_name FROM decks ORDER BY id DESC" + """ + SELECT d.id, d.name, d.commander_name, + COALESCE(SUM(dc.quantity), 0) AS total_cards + FROM decks d + LEFT JOIN deck_cards dc ON dc.deck_id = d.id + GROUP BY d.id + ORDER BY d.id DESC + """ ).fetchall() for row in rows: db_name = row["name"] @@ -270,6 +277,7 @@ async def list_decks(): "source": "deckbuilder", "deck_id": row["id"], "commander": row["commander_name"] or "", + "totalCards": row["total_cards"], }) seen_names.add(db_name.lower()) except Exception as e: diff --git a/services/deck_service.py b/services/deck_service.py index 9b2c0a2..6fd4f56 100644 --- a/services/deck_service.py +++ b/services/deck_service.py @@ -21,6 +21,7 @@ _BUILTIN_DECK_DIRS = [ _REPO_ROOT / "precon-decks", _REPO_ROOT / "sample-decks", _REPO_ROOT / "imported-decks", + _REPO_ROOT / "user-decks", ] @@ -352,7 +353,23 @@ def _load_deck_cards_by_name(deck_name: str) -> list: ) cr ON cr.name = dc.card_name WHERE dc.deck_id = ? """, (deck_id,)).fetchall() - return [_row_to_dict(r) for r in rows] + result = [_row_to_dict(r) for r in rows] + + # Enrich cards missing type_line from Forge card cache + try: + from commander_ai_lab.sim.forge_card_loader import lookup_forge_card + for card in result: + if not card.get("type_line"): + forge = lookup_forge_card(card.get("card_name", "")) + if forge: + card["type_line"] = forge.type_line + card["oracle_text"] = forge.oracle_text + card["cmc"] = forge.cmc + card["mana_cost"] = forge.mana_cost + except ImportError: + pass # Forge card loader not available + + return result # -- 2. .dck file fallback -- dck_path = _find_dck_file(deck_name) diff --git a/services/forge_runner.py b/services/forge_runner.py index 3892106..7b9af7a 100644 --- a/services/forge_runner.py +++ b/services/forge_runner.py @@ -449,7 +449,7 @@ def _get_deepseek_brain(): from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig cfg = DeepSeekConfig( api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"), - model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"), + model=os.environ.get("BRAIN_MODEL", "qwen2.5:7b"), temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")), max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")), request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")), diff --git a/src/commander_ai_lab/deck_builder/adapter.py b/src/commander_ai_lab/deck_builder/adapter.py index d849a28..949ab1d 100644 --- a/src/commander_ai_lab/deck_builder/adapter.py +++ b/src/commander_ai_lab/deck_builder/adapter.py @@ -26,7 +26,7 @@ class DeckBuilderAdapter: def __init__( self, db_conn_factory: Callable[[], sqlite3.Connection] = None, - model: str = "gpt-oss:20b", + model: str = "claude-opus-4-20250514", ): self.db_conn_factory = db_conn_factory self.model = model diff --git a/src/commander_ai_lab/deck_builder/api/ollama_client.py b/src/commander_ai_lab/deck_builder/api/ollama_client.py index 78ff8f5..9fccb3a 100644 --- a/src/commander_ai_lab/deck_builder/api/ollama_client.py +++ b/src/commander_ai_lab/deck_builder/api/ollama_client.py @@ -1,5 +1,5 @@ """ -Ollama gpt-oss:20b client for the Commander AI Deck Builder. +Ollama client for the Commander AI Deck Builder. Uses the OpenAI-compatible API exposed by Ollama at localhost:11434. Provides 6 core functions: diff --git a/src/commander_ai_lab/deck_builder/main.py b/src/commander_ai_lab/deck_builder/main.py index 0a23758..6f78f7b 100644 --- a/src/commander_ai_lab/deck_builder/main.py +++ b/src/commander_ai_lab/deck_builder/main.py @@ -87,8 +87,8 @@ def cli() -> None: ) parser.add_argument( "--model", - default="gpt-oss:20b", - help="Ollama model name (default: gpt-oss:20b)", + default="claude-opus-4-20250514", + help="AI model name (default: claude-opus-4-20250514)", ) parser.add_argument( "--output", diff --git a/tests/deck_builder/test_adapter.py b/tests/deck_builder/test_adapter.py index 168ddd5..37ea1c8 100644 --- a/tests/deck_builder/test_adapter.py +++ b/tests/deck_builder/test_adapter.py @@ -32,7 +32,7 @@ def _fake_build_result(): class TestDeckBuilderAdapter: def test_init_defaults(self): adapter = DeckBuilderAdapter() - assert adapter.model == "gpt-oss:20b" + assert adapter.model == "claude-opus-4-20250514" assert adapter.db_conn_factory is None @patch("src.commander_ai_lab.deck_builder.adapter.build_deck") @@ -45,7 +45,7 @@ class TestDeckBuilderAdapter: assert result["commander"]["name"] == "Atraxa" assert "cards" in result assert "stats" in result - assert result["model"] == "gpt-oss:20b" + assert result["model"] == "claude-opus-4-20250514" assert result["build_time_seconds"] == 1.23 assert "scryfall" in result["reasoning"]["sources"] diff --git a/ui/ai-lab.js b/ui/ai-lab.js index bb0c9b5..626ec83 100644 --- a/ui/ai-lab.js +++ b/ui/ai-lab.js @@ -93,24 +93,24 @@ const AiLab = (() => {

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

- -
-
Import Deck
+ +
+ \u2B07 Advanced Import (URL / Text / Precon)
-
+
@@ -403,7 +403,9 @@ const AiLab = (() => { const data = await res.json(); decks = (data.decks || []).map(d => ({ name: d.name, - commander: d.commander || d.name.replace(/_/g, ' '), + commander: d.commander || d.commander_name || d.name.replace(/_/g, ' '), + source: d.source || 'forge', + deck_id: d.deck_id || null, })); } } catch (err) { @@ -427,9 +429,10 @@ const AiLab = (() => { const prev = select.value; select.innerHTML = ''; - // Group: imported decks first - const imported = availableDecks.filter(d => importedDeckMeta[d.name]); - const local = availableDecks.filter(d => !importedDeckMeta[d.name]); + // Split into three groups + const imported = availableDecks.filter(d => importedDeckMeta[d.name]); + const myDecks = availableDecks.filter(d => d.source === 'deckbuilder' && !importedDeckMeta[d.name]); + const forgeDecks = availableDecks.filter(d => d.source !== 'deckbuilder' && !importedDeckMeta[d.name]); if (imported.length > 0) { const group = document.createElement('optgroup'); @@ -446,10 +449,23 @@ const AiLab = (() => { select.appendChild(group); } - if (local.length > 0) { + if (myDecks.length > 0) { + const group = document.createElement('optgroup'); + group.label = '\uD83D\uDDC2 My Decks (Deck Builder)'; + myDecks.forEach(deck => { + const opt = document.createElement('option'); + opt.value = deck.name; + opt.textContent = deck.name + (deck.commander ? ` \u2014 ${deck.commander}` : ''); + opt.dataset.commander = deck.commander || ''; + group.appendChild(opt); + }); + select.appendChild(group); + } + + if (forgeDecks.length > 0) { const group = document.createElement('optgroup'); group.label = 'Local Decks'; - local.forEach(deck => { + forgeDecks.forEach(deck => { const opt = document.createElement('option'); opt.value = deck.name; opt.textContent = deck.name; diff --git a/ui/deckgenerator.html b/ui/deckgenerator.html index 1f6ca15..c1526e2 100644 --- a/ui/deckgenerator.html +++ b/ui/deckgenerator.html @@ -142,11 +142,10 @@
- + + claude-opus-4-20250514
@@ -196,7 +195,7 @@ - - + + diff --git a/ui/deckgenerator.js b/ui/deckgenerator.js index 3ce0dfa..71efc4c 100644 --- a/ui/deckgenerator.js +++ b/ui/deckgenerator.js @@ -159,7 +159,7 @@ const DeckGenerator = (() => { if (!state.v3Ready) { // Warn but do not block — local/Ollama mode may still work console.warn('V3 status: not initialized. Error:', data.error || 'unknown'); - toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning'); + toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning'); } } catch (e) { console.warn('V3 status check failed:', e); @@ -386,7 +386,7 @@ const DeckGenerator = (() => { // ── Generate Deck ─────────────────────────────────────── async function generateDeck() { clearLog(); - logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info'); + logMessage('Connecting to Claude AI...', 'info'); if (!state.commander) { toast('Select a commander first', 'error'); return; } // Non-blocking warning only — local Ollama mode does not require v3Ready if (!state.v3Ready) { @@ -435,7 +435,7 @@ const DeckGenerator = (() => { state.isLoading = loading; $('dg-empty-state').style.display = 'none'; $('dg-loading').style.display = loading ? 'flex' : 'none'; - $('dg-results').style.display = loading ? 'none' : 'none'; + $('dg-results').style.display = loading ? 'none' : 'block'; $('dg-preview-btn').disabled = loading || !state.commander; if (text) $('dg-loading-text').textContent = text; } @@ -454,6 +454,7 @@ const DeckGenerator = (() => { function renderStrategyBar(result) { const bar = $('dg-strategy-bar'); + if (!bar) return; const bracket = result.bracket || {}; const archetype = result.archetype || ''; const strategy = result.strategy_summary || ''; @@ -472,6 +473,7 @@ const DeckGenerator = (() => { function renderStatsBar(result) { const stats = result.stats || {}; const container = $('dg-stats-bar'); + if (!container) return; const chips = [ chipHtml('Total', stats.total_cards || 0, ''), @@ -507,6 +509,7 @@ const DeckGenerator = (() => { function renderCardGroups(result) { const container = $('dg-card-groups'); + if (!container) return; const cards = result.cards || []; const commander = result.commander; diff --git a/ui/simulator.js b/ui/simulator.js index 8cb5196..eba33cc 100644 --- a/ui/simulator.js +++ b/ui/simulator.js @@ -39,16 +39,41 @@ // ── Init: load decks ── async function loadDecks() { try { - const res = await fetch('/api/decks'); + const res = await fetch('/api/lab/decks'); if (!res.ok) return; const data = await res.json(); - const decks = data.decks || data || []; - decks.forEach(d => { - const opt = document.createElement('option'); - opt.value = d.id; - opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : ''); - deckSelect.appendChild(opt); - }); + const decks = data.decks || []; + + // Split into groups by source + const dbDecks = decks.filter(d => d.source === 'deckbuilder'); + const forgeDecks = decks.filter(d => d.source === 'forge'); + + // My Decks — from Deck Builder (stored in SQLite) + if (dbDecks.length > 0) { + const group = document.createElement('optgroup'); + group.label = '\u{1F0CF} My Decks (Deck Builder)'; + dbDecks.forEach(d => { + const opt = document.createElement('option'); + opt.value = 'db:' + d.deck_id; + opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : ''); + group.appendChild(opt); + }); + deckSelect.appendChild(group); + } + + // Forge .dck files (user-decks/, precon-decks/, sample-decks/) + if (forgeDecks.length > 0) { + const group = document.createElement('optgroup'); + group.label = '\u{1F4C1} Deck Files (.dck)'; + forgeDecks.forEach(d => { + const opt = document.createElement('option'); + opt.value = d.name; + opt.textContent = d.name.replace(/_/g, ' '); + group.appendChild(opt); + }); + deckSelect.appendChild(group); + } + } catch (e) { console.warn('Could not load decks:', e); } @@ -165,18 +190,17 @@ // ── Run from deck ID ── runBtn.addEventListener('click', async () => { - const deckId = parseInt(deckSelect.value); - if (!deckId) return; + const val = deckSelect.value; + if (!val) return; - const numGames = parseInt(numGamesInput.value) || 10; + const numGames = parseInt(numGamesInput.value) || 10; const recordLogs = recordLogsChk.checked; const useDeepSeek = oppSelect.value === 'deepseek'; - // If DeepSeek selected but not connected, try to connect first if (useDeepSeek && !dsConnected) { await connectDeepSeek(); if (!dsConnected) { - alert('Could not connect to DeepSeek. Make sure LM Studio is running at ' + dsApiBase.value); + alert('Could not connect to DeepSeek. Make sure Ollama is running at ' + dsApiBase.value); return; } await applyDeepSeekConfig(); @@ -189,16 +213,35 @@ hideResults(); try { - const endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck'; + let endpoint, body; + + if (val.startsWith('db:')) { + // Deck Builder deck — auto-export to user-decks/ first + const deckId = parseInt(val.replace('db:', '')); + + const exportRes = await fetch(`/api/decks/${deckId}/export-to-sim`, { method: 'POST' }); + const exportData = await exportRes.json(); + if (!exportData.success) throw new Error('Export failed: ' + (exportData.detail || 'unknown')); + + endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck'; + body = { deckId, numGames, recordLogs }; + + } else { + // File-based deck (already a .dck) + endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck'; + body = { deckName: val, numGames, recordLogs }; + } + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deckId, numGames, recordLogs }), + body: JSON.stringify(body), }); const data = await res.json(); if (data.error) throw new Error(data.error); currentSimId = data.simId; startPolling(); + } catch (e) { alert('Error: ' + e.message); resetRunBtn(); diff --git a/user-decks/.gitkeep b/user-decks/.gitkeep new file mode 100644 index 0000000..e69de29