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:
1 parent
409aba26f4
commit
9eaa41a4ae
23 files changed
+376
-152
No files matched your search
+2
-1
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 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">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+105
-80
@@ -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, '<') + '</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>' +
|
||||
'<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... // Commander 1 Atraxa, Praetors\' Voice // Deck 1 Sol Ring ..."></textarea>' +
|
||||
'<button class="deck-select-import-btn" onclick="importSetupDeck(' + idx + ')">\uD83D\uDCE5 Validate & Use This List</button>' +
|
||||
'<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>';
|
||||
}
|
||||
|
||||
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, '<') + '</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
@@ -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, '<') + '</span>' +
|
||||
'<span class="deck-precon-cmdr">' + (cmdr ? cmdr.replace(/</g, '<') + ' \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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
+30
-14
@@ -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
|
||||
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;
|
||||
|
||||
+21
-7
@@ -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">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 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">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+6
-3
@@ -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;
|
||||
|
||||
|
||||
+58
-15
@@ -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();
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user