feat: deck dropdown, user-decks pipeline, deckgen fixes, gpt-oss removal

- Add user-decks/ directory and USER_DECKS_DIR config for exported decks
- Update export-to-sim to write to user-decks/ with static mount in lab_api
- Replace import/paste UI with API-backed "My Decks" dropdown in LAN client
  and AI Lab, fetching from GET /api/lab/decks with source grouping
- Fix GET /api/lab/decks to JOIN deck_cards for totalCards count
- Fix deckgenerator: add missing DOM elements (dg-strategy-bar, dg-stats-bar,
  dg-card-groups) inside #dg-results, null guards on render functions,
  fix setLoading always hiding results
- Replace gpt-oss:20b with claude-opus-4-20250514 across all files
- Add user-decks/ to _BUILTIN_DECK_DIRS in deck_service.py
- Enrich missing type_line from Forge card cache in DB deck path
- Collapse import bar behind <details> toggle in AI Lab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rgriffithandClaude Opus 4.6 committed 2026-04-05 01:17:30 -04:00
1 parent 409aba26f4
commit 9eaa41a4ae
23 files changed
+369 -145

No files matched your search

+2 -1
View File
@@ -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
+3
View File
@@ -32,6 +32,9 @@ embeddings/
checkpoints/
imported-decks/
# User-created decks (generated at runtime, not committed)
user-decks/*.dck
# OS
.DS_Store
Thumbs.db
+1 -1
View File
@@ -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
+5
View File
@@ -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():
+21 -7
View File
@@ -146,11 +146,10 @@
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
</div>
</div>
</div>
@@ -200,7 +199,7 @@
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
@@ -212,8 +211,23 @@
</div>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<div class="dg-results-header">
<div id="dg-strategy-bar"></div>
<div id="dg-stats-bar" class="dg-stats-bar"></div>
<div id="dg-sub-summary" style="display:none"></div>
<div class="dg-result-actions">
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; Save to Deck Builder</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">&#128722; Shopping</button>
</div>
</div>
<div id="dg-card-groups" class="dg-card-groups"></div>
</div>
</section>
</main>
+6 -3
View File
@@ -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;
+104 -79
View File
@@ -12556,32 +12556,6 @@ function renderDeckSelectionGrid() {
'<div style="color:var(--color-text-secondary);font-size:var(--text-sm);">AI Deck (auto-generated)</div>';
} else {
var idx = i;
// Build saved decks options
var savedDecks = (typeof getDecks === 'function') ? getDecks() : [];
var savedDecksHtml = '';
if (savedDecks.length === 0) {
savedDecksHtml = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
} else {
savedDecksHtml = '<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">';
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 += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + idx + '-' + di + '"' +
' onclick="selectSetupCustomDeck(' + idx + ',\'' + sd.id.replace(/'/g, "\\'") + '\')">' +
'<span class="setup-custom-deck-name">' + (sd.name || 'Untitled').replace(/</g, '&lt;') + '</span>' +
'<span class="setup-custom-deck-meta">' + deckColors + cardCount + ' cards</span>' +
'</button>';
}
savedDecksHtml += '</div>';
}
panel.innerHTML =
'<div class="deck-select-panel-header">' +
'<div class="player-color-dot" style="background:' + color + '"></div>' +
@@ -12591,7 +12565,6 @@ function renderDeckSelectionGrid() {
'<div class="deck-select-tabs">' +
'<button class="deck-select-tab active" id="setup-deck-tab-precon-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'precon\')">Precon</button>' +
'<button class="deck-select-tab" id="setup-deck-tab-mydecks-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'mydecks\')">My Decks</button>' +
'<button class="deck-select-tab" id="setup-deck-tab-import-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'import\')">Import</button>' +
'</div>' +
'<div class="deck-select-tab-content active" id="setup-deck-content-precon-' + idx + '">' +
'<select class="deck-select-precon-select" id="setup-deck-precon-select-' + idx + '" onchange="selectSetupPrecon(' + idx + ')">' +
@@ -12599,20 +12572,95 @@ function renderDeckSelectionGrid() {
'</select>' +
'</div>' +
'<div class="deck-select-tab-content" id="setup-deck-content-mydecks-' + idx + '">' +
savedDecksHtml +
'<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">' +
'<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Loading user decks...</div>' +
'</div>' +
'<div class="deck-select-tab-content" id="setup-deck-content-import-' + idx + '">' +
'<textarea class="deck-select-import-text" id="setup-deck-import-text-' + idx + '" placeholder="Paste deck list here...&#10;&#10;// Commander&#10;1 Atraxa, Praetors\' Voice&#10;&#10;// Deck&#10;1 Sol Ring&#10;..."></textarea>' +
'<button class="deck-select-import-btn" onclick="importSetupDeck(' + idx + ')">\uD83D\uDCE5 Validate &amp; Use This List</button>' +
'</div>';
}
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 = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
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 += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + i + '-' + di + '"' +
' onclick="selectSetupUserDeck(' + i + ',' + d.deck_id + ',\'' + (d.name || 'Untitled').replace(/'/g, "\\'") + '\')">' +
'<span class="setup-custom-deck-name">' + (d.name || 'Untitled').replace(/</g, '&lt;') + '</span>' +
'<span class="setup-custom-deck-meta">' + (cmdr ? cmdr + ' \u2014 ' : '') + cardCount + ' cards</span>' +
'</button>';
}
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 = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Could not load decks. Is the server running?</div>';
}
});
}
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 <strong>' + player.name + '</strong> 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) {
+71 -6
View File
@@ -1407,18 +1407,83 @@ function switchDeckTab(playerIdx, tab) {
case 'mydecks':
content.innerHTML =
'<div class="deck-mydecks-content">' +
'<div class="deck-tab-empty">' +
'<div style="margin-bottom:8px;">Import a decklist:</div>' +
'<textarea class="deck-import-textarea" id="deck-import-' + playerIdx + '" ' +
'placeholder="Paste decklist here...\n1 Sol Ring\n1 Command Tower\n..."></textarea>' +
'<button class="btn btn-sm" style="margin-top:6px;" ' +
'onclick="importDeckFromTextarea(' + playerIdx + ')">Import Decklist</button>' +
'<div class="deck-mydecks-list" id="mydecks-list-' + playerIdx + '">' +
'<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Loading user decks...</div>' +
'</div>' +
'</div>';
_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 = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">No saved decks. Use the Deck Builder or Auto Gen to create one.</div>';
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 += '<button class="deck-precon-item" onclick="selectUserDeck(' + playerIdx + ',' + d.deck_id + ',\'' + name.replace(/'/g, "\\'") + '\')">' +
'<span class="deck-precon-name">' + name.replace(/</g, '&lt;') + '</span>' +
'<span class="deck-precon-cmdr">' + (cmdr ? cmdr.replace(/</g, '&lt;') + ' \u2014 ' : '') + count + ' cards</span>' +
'</button>';
});
listEl.innerHTML = html;
})
.catch(function(e) {
console.warn('[_loadMyDecksTab] Error:', e);
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Could not load decks. Is the server running?</div>';
});
}
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;
+5
View File
@@ -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:
+5 -3
View File
@@ -796,9 +796,11 @@ 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")
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"
+2 -2
View File
@@ -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)')
+9 -1
View File
@@ -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:
+18 -1
View File
@@ -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)
+1 -1
View File
@@ -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")),
+1 -1
View File
@@ -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
@@ -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:
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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"]
+29 -13
View File
@@ -93,24 +93,24 @@ const AiLab = (() => {
</div>
<p class="lab-subtitle">Headless 3-AI batch simulation — stress-test your decks at scale</p>
<!-- v3: Import Bar -->
<div class="lab-import-bar">
<div class="lab-import-bar-label">Import Deck</div>
<!-- v3: Import Bar (collapsed by default) -->
<details class="lab-import-bar">
<summary class="lab-import-bar-label" style="cursor:pointer;">\u2B07 Advanced Import (URL / Text / Precon)</summary>
<div class="lab-import-actions">
<button class="lab-import-action-btn" onclick="AiLab.showUrlImport()" title="Import from Archidekt or EDHREC URL">
<span class="import-icon">🔗</span> URL Import
<span class="import-icon">\uD83D\uDD17</span> URL Import
</button>
<button class="lab-import-action-btn" onclick="AiLab.showMetaPicker()" title="Browse EDHREC average decks for popular commanders">
<span class="import-icon">📊</span> Commander Meta
<span class="import-icon">\uD83D\uDCCA</span> Commander Meta
</button>
<button class="lab-import-action-btn" onclick="AiLab.showTextImport()" title="Paste a card list directly">
<span class="import-icon">📋</span> Text Import
<span class="import-icon">\uD83D\uDCCB</span> Text Import
</button>
<button class="lab-import-action-btn lab-precon-btn" onclick="AiLab.showPreconPicker()" title="Browse and load official Commander precon decks">
<span class="import-icon">🎴</span> Precon Decks
<span class="import-icon">\uD83C\uDCB4</span> Precon Decks
</button>
</div>
</div>
</details>
<!-- v3: URL Import Panel (hidden by default) -->
<div class="lab-url-import" id="lab-url-import">
@@ -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 = '<option value="">— Select Deck —</option>';
// Group: imported decks first
// Split into three groups
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const local = 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;
+21 -7
View File
@@ -142,11 +142,10 @@
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
</div>
</div>
</div>
@@ -196,7 +195,7 @@
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
@@ -208,8 +207,23 @@
</div>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<div class="dg-results-header">
<div id="dg-strategy-bar"></div>
<div id="dg-stats-bar" class="dg-stats-bar"></div>
<div id="dg-sub-summary" style="display:none"></div>
<div class="dg-result-actions">
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; Save to Deck Builder</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">&#128722; Shopping</button>
</div>
</div>
<div id="dg-card-groups" class="dg-card-groups"></div>
</div>
</section>
</main>
+6 -3
View File
@@ -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;
+54 -11
View File
@@ -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 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 = d.id;
opt.value = 'db:' + d.deck_id;
opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : '');
deckSelect.appendChild(opt);
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 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();
View File
Whitespace-only changes.