Compare commits

..
Author SHA1 Message Date
rgriffithandClaude Opus 4.6 04f479dcd0 revert: undo all card size changes, restore original layout
Reverts style.css and index.html to their state before any
card sizing modifications were made.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:27:18 -04:00
rgriffithandClaude Opus 4.6 0e49f46513 fix: force hand card size with explicit px and min-height
Use hardcoded 128x180px with min-width/min-height on .hand-thumb
to prevent flex compression. Set hand zone min-height to 180px,
align-items: stretch, and overflow-x: auto so cards render at
full size and scroll horizontally if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:19:01 -04:00
rgriffithandClaude Opus 4.6 b828c474c7 fix: bump CSS cache-busting version to force reload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:15:21 -04:00
rgriffithandClaude Opus 4.6 105c8db4ab fix: double hand card size again to fill vertical space
Hand cards (--hand-card-w/h) bumped from 64x90 to 128x180
so they fill the hand zone height. Tablet breakpoint updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:09:45 -04:00
rgriffithandClaude Opus 4.6 a5a66a81f2 fix: correct card sizing — shrink zones, enlarge hand cards
Red zones (Library/Exile/Grave/Commander): halved from original
(63→32, 88→44). Blue zone (hand cards): doubled from original
(32→64, 45→90) using new --hand-card-w/--hand-card-h variables
so hand sizing is decoupled from zone sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:05:33 -04:00
rgriffithandClaude Opus 4.6 bce0d50e39 feat: enlarge hand cards and commander zone 200%
Double card-w/card-h CSS variables (63→126, 88→176) and hand-thumb
fixed dimensions (32→64, 45→90) so hand and commander cards render
at 200% of their original size. Tablet breakpoint updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:52:32 -04:00
87 changed files with 604 additions and 21204 deletions

No files matched your search

+3 -4
View File
@@ -8,12 +8,12 @@ XIMILAR_API_KEY=your-ximilar-key-here
# ── Forge JAR Path ────────────────────────────────────────────────
# Point this to your Forge GUI desktop fat JAR.
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
FORGE_JAR_PATH=D:/ForgeCommander/forge-repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
# The JAR looks for res/ relative to its working directory. Without this, you get
# ExceptionInInitializerError on startup.
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui
# ── Deck Paths ────────────────────────────────────────────────────
# Directory containing your precon .dck files.
@@ -23,5 +23,4 @@ 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
USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
-15
View File
@@ -32,24 +32,9 @@ embeddings/
checkpoints/
imported-decks/
# User-created decks (generated at runtime, not committed)
user-decks/*.dck
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Runtime artifacts / caches
static/
api/
image_cache/
data/rag_chroma/
.claude/
dependency-reduced-pom.xml
# Precon decks (generated / imported at runtime)
precon-decks/
+2 -2
View File
@@ -171,13 +171,13 @@ OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from pho
| Maven | 3.8+ |
| Node.js | 18+ (for LAN client scripts) |
| Ollama | Latest (for local LLM inference) |
| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` |
| [Forge](https://github.com/Card-Forge/forge) | Built at `D:\ForgeCommander\forge-repo` |
### Setup (Windows)
```powershell
# 1. Build Forge (one-time)
cd D:\ForgeCommander\Forge-Repo
cd D:\ForgeCommander\forge-repo
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
# 2. Build Commander AI Lab
+4 -4
View File
@@ -77,7 +77,7 @@ class CoachService:
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r", encoding="utf-8") as f:
with open(report_path, "r") as f:
data = json.load(f)
return DeckReport(**data)
except Exception as e:
@@ -95,7 +95,7 @@ class CoachService:
"""Persist a coaching session to disk and SQLite."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
@@ -147,7 +147,7 @@ class CoachService:
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
with open(path, "r") as f:
data = json.load(f)
return CoachSession(**data)
except Exception as e:
@@ -168,7 +168,7 @@ class CoachService:
sessions = []
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
with open(f, "r") as fh:
data = json.load(fh)
if deck_id and data.get("deckId", "") != deck_id:
continue
+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", "claude-opus-4-20250514")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
-42
View File
@@ -1,42 +0,0 @@
with open('ml/eval/forge_evaluator.py', 'rb') as f:
data = f.read()
# Fix 1: absolute JAR path in subprocess call
data = data.replace(
b'"--forge-jar", self.cfg.forge_jar,',
b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),'
)
# Fix 2: resolve forge_work_dir to absolute path too
data = data.replace(
b'"--forge-dir", self.cfg.forge_work_dir,',
b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),'
)
# Fix 3: change default forge_work_dir
data = data.replace(
b'forge_work_dir: str = "forge"',
b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('ml/eval/forge_evaluator.py', 'wb') as f:
f.write(data)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('forge_evaluator.py - Syntax OK')
# Fix eval_policy.py too
with open('scripts/eval_policy.py', 'rb') as f:
data = f.read()
data = data.replace(
b'"--forge-dir", default="forge"',
b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('scripts/eval_policy.py', 'wb') as f:
f.write(data)
py_compile.compile('scripts/eval_policy.py', doraise=True)
print('eval_policy.py - Syntax OK')
+2 -10
View File
@@ -170,7 +170,6 @@ 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)
@@ -181,10 +180,6 @@ 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():
@@ -256,9 +251,7 @@ def _resolve_forge_dir() -> str:
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "Forge-Repo",
project_root.parent / "Forge-Repo" / "forge-gui",
project_root.parent / "forge-repo", # legacy fallback
project_root.parent / "forge-repo",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
@@ -284,8 +277,7 @@ def _resolve_forge_jar() -> str:
project_root = Path(__file__).parent
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
+17 -38
View File
@@ -366,11 +366,7 @@ class GameStateBridge {
seat_type: p.isAI
? this._mapAiProfile(gameState.aiDifficulty)
: 'Human',
deck_name: p.deckFileName || (
p.commanderCardName
? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
: ''
),
deck_name: p.commanderCardName || '',
player_name: p.name,
}));
@@ -398,13 +394,11 @@ class GameStateBridge {
*/
_mapAiProfile(difficulty) {
const map = {
easy: 'AI',
normal: 'AI Midrange',
hard: 'AI Control',
combo: 'AI Combo',
aggro: 'AI Aggro',
easy: 'AI',
normal: 'AI Aggro',
hard: 'AI Control',
};
return map[difficulty] || 'AI Midrange';
return map[difficulty] || 'AI';
}
// ── Game State Serialization ──────────────────────────────
@@ -457,10 +451,7 @@ class GameStateBridge {
// Zone contents — card names
hand: p.zones.hand.map(c => c.name || 'Unknown'),
hand_count: p.zones.hand.length,
// Flat string list for the state encoder
battlefield: bfCards.map(c => c.name || 'Unknown'),
// Structured data for backend logic that needs detail
battlefield_detail: bfCards.map(c => ({
battlefield: bfCards.map(c => ({
name: c.name || 'Unknown',
type: c.typeLine || '',
power: c.power || '0',
@@ -497,7 +488,7 @@ class GameStateBridge {
return {
game_id: this._sessionGameId || '',
turn: gameState.turnNumber || gameState.turnCounter || 1,
turn: gameState.turnCounter || 1,
phase: phaseMap[phaseId] || 'main_1',
active_player: playerIdx,
players: players,
@@ -514,9 +505,9 @@ class GameStateBridge {
* Get all battlefield cards for a player.
*/
_getBattlefieldCards(player, gameState) {
if (!gameState || !gameState.battlefieldCards) return [];
return gameState.battlefieldCards.filter(
c => c.ownerIndex === player.id
if (!gameState || !gameState.battlefield) return [];
return (gameState.battlefield || []).filter(
c => c.controller === player.id || c.owner === player.id
);
}
@@ -716,7 +707,7 @@ class MacroActionMapper {
if (castable.length === 0) return false;
castable.sort((a, b) => (MacroActionMapper._cmc(b) - MacroActionMapper._cmc(a)));
for (const card of castable) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -737,7 +728,7 @@ class MacroActionMapper {
});
for (const card of creatures) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -761,7 +752,7 @@ class MacroActionMapper {
if (removal.length === 0) return false;
for (const card of removal) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -790,7 +781,7 @@ class MacroActionMapper {
);
for (const card of drawSpells) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -835,7 +826,7 @@ class MacroActionMapper {
);
for (const card of rampSpells) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -893,24 +884,12 @@ class MacroActionMapper {
return 0;
}
static _canAfford(playerIdx, card, gameState) {
static _canAfford(playerIdx, card) {
if (!gameState) return false;
const cmc = MacroActionMapper._cmc(card);
if (cmc === 0) return true; // Free spells always castable
// Prefer actual mana pool if populated
const player = gameState.players[playerIdx];
const pool = player?.manaPool;
if (pool) {
const total = Object.values(pool).reduce((s, v) => s + (v || 0), 0);
if (total > 0) return total >= cmc;
}
// Fallback: count untapped lands
const available = typeof aiCountUntappedLands === 'function'
? aiCountUntappedLands(playerIdx)
: 0;
return available >= cmc;
return available >= MacroActionMapper._cmc(card);
}
static _creatureScore(card) {
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Collection</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="collection.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
<div id="collection-container"></div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="collection.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Collection.init();
});
</script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-495
View File
@@ -1,495 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Deck Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckbuilder.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
<div id="deck-builder-page">
<!-- TOP BAR -->
<header class="deck-topbar">
<div class="deck-topbar-commander">
<div class="deck-commander-portrait" id="deck-commander-portrait">
<span class="deck-commander-portrait-placeholder">⚔</span>
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
</div>
<div class="deck-name-block">
<input
type="text"
class="deck-name-input"
id="deck-name-input"
placeholder="Untitled Deck"
value=""
/>
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
</div>
<div class="deck-color-chips" id="deck-color-chips"></div>
</div>
<div class="deck-topbar-center">
<div class="deck-count-display">
<span class="deck-count-num" id="deck-total-count">0</span>
<span class="deck-count-sep">/</span>
<span class="deck-count-target">100</span>
<span class="deck-count-label">cards</span>
</div>
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
<div class="deck-selector-wrap">
<select class="deck-selector" id="deck-selector">
<option value="">— Select Deck —</option>
</select>
</div>
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
</div>
<div class="deck-topbar-actions">
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
<span>⬆</span> Import
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
<span>⬇</span> Export .dck
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
<span>&#x1F4E4;</span> Export to Sim
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
<span>&#x1F3AE;</span> Use in Game
</button>
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
<span class="deck-btn-icon">▶</span> Run Simulation
</button>
</div>
</header>
<!-- MAIN 3-COLUMN LAYOUT -->
<main class="deck-main">
<!-- LEFT COLUMN: Collection Search -->
<aside class="deck-col-left">
<div class="deck-panel deck-panel-search">
<div class="deck-panel-header">
<span class="deck-panel-title">Collection Search</span>
<span class="deck-search-count" id="deck-search-count">0 results</span>
</div>
<div class="deck-search-bar-wrap">
<input
type="search"
class="deck-search-bar"
id="deck-search-bar"
placeholder="Search cards..."
autocomplete="off"
/>
<span class="deck-search-icon">🔍</span>
</div>
<div class="deck-filter-row">
<select class="deck-filter-select" id="deck-type-filter">
<option value="">All Types</option>
<option value="Land">Land</option>
<option value="Creature">Creature</option>
<option value="Instant">Instant</option>
<option value="Sorcery">Sorcery</option>
<option value="Artifact">Artifact</option>
<option value="Enchantment">Enchantment</option>
<option value="Planeswalker">Planeswalker</option>
</select>
<div class="deck-cmc-filter">
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
<span class="deck-cmc-sep">–</span>
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
</div>
</div>
<div class="deck-color-filter-row" id="deck-color-filter">
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
</div>
<!-- Results list -->
<div class="deck-search-results" id="deck-search-results">
<div class="deck-empty-state">
<div class="deck-empty-icon">🃏</div>
<div class="deck-empty-text">Search your collection to add cards</div>
</div>
</div>
<div class="deck-search-footer">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
Add All Matching (Owned)
</button>
</div>
</div>
</aside>
<!-- CENTER COLUMN: Deck Card List -->
<section class="deck-col-center">
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
<div class="deck-panel-header deck-card-list-header">
<span class="deck-panel-title">Deck Contents</span>
<div class="deck-card-list-actions">
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
</div>
</div>
<!-- Card groups by type -->
<div class="deck-card-groups" id="deck-card-groups">
<div class="deck-empty-state deck-empty-state-lg">
<div class="deck-empty-icon">⚔</div>
<div class="deck-empty-text">Create or select a deck to start building</div>
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
</div>
</div>
<!-- Quick add by name -->
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
<div class="deck-quickadd-wrap">
<input
type="text"
class="deck-quickadd-input"
id="deck-quickadd-input"
placeholder="Add card by name..."
autocomplete="off"
/>
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
</div>
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
</div>
<!-- Deck stats footer -->
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
<span class="deck-stat-pill-label">total</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
<span class="deck-stat-pill-label">owned</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
<span class="deck-stat-pill-label">avg CMC</span>
</div>
</div>
</div>
</section>
<!-- RIGHT COLUMN: EDH Advisor -->
<aside class="deck-col-right">
<!-- Ratio Dashboard -->
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
<span class="deck-panel-title">📊 Ratio Dashboard</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ratio-body">
<div class="deck-ratio-bars" id="deck-ratio-bars">
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
</div>
</div>
</div>
<!-- Fix My Ratios -->
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
<span class="deck-panel-title">🔧 Fix My Ratios</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-fix-body">
<div class="deck-shortfalls" id="deck-shortfalls">
<div class="deck-ratio-placeholder">No analysis available</div>
</div>
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
⚡ Auto-suggest from Collection
</button>
</div>
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
</div>
</div>
<!-- EDHREC Recommendations -->
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
<span class="deck-panel-title">✨ EDHREC Recs</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-edhrec-body">
<div class="deck-edhrec-controls">
<label class="deck-toggle-label">
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Owned Only
</label>
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
↺ Refresh
</button>
</div>
<div class="deck-edhrec-list" id="deck-edhrec-list">
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
</div>
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
Add All Owned (respect ratios)
</button>
</div>
</div>
</div>
<!-- AI Research (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
<span class="deck-panel-title">🧠 AI Research</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-research-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Analysis Goal</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
placeholder="Identify weaknesses and suggest upgrades" />
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
placeholder="Dockside Extortionist, Rhystic Study" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
🔍 Analyze Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Analyzing with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
</div>
</div>
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
<span class="deck-panel-title">✨ AI Generate Deck</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-generate-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Commander</label>
<div class="deck-ai-commander-wrap">
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
placeholder="Search for a commander..." autocomplete="off" />
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
</div>
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
placeholder="Rhystic Study, Cyclonic Rift" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
✨ Generate Full Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Generating deck with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
</div>
</div>
</aside>
</main>
<!-- CARD PREVIEW TOOLTIP -->
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
</div>
<!-- TOAST CONTAINER -->
<div class="deck-toast-container" id="deck-toast-container"></div>
<!-- CONFIRM MODAL -->
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
</div>
<div class="deck-modal-body">
<p class="deck-confirm-message" id="deck-confirm-message"></p>
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- NEW DECK MODAL -->
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Create New Deck</h3>
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-form-field">
<label class="deck-form-label">Deck Name</label>
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
</div>
<div class="deck-form-field">
<label class="deck-form-label">Commander (optional)</label>
<div class="deck-commander-search-wrap">
<input
type="text"
class="deck-form-input"
id="deck-new-commander-search"
placeholder="Search Scryfall for commander..."
autocomplete="off"
/>
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
</div>
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
</div>
</div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
</div>
</div>
</div>
<!-- IMPORT MODAL -->
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
<div class="deck-modal deck-modal-wide">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Import Decklist</h3>
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-import-tabs">
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
<button class="deck-import-tab" data-tab="file">Upload File</button>
</div>
<div id="deck-import-paste-panel" class="deck-import-panel">
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
Supported formats:
[Commander]
1 Korvold, Fae-Cursed King
[Deck]
1 Sol Ring
1 Cultivate
37 Forest
Or plain list:
Sol Ring
Cultivate
Forest"></textarea>
</div>
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
<div class="deck-import-dropzone" id="deck-import-dropzone">
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
</div>
<div class="deck-import-file-info" id="deck-import-file-name"></div>
</div>
<div class="deck-import-options">
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="new" checked /> Create new deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="add" /> Add to current deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
</label>
</div>
<div id="deck-import-new-name-row" class="deck-import-name-row">
<label>Deck name (optional):</label>
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
</div>
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
<div class="deck-import-progress-track">
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
</div>
</div>
<div id="deck-import-result" style="display:none"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
</div>
</div>
</div>
</div><!-- /#deck-builder-page -->
<script>
// Set API base URL — when served from a different host, override this value
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckbuilder.js"></script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-245
View File
@@ -1,245 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Auto Deck Generator V3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckgenerator.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
<div id="deckgen-page">
<!-- MAIN LAYOUT -->
<main class="dg-main">
<!-- LEFT: Configuration -->
<section class="dg-col-config">
<!-- Commander Search -->
<div class="dg-panel dg-panel-commander">
<div class="dg-panel-header">
<span class="dg-panel-title">Commander</span>
</div>
<div class="dg-panel-body">
<div class="dg-commander-search-wrap">
<input
type="text"
class="dg-input"
id="dg-commander-input"
placeholder="Search for a legendary creature..."
autocomplete="off"
/>
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
</div>
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
<div class="dg-commander-card">
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">&times;</button>
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
<div class="dg-commander-info">
<div class="dg-commander-name" id="dg-commander-name"></div>
<div class="dg-commander-type" id="dg-commander-type"></div>
<div class="dg-commander-colors" id="dg-commander-colors"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Strategy & Bracket -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Strategy &amp; Power</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<div class="dg-bracket-btns" id="dg-bracket-btns">
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
</div>
</div>
</div>
</div>
<!-- Deck Name -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Deck Name</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-deck-name"
placeholder="Auto-generated from commander name"
/>
</div>
</div>
<!-- Budget -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Budget</span>
</div>
<div class="dg-panel-body">
<div class="dg-budget-row">
<input
type="number"
class="dg-input dg-input-narrow"
id="dg-budget"
placeholder="No limit"
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
</div>
</div>
</div>
<!-- Options -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Options</span>
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<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>
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">&#9889;</span> Generate Deck
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
</section>
<!-- RIGHT: Preview Results -->
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
</div>
<!-- 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 Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</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>
<!-- TOAST CONTAINER -->
<div class="dg-toast-container" id="dg-toast-container"></div>
</div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckgenerator.js"></script>
</body>
</html>
-795
View File
@@ -1,795 +0,0 @@
/**
* Commander AI Lab — Auto Deck Generator V3 UI
* ═══════════════════════════════════════════════
*
* V3 Endpoints:
* GET /api/deck/v3/status — check V3 generator status
* GET /api/deck-generator/commander-search — autocomplete commanders
* POST /api/deck/v3/generate — generate deck (local AI structured output)
* POST /api/deck/v3/commit — generate + save to Deck Builder
* POST /api/deck/v3/export/csv — export as CSV
* POST /api/deck/v3/export/dck — export as Forge .dck
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
* POST /api/deck/v3/export/shopping — shopping list of missing cards
*/
const DeckGenerator = (() => {
'use strict';
const API_BASE = window.LAB_API_BASE || window.location.origin;
const DEBOUNCE_MS = 350;
const COLOR_MAP = {
W: { label: 'W', cls: 'dg-pip-W' },
U: { label: 'U', cls: 'dg-pip-U' },
B: { label: 'B', cls: 'dg-pip-B' },
R: { label: 'R', cls: 'dg-pip-R' },
G: { label: 'G', cls: 'dg-pip-G' },
C: { label: 'C', cls: 'dg-pip-C' },
};
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
const TYPE_ICONS = {
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
'Other': '🃏',
};
const STATUS_BADGES = {
owned: { label: 'Owned', cls: 'dg-badge-owned' },
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
missing: { label: 'Missing', cls: 'dg-badge-missing' },
};
// ── State ──────────────────────────────────────────────
let state = {
commander: null,
targetBracket: 3,
previewResult: null,
lastRequestBody: null,
isLoading: false,
v3Ready: false,
};
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
async function init() {
await checkV3Status();
bindEvents();
checkQueryParams();
}
async function checkV3Status() {
try {
const resp = await fetch(API_BASE + '/api/deck/v3/status');
const data = await resp.json();
state.v3Ready = data.initialized;
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 Anthropic API key is configured', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
}
}
function bindEvents() {
// Commander search
const input = $('dg-commander-input');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = input.value.trim();
if (q.length < 2) { hideDropdown(); return; }
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
});
input.addEventListener('focus', () => {
const q = input.value.trim();
if (q.length >= 2) searchCommander(q);
});
document.addEventListener('click', (e) => {
const wrap = document.querySelector('.dg-commander-search-wrap');
if (wrap && !wrap.contains(e.target)) hideDropdown();
});
$('dg-commander-clear').addEventListener('click', clearCommander);
// Bracket buttons
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
btn.classList.add('dg-bracket-active');
state.targetBracket = parseInt(btn.dataset.bracket, 10);
});
});
// Generate & save
$('dg-preview-btn').addEventListener('click', generateDeck);
$('dg-save-btn').addEventListener('click', commitDeck);
$('dg-regenerate-btn').addEventListener('click', generateDeck);
// Export buttons
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
const params = new URLSearchParams(window.location.search);
const name = params.get('commander');
if (name) {
$('dg-commander-input').value = name;
searchCommander(name);
}
}
// ── Build Request Body ──────────────────────────────────
function buildRequestBody() {
const budget = parseFloat($('dg-budget').value) || null;
return {
commander_name: state.commander.name,
strategy: $('dg-strategy').value.trim(),
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: omitCards.slice(),
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
deck_name: $('dg-deck-name').value.trim() || '',
};
}
// ── Commander Search ────────────────────────────────────
function positionDropdown() {
const input = $('dg-commander-input');
const dropdown = $('dg-commander-dropdown');
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
}
async function searchCommander(q) {
const dropdown = $('dg-commander-dropdown');
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
dropdown.classList.add('open');
positionDropdown();
try {
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
const data = await resp.json();
renderDropdown(data.results || []);
} catch (e) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
}
}
function renderDropdown(results) {
const dropdown = $('dg-commander-dropdown');
if (results.length === 0) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
return;
}
dropdown.innerHTML = results.map((r, idx) => {
const colors = (r.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const badge = r.in_collection
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
const imgTag = imgSrc
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
+ imgTag
+ '<div class="dg-dropdown-item-info">'
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
+ '</div>'
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
+ badge
+ '</div>';
}).join('');
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
el.addEventListener('click', () => {
selectCommander(results[parseInt(el.dataset.idx, 10)]);
hideDropdown();
});
});
}
function selectCommander(cmdr) {
state.commander = cmdr;
$('dg-commander-input').style.display = 'none';
$('dg-commander-selected').style.display = 'block';
$('dg-commander-name').textContent = cmdr.name;
$('dg-commander-type').textContent = cmdr.type_line || '';
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const img = $('dg-commander-img');
if (cmdr.image_url) {
var imgUrl = cmdr.image_url;
if (imgUrl.indexOf('version=small') !== -1) {
imgUrl = imgUrl.replace('version=small', 'version=normal');
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
imgUrl += '&version=normal';
} else if (imgUrl.indexOf('version=') === -1) {
imgUrl += '?version=normal';
}
img.src = imgUrl;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
state.commander = null;
$('dg-commander-input').style.display = '';
$('dg-commander-input').value = '';
$('dg-commander-selected').style.display = 'none';
$('dg-preview-btn').disabled = true;
}
function hideDropdown() {
$('dg-commander-dropdown').classList.remove('open');
}
// -- Conversation Log Helper --
function logMessage(msg, type = 'info') {
const log = $('dg-conversation-log');
if (!log) return;
const entry = document.createElement('div');
entry.className = 'dg-log-entry dg-log-' + type;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
const log = $('dg-conversation-log');
if (!log) return;
log.innerHTML = '';
}
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
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) {
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
}
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
state.lastRequestBody = body;
try {
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Generation failed');
}
state.previewResult = await resp.json();
logMessage('AI response received! Processing deck data...', 'success');
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
}
}
function setLoading(loading, text) {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'block';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
// ── Render Results ──────────────────────────────────────
function renderResults(result) {
$('dg-loading').style.display = 'none';
$('dg-results').style.display = 'block';
$('dg-preview-btn').disabled = false;
renderStrategyBar(result);
renderStatsBar(result);
renderSubstitutionSummary(result);
renderCardGroups(result);
renderUseInGameButton(result);
}
function renderUseInGameButton(result) {
// Only show when running inside the LAN client iframe
if (window.parent === window) return;
var existing = document.getElementById('dg-export-game-btn');
if (existing) existing.remove();
var btn = document.createElement('button');
btn.id = 'dg-export-game-btn';
btn.className = 'dg-btn dg-btn-preview';
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
btn.innerHTML = '<span class="dg-btn-icon">&#x1F3AE;</span> Use in Game';
btn.addEventListener('click', function() {
var cards = (result.cards || []).map(function(c) {
return { name: c.name, quantity: c.count || 1, is_commander: false };
});
if (result.commander) {
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
}
window.parent.postMessage({
type: 'DECK_BUILDER_EXPORT',
deck: cards,
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
commander: result.commander ? result.commander.name : '',
}, '*');
toast('Deck sent to game setup!', 'success');
});
$('dg-results').appendChild(btn);
}
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
if (!bar) return;
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
bar.innerHTML = '<div class="dg-strategy-info">'
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
+ '</div>'
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
+ (bracket.game_changers && bracket.game_changers.length
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
: '');
}
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
if (!container) return;
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
chipHtml('Lands', stats.land_count || 0, ''),
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
];
container.innerHTML = chips.join('');
}
function chipHtml(label, num, cls) {
return '<div class="dg-stat-chip ' + cls + '">'
+ '<span class="dg-stat-num">' + num + '</span>'
+ '<span class="dg-stat-label">' + label + '</span>'
+ '</div>';
}
function renderSubstitutionSummary(result) {
const el = $('dg-sub-summary');
const sub = result.substitution_stats;
if (!sub) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '<div class="dg-sub-bar">'
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
+ '</div>';
}
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
const groups = {};
for (const card of cards) {
const cat = card.category || 'Other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(card);
}
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
for (const t of Object.keys(groups)) {
if (!sortedTypes.includes(t)) sortedTypes.push(t);
}
let html = '';
if (commander) {
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header">'
+ '<span class="dg-group-title">⚔ Commander</span>'
+ '<span class="dg-group-count">1</span>'
+ '</div>'
+ '<div class="dg-group-body">'
+ '<div class="dg-card-row dg-card-row-commander">'
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
+ '</div>'
+ '</div></div>';
}
for (const type of sortedTypes) {
const groupCards = groups[type];
if (!groupCards || groupCards.length === 0) continue;
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const icon = TYPE_ICONS[type] || '🃏';
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header" data-type="' + type + '">'
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
+ '</div>'
+ '<div class="dg-group-body" data-group="' + type + '">';
for (const card of groupCards) {
html += renderCardRow(card);
}
html += '</div></div>';
}
container.innerHTML = html;
container.querySelectorAll('.dg-group-header').forEach(header => {
header.addEventListener('click', () => {
const type = header.dataset.type;
if (!type) return;
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
if (body) body.classList.toggle('collapsed');
});
});
}
function renderCardRow(card) {
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
const roleTags = (card.role_tags || []).slice(0, 3)
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
.join('');
let subHtml = '';
if (card.status === 'substituted' && card.selected_substitute) {
subHtml = '<div class="dg-card-sub-line">'
+ '<span class="dg-sub-arrow">→</span> '
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
+ '</div>';
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
+ '<span class="dg-sub-label">Alternatives:</span> '
+ card.alternatives.slice(0, 3).map(a =>
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
+ '</span>'
).join('')
+ '</div>';
}
const price = card.estimated_price_usd
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
: '';
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
+ '<div class="dg-card-row-main">'
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
+ statusBadge
+ price
+ '</div>'
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
+ subHtml
+ '</div>';
}
// ── Commit Deck ─────────────────────────────────────────
async function commitDeck() {
if (!state.commander) { toast('No commander selected', 'error'); return; }
$('dg-save-btn').disabled = true;
$('dg-save-btn').textContent = 'Saving...';
try {
const body = state.lastRequestBody || buildRequestBody();
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Save failed');
}
const result = await resp.json();
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
setTimeout(() => {
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
}, 1200);
} catch (e) {
toast(e.message || 'Save failed', 'error');
} finally {
$('dg-save-btn').disabled = false;
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
}
}
// ── Exports ─────────────────────────────────────────────
async function exportDeck(format) {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
a.click();
URL.revokeObjectURL(url);
toast('Exported ' + format.toUpperCase(), 'success');
} catch (e) {
toast(e.message || 'Export failed', 'error');
}
}
async function exportMoxfield() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.content) {
await navigator.clipboard.writeText(data.content);
toast('Moxfield deck copied to clipboard', 'success');
}
} catch (e) {
toast('Moxfield export failed', 'error');
}
}
async function exportShopping() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
const list = data.shopping_list || [];
if (list.length === 0) {
toast('All cards are owned — nothing to buy', 'success');
return;
}
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
for (const item of list) {
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
}
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
await navigator.clipboard.writeText(lines.join('\n'));
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
} catch (e) {
toast('Shopping list failed', 'error');
}
}
// ── Toast ───────────────────────────────────────────────
function toast(msg, type) {
type = type || 'info';
const container = $('dg-toast-container');
const el = document.createElement('div');
el.className = 'dg-toast dg-toast-' + type;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Helpers ─────────────────────────────────────────────
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return { init };
})();
document.addEventListener('DOMContentLoaded', () => {
DeckGenerator.init();
});
+105 -200
View File
@@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) {
// Helper: find the card on battlefield for counter placement
function findCardOnBattlefield(cardName, ownerIdx) {
var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; });
var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
for (var i = 0; i < bf.length; i++) {
if (bf[i].name === cardName) return bf[i];
}
@@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) {
var opps4 = getOpponents(oi);
var placed = false;
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; });
var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
cmBf[cmj].counters = cmBf[cmj].counters || {};
@@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) {
case 'counter_all': {
// +1/+1 counter on each creature you control
var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var caBf = (gameState.players[oi] || {}).battlefield || [];
var caCount = 0;
for (var cai = 0; cai < caBf.length; cai++) {
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
@@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) {
}
case 'untap_all': {
var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var uaBf = (gameState.players[oi] || {}).battlefield || [];
var uaCount = 0;
for (var uai = 0; uai < uaBf.length; uai++) {
if (uaBf[uai].tapped) {
@@ -3247,15 +3247,19 @@ function addCardToBattlefield(name, imageUrl, typeLine, ownerIndex, x, y, oracle
} else {
// Place cards by type in designated battlefield zones
var zone = getCardTypeZone(typeLine || '');
var cardW = 63, cardH = 84;
var cardW = 110, cardH = 150;
// Zone regions (percentage of section area)
// Offset from edges to avoid overlapping the overlaid zone-row/commander-row
// Creatures: top center (30%-70% x, 5%-45% y)
// Artifacts/Enchantments: top right (70%-95% x, 5%-45% y)
// Lands: bottom left (5%-35% x, 55%-90% y)
// Instants/Sorceries: bottom center (35%-65% x, 55%-90% y)
// Other: center (30%-70% x, 25%-75% y)
var zones = {
creature: { x1: 0.25, x2: 0.70, y1: 0.20, y2: 0.55 },
artifact: { x1: 0.70, x2: 0.95, y1: 0.20, y2: 0.55 },
land: { x1: 0.03, x2: 0.35, y1: 0.55, y2: 0.85 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.85 },
other: { x1: 0.25, x2: 0.70, y1: 0.30, y2: 0.70 }
creature: { x1: 0.25, x2: 0.65, y1: 0.05, y2: 0.42 },
artifact: { x1: 0.68, x2: 0.95, y1: 0.05, y2: 0.42 },
land: { x1: 0.03, x2: 0.32, y1: 0.55, y2: 0.90 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.90 },
other: { x1: 0.25, x2: 0.70, y1: 0.25, y2: 0.75 }
};
var z = zones[zone] || zones.other;
var areaW = rect.width;
@@ -5286,7 +5290,8 @@ function setLife(playerId, newLife) {
eliminatePlayer(playerId, 'life total reached 0');
}
checkStateBasedActions();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
@@ -5321,7 +5326,8 @@ function adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount) {
panelContainer.replaceWith(newPanel);
}
checkStateBasedActions();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
@@ -5354,6 +5360,8 @@ function adjustPoison(playerId, amount) {
panelContainer.replaceWith(newPanel);
}
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
@@ -8237,7 +8245,6 @@ function drawCard(playerId) {
}
const card = player.zones.library.shift(); // Take from top (index 0)
if (typeof _enrichCardFromCache === 'function') _enrichCardFromCache(card);
player.zones.hand.push(card);
addLogEntry('<strong>' + player.name + '</strong> drew a card (' + player.zones.hand.length + ' in hand)');
updatePlayerZones();
@@ -12553,6 +12560,32 @@ 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>' +
@@ -12562,6 +12595,7 @@ 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 + ')">' +
@@ -12569,95 +12603,20 @@ function renderDeckSelectionGrid() {
'</select>' +
'</div>' +
'<div class="deck-select-tab-content" id="setup-deck-content-mydecks-' + idx + '">' +
'<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>' +
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...&#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.totalCards || 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'];
var tabs = ['precon', 'mydecks', 'import'];
tabs.forEach(function(t) {
var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx);
var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx);
@@ -12686,6 +12645,51 @@ 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() : [];
@@ -12779,9 +12783,6 @@ async function loadPreconForPlayer(playerIdx, preconId) {
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
if (precon.fileName) {
player.deckFileName = precon.fileName;
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
@@ -12800,28 +12801,6 @@ 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;
@@ -12921,8 +12900,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 === 'user-deck') {
await loadUserDeckForPlayer(hd.idx, hd.choice.deckName);
} else if (hd.choice.type === 'import') {
await importDeckForPlayer(hd.idx, hd.choice.text);
}
}
} catch (err) {
@@ -13045,8 +13024,6 @@ function startGame() {
renderHandArea();
renderPhaseTracker();
updateTurnDisplay();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
if (typeof refreshLeftPanelTriggers === 'function') refreshLeftPanelTriggers();
// Set default search focus
setTimeout(() => {
@@ -16252,72 +16229,6 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
return assignments;
}
// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
function aiDeclareAttacks(playerIdx) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player || player.eliminated) return;
var difficulty = gameState.aiDifficulty || 'easy';
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) return;
var attackerCards = [];
if (difficulty === 'easy') {
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
}
}
}
if (attackerCards.length === 0) return;
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
fireBattlefieldTriggers('attack');
}
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
@@ -16686,7 +16597,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
{
if (difficulty !== 'easy') {
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
@@ -17298,12 +17209,12 @@ function _runLocalAiTurn() {
}
// ========================================
// MAIN PHASE 2: Post-combat spell casting
// MAIN PHASE 2: Post-combat spell casting (normal & hard)
// Hard: play held-back removal + instants + any remaining affordable spells
// Normal/Easy: try to play any remaining affordable spells
// Normal: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
{
if (difficulty !== 'easy') {
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this
var bluffReserve = 0;
@@ -25727,9 +25638,3 @@ function _safeRender(fn, ...args) {
try { window[fn](...args); } catch(e) {}
}
}
// Expose functions needed by ai-bridge.js
window.aiTryCastCommander = aiTryCastCommander;
window.aiDeclareAttacks = aiDeclareAttacks;
window.playCardFromHand = playCardFromHand;
window.aiCountUntappedLands = aiCountUntappedLands;
-955
View File
@@ -1,955 +0,0 @@
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Global Design System
Dark & Polished · Premium Dashboard Aesthetic
═══════════════════════════════════════════════════════════ */
/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ──────────────────────────────────────── */
:root {
/* Surfaces — layered dark with subtle warmth */
--bg: #0c0e14;
--surface-0: #12141c;
--surface-1: #171a24;
--surface-2: #1c1f2b;
--surface-3: #222533;
--surface-hover: #252839;
/* Borders */
--border: #2a2d3a;
--border-subtle: #1e2130;
--border-focus: #4a6fa5;
/* Text */
--text: #e1e4eb;
--text-secondary:#8b90a0;
--text-tertiary: #5c6070;
--text-inverse: #0c0e14;
/* Accent — cool blue with subtle depth */
--accent: #5b9ef0;
--accent-hover: #7ab4ff;
--accent-dim: rgba(91, 158, 240, 0.12);
--accent-glow: rgba(91, 158, 240, 0.25);
/* Semantic */
--success: #4ade80;
--success-dim: rgba(74, 222, 128, 0.12);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--gold: #fcd34d;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
--shadow-glow: 0 0 20px var(--accent-dim);
/* Transitions */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 180ms;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
--sp-16: 64px;
/* Layout */
--content-max: 1280px;
--sidebar-width: 280px;
--nav-height: 56px;
}
/* ── Base ───────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
h1, h2, h3, h4, h5, h6 {
color: #fff;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 24px; }
h2 { font-size: 18px; }
h3 { font-size: 16px; }
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
a:hover { color: var(--accent-hover); }
img, svg { display: block; max-width: 100%; }
button { cursor: pointer; font: inherit; }
input, select, textarea { font: inherit; color: inherit; }
table { border-collapse: collapse; width: 100%; }
::selection {
background: var(--accent-dim);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* ── Navigation ─────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: var(--nav-height);
padding: 0 var(--sp-6);
background: rgba(12, 14, 20, 0.85);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
}
.nav-brand svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.nav-links {
display: flex;
align-items: center;
gap: var(--sp-1);
overflow-x: auto;
scrollbar-width: none;
}
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--duration) var(--ease);
text-decoration: none;
}
.nav-links a:hover {
color: var(--text);
background: var(--surface-2);
}
.nav-links a.active {
color: #fff;
background: var(--accent-dim);
}
.nav-links a .nav-icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.nav-links a.active .nav-icon,
.nav-links a:hover .nav-icon {
opacity: 1;
}
/* ── Page Layout ────────────────────────────────────────── */
.page {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-8) var(--sp-6);
}
.page-header {
margin-bottom: var(--sp-8);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
}
/* ── Cards / Panels ─────────────────────────────────────── */
.panel {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-6);
margin-bottom: var(--sp-6);
transition: border-color var(--duration) var(--ease);
}
.panel:hover {
border-color: var(--border-focus);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Stat Cards ─────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-6);
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-5);
transition: all var(--duration) var(--ease);
}
.stat-card:hover {
border-color: var(--border-focus);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: var(--sp-1);
}
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all var(--duration) var(--ease);
white-space: nowrap;
line-height: 1.4;
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 14px var(--accent-glow);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--text-tertiary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px 12px;
}
.btn-ghost:hover {
color: var(--text);
background: var(--surface-2);
}
.btn-success { background: var(--success); color: var(--text-inverse); }
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
.btn-purple { background: var(--purple); color: var(--text-inverse); }
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-lg { padding: 12px 24px; font-size: 15px; }
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
/* ── Forms ──────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 9px 14px;
font-size: 14px;
color: var(--text);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all var(--duration) var(--ease);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
outline: none;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--text-tertiary);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-4);
}
/* ── Progress Bar ───────────────────────────────────────── */
.progress-container {
margin-top: var(--sp-4);
padding: var(--sp-4);
background: var(--surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.progress-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.progress-track {
height: 6px;
background: var(--surface-3);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
border-radius: 3px;
transition: width 0.3s var(--ease);
}
.progress-detail {
margin-top: var(--sp-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
line-height: 1.6;
}
/* ── Phase Badge ────────────────────────────────────────── */
.badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
.badge-idle::before { background: var(--text-tertiary); }
.badge-running { background: var(--accent-dim); color: var(--accent); }
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-success::before { background: var(--success); }
.badge-error { background: var(--error-dim); color: var(--error); }
.badge-error::before { background: var(--error); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-warning::before { background: var(--warning); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Empty State ────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-12) var(--sp-6);
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--sp-4);
opacity: 0.3;
}
.empty-state p {
font-size: 14px;
margin-bottom: var(--sp-3);
}
/* ── Tables ─────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
background: var(--surface-0);
}
.data-table tbody td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.data-table tbody tr:hover {
background: var(--surface-2);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* ── Tags ───────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-blue { background: var(--accent-dim); color: var(--accent); }
.tag-green { background: var(--success-dim); color: var(--success); }
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
.tag-red { background: var(--error-dim); color: var(--error); }
.tag-purple { background: var(--purple-dim); color: var(--purple); }
/* ── Toast / Notification ───────────────────────────────── */
.toast {
position: fixed;
bottom: var(--sp-6);
right: var(--sp-6);
padding: var(--sp-3) var(--sp-5);
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 13px;
color: var(--text);
z-index: 1000;
animation: toast-in 0.3s var(--ease);
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--error); }
.toast-warning { border-left: 3px solid var(--warning); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading Spinner ────────────────────────────────────── */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Divider ────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--border);
margin: var(--sp-5) 0;
}
/* ── Utility Classes ────────────────────────────────────── */
.hidden { display: none !important; }
.text-accent { color: var(--accent); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-error { color: var(--error); }
.text-muted { color: var(--text-secondary); }
.text-faint { color: var(--text-tertiary); }
.text-mono { font-family: var(--font-mono); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-6 { margin-bottom: var(--sp-6); }
.gap-2 { gap: var(--sp-2); }
.gap-4 { gap: var(--sp-4); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.grid { display: grid; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: var(--sp-5) var(--sp-4);
}
.nav {
padding: 0 var(--sp-4);
}
.nav-brand {
margin-right: var(--sp-4);
font-size: 13px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.stat-grid {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════
PREMIUM UPGRADE LAYER
═══════════════════════════════════════════════════════════ */
/* ── Ambient background glow (body) ─────────────────────── */
body {
background-image:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
background-attachment: fixed;
}
/* ── Page title: subtle gradient text ───────────────────── */
.page-header h1 {
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Nav: stronger glass + accent underline ─────────────── */
.nav {
background: rgba(9,11,16,0.80);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
border-bottom-color: rgba(255,255,255,0.06);
}
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
/* ── Panels: gradient border on hover ───────────────────── */
.panel {
position: relative;
transition: border-color var(--duration) var(--ease),
box-shadow var(--duration) var(--ease);
}
.panel::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration) var(--ease);
}
.panel:hover::before { opacity: 1; }
.panel:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
}
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
.stat-card {
position: relative;
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
pointer-events: none;
}
.stat-card:hover {
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
transform: translateY(-2px);
}
/* ── Buttons: gradient fills + inner highlight ───────────── */
.btn { position: relative; overflow: hidden; }
.btn::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
border-radius: inherit;
pointer-events: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #4a8de0);
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
}
.btn-success {
background: linear-gradient(135deg, var(--success), #38c46c);
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
}
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
.btn-purple {
background: linear-gradient(135deg, var(--purple), #9171f0);
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
}
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
/* ── Tags: subtle colored border ────────────────────────── */
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
/* ── Progress fill: glow ─────────────────────────────────── */
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
/* ── Badge running: glow dot ─────────────────────────────── */
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
/* ── Toast: glassmorphism + colored glow ─────────────────── */
.toast { backdrop-filter: blur(12px); }
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
/* ── Divider: fade-out gradient ──────────────────────────── */
.divider {
background: linear-gradient(90deg, transparent, var(--border), transparent);
}
/* ── Inputs: inset shadow for depth ─────────────────────── */
.form-input, .form-select, .form-textarea {
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
}
/* ══════════════════════════════════════════════════════════════
Tooltip System (.tip-icon + data-tip)
Shared across all pages — do not duplicate in page-specific CSS
══════════════════════════════════════════════════════════════ */
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
font-style: normal;
cursor: default;
position: relative;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tip-icon:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tip-icon::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-0, #1a1a2e);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #333);
border-radius: var(--radius-md, 6px);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
width: 240px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
text-align: left;
text-transform: none;
letter-spacing: 0;
}
.tip-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border, #333);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
}
.tip-icon.tip-below::after {
bottom: auto;
top: calc(100% + 8px);
}
.tip-icon.tip-below::before {
bottom: auto;
top: calc(100% + 3px);
border-top-color: transparent;
border-bottom-color: var(--border, #333);
}
+33 -326
View File
@@ -51,8 +51,6 @@
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</button>
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">&#x1F0CF;</button>
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">&#x2715;</button>
</div>
<!-- Storm counter -->
@@ -69,6 +67,15 @@
<div class="corner-accent bl"></div>
<div class="corner-accent br"></div>
<!-- CENTER EMBLEM -->
<div class="center-emblem">
<svg viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
</svg>
</div>
<!-- ================================================
4-QUADRANT BATTLEFIELD
@@ -78,6 +85,12 @@
<!-- PLAYER 1: Top-Left (Crimson) -->
<div class="player-area p1" id="player-area-1">
<span class="player-label" id="player-label-1">Player 1</span>
<div class="life-counter" id="life-counter-1">
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">&#x2212;</button>
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
</div>
<div class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
<div class="zone-row" id="zone-row-1">
@@ -126,6 +139,12 @@
<!-- PLAYER 2: Top-Right (Sapphire) -->
<div class="player-area p2" id="player-area-2">
<span class="player-label" id="player-label-2">Player 2</span>
<div class="life-counter" id="life-counter-2">
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">&#x2212;</button>
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
</div>
<div class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
<div class="zone-row" id="zone-row-2">
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
@@ -210,6 +229,12 @@
</div>
<span class="player-label" id="player-label-3">Player 3</span>
<div class="life-counter" id="life-counter-3">
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">&#x2212;</button>
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
</div>
<div class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</div>
<!-- PLAYER 4: Bottom-Right (Gold) -->
@@ -254,89 +279,16 @@
</div>
<span class="player-label" id="player-label-4">Player 4</span>
<div class="life-counter" id="life-counter-4">
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">&#x2212;</button>
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
</div>
<div class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div><!-- /.battlefield -->
<!-- LIFE PANELS OVERLAY (top-most layer) -->
<div id="life-panels-overlay">
<div class="life-panel lp-p1" id="life-panel-1">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-1">
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">&#x2212;</button>
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-0" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-0" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p2" id="life-panel-2">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-2">
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">&#x2212;</button>
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-1" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-1" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p3" id="life-panel-3">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-3">
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">&#x2212;</button>
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-2" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-2" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p4" id="life-panel-4">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-4">
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">&#x2212;</button>
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-3" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-3" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div>
</div>
<!-- COMBAT SVG OVERLAY (arrows) -->
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
<defs>
@@ -369,24 +321,6 @@
<!-- Sidebar edge tab -->
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">&#x2039;</div>
<!-- LEFT ACTION PANEL (triggers + quick actions) -->
<div id="left-action-panel" class="collapsed">
<div class="left-panel-header">
<span class="left-panel-title">Triggers <span id="left-panel-trigger-count" class="left-panel-count">0</span></span>
<button class="sidebar-toggle" onclick="toggleLeftPanel()" title="Close panel">&#x2715;</button>
</div>
<div class="left-panel-actions">
<button class="left-panel-btn" onclick="openTokenModal((typeof gameState !== 'undefined' && gameState) ? (gameState.currentPlayerIndex || 0) : 0)" title="Create token">&#x2728; Create Token</button>
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">&#x21C4; Auto-Arrange</button>
</div>
<div class="left-panel-body" id="left-panel-triggers">
<div class="left-panel-empty">No pending triggers.</div>
</div>
</div>
<!-- Left panel edge tab -->
<div id="left-panel-edge-tab" class="left-panel-edge-tab" onclick="toggleLeftPanel()" title="Toggle quick actions">&#x203A;</div>
</div><!-- /.table-surface / #game-battlefield -->
<!-- ====================================================
@@ -708,237 +642,10 @@ document.addEventListener('DOMContentLoaded', function() {
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
// Wire up WebSocket push events from backend
AIBridge.connector.on('ai:decision', function(msg) {
if (!gameState || !msg.action) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (player && player.isAI) {
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
}
});
AIBridge.connector.on('state:snapshot', function(msg) {
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
console.log('[WS] State snapshot received from backend');
}
});
});
}
});
</script>
<!-- ====================================================
DECK BUILDER PANEL (iframe)
==================================================== -->
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Deck Builder</span>
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">&times;</button>
</div>
<iframe src="deckbuilder.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckbuilder-iframe">
</iframe>
</div>
<div id="panel-collection" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Collection</span>
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">&times;</button>
</div>
<iframe src="collection.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="collection-iframe">
</iframe>
</div>
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Auto Deck Generator</span>
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">&times;</button>
</div>
<iframe src="deckgenerator.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckgenerator-iframe">
</iframe>
</div>
<script>
function toggleDeckBuilderPanel() {
var panel = document.getElementById('panel-deckbuilder');
if (panel) panel.classList.toggle('hidden');
}
function toggleDeckGenPanel() {
var panel = document.getElementById('panel-deckgenerator');
if (panel) panel.classList.toggle('hidden');
}
function toggleCollectionPanel() {
var panel = document.getElementById('panel-collection');
if (panel) panel.classList.toggle('hidden');
}
function toggleLeftPanel() {
var panel = document.getElementById('left-action-panel');
if (panel) {
panel.classList.toggle('collapsed');
if (!panel.classList.contains('collapsed') && typeof refreshLeftPanelTriggers === 'function') {
refreshLeftPanelTriggers();
}
}
}
// Render the triggers list inside the left action panel.
// Mirrors #trigger-stack-body rendering but stays embedded in the panel.
function refreshLeftPanelTriggers() {
var body = document.getElementById('left-panel-triggers');
var countEl = document.getElementById('left-panel-trigger-count');
if (!body) return;
if (typeof gameState === 'undefined' || !gameState) {
if (countEl) countEl.textContent = '0';
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var pending = (gameState.triggerStack || []).filter(function(t){ return !t.resolved; });
if (countEl) countEl.textContent = String(pending.length);
if (pending.length === 0) {
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var icons = (typeof TRIGGER_TYPE_ICONS !== 'undefined') ? TRIGGER_TYPE_ICONS : {};
var labels = (typeof TRIGGER_TYPE_LABELS !== 'undefined') ? TRIGGER_TYPE_LABELS : {};
var html = '';
pending.forEach(function(t) {
var icon = icons[t.type] || '\u2747';
var label = labels[t.type] || t.type || '';
var owner = (gameState.players && gameState.players[t.ownerIndex]) || {};
var color = owner.color || '#888';
var name = (t.cardName || 'Unknown').replace(/</g,'&lt;');
var snip = (t.oracleSnippet || '').replace(/</g,'&lt;');
html +=
'<div class="lpt-item">' +
'<div class="lpt-icon">' + icon + '</div>' +
'<div class="lpt-body">' +
'<div class="lpt-title">' + name + '</div>' +
'<div class="lpt-type">' + label + '</div>' +
'<div class="lpt-text">' + snip + '</div>' +
'</div>' +
'<div class="lpt-dot" style="background:' + color + '"></div>' +
'<button class="lpt-dismiss" title="Dismiss" onclick="if(typeof dismissTrigger===\'function\')dismissTrigger(' + t.id + ');refreshLeftPanelTriggers();">&#x2715;</button>' +
'</div>';
});
body.innerHTML = html;
}
// Populate each seat's Cmd Dmg score-area box with per-opponent badges.
// Skips the seat's own player number from the display.
function renderCmdDmgInline() {
if (typeof gameState === 'undefined' || !gameState || !gameState.players) return;
var players = gameState.players;
for (var i = 0; i < players.length; i++) {
var box = document.getElementById('cmd-dmg-inline-' + i);
if (!box) continue;
var row = box.querySelector('.cmd-dmg-inline-row');
if (!row) continue;
var me = players[i];
if (!me) continue;
var html = '';
for (var j = 0; j < players.length; j++) {
if (j === i) continue; // leave own player number out
var op = players[j];
if (!op) continue;
var dmg = (me.commanderDamage && me.commanderDamage[op.id]) || 0;
var danger = dmg >= 21 ? ' danger' : dmg >= 15 ? ' crit' : dmg >= 8 ? ' warn' : '';
html +=
'<span class="cdi-badge' + danger + '" title="Commander damage from ' + (op.name||'P'+(j+1)) + '">' +
'<span class="cdi-dot" style="background:' + (op.color||'#888') + '"></span>' +
'P' + (j + 1) + ' <b>' + dmg + '</b>' +
'</span>';
}
row.innerHTML = html || '&#8212;';
}
}
// Hook into the trigger-stack badge updater so the left panel stays in sync.
(function() {
if (typeof window === 'undefined') return;
var tryHook = function() {
if (typeof window.updateTriggerBadge === 'function' && !window.updateTriggerBadge._leftPanelHooked) {
var orig = window.updateTriggerBadge;
window.updateTriggerBadge = function() {
var r = orig.apply(this, arguments);
try { refreshLeftPanelTriggers(); } catch(e) {}
return r;
};
window.updateTriggerBadge._leftPanelHooked = true;
}
// Also wrap adjustCommanderDamage so badges refresh after matrix clicks.
if (typeof window.adjustCommanderDamage === 'function' && !window.adjustCommanderDamage._cdmInlineHooked) {
var origAdj = window.adjustCommanderDamage;
window.adjustCommanderDamage = function() {
var r = origAdj.apply(this, arguments);
try { renderCmdDmgInline(); } catch(e) {}
return r;
};
window.adjustCommanderDamage._cdmInlineHooked = true;
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryHook);
} else {
tryHook();
}
// Also attempt the hook again a tick later (engine.js loads after this script in some orderings)
setTimeout(tryHook, 0);
setTimeout(tryHook, 500);
})();
// Listen for deck exports from the deck builder iframe
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
var deckData = e.data;
// Build a decklist text string compatible with importDeckForPlayer()
var lines = [];
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
if (commanders.length > 0) {
lines.push('[Commander]');
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
lines.push('');
}
lines.push('[Deck]');
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
// Store on window for the setup screen to pick up
window._pendingDeckImport = {
text: lines.join('\n'),
deckName: deckData.deckName || 'Imported Deck',
commander: deckData.commander || '',
};
// If a game is in progress, import into current player's slot
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
var playerIdx = gameState.currentPlayerIndex || 0;
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
if (typeof renderHandArea === 'function') renderHandArea();
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof addLogEntry === 'function') {
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
}
});
} else if (typeof addLogEntry === 'function') {
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
}
// Close the deck builder panel
toggleDeckBuilderPanel();
});
</script>
</body>
</html>
+1 -6
View File
@@ -228,11 +228,6 @@ function removeEmblem(playerId, idx) {
renderEnhancedPanels();
}
// index: 0 1 2 3 → area: 1 2 4 3
// players[2] → bottom-RIGHT (area-4), players[3] → bottom-LEFT (area-3)
// Keeps turn order P1→P2→P3→P4 visually clockwise.
var PLAYER_INDEX_TO_QUADRANT = [1, 2, 4, 3];
/**
* Re-render all enhanced player panels in their quadrant positions.
*/
@@ -241,7 +236,7 @@ function renderEnhancedPanels() {
for (var i = 0; i < gameState.players.length; i++) {
var player = gameState.players[i];
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
var quadrant = i + 1;
var areaEl = document.getElementById('player-area-' + quadrant);
if (!areaEl) continue;
+11 -138
View File
@@ -8,75 +8,12 @@
'use strict';
// In-memory Scryfall image cache: cardName -> { imageUrl, typeLine, oracleText, manaCost, power, toughness }
const _scryfallCache = {};
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
// ────────────────────────────────────────────────────────────
// Scryfall image cache: cardName -> { imageUrl, typeLine, ... }
// Persisted to localStorage so repeat games don't re-hit Scryfall.
// ────────────────────────────────────────────────────────────
const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1';
const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const _SCRYFALL_CACHE_MAX_ENTRIES = 20000;
let _scryfallCache = {};
(function _loadScryfallCacheFromLocalStorage() {
try {
const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
const now = Date.now();
const stamp = parsed.__stamp || 0;
if (now - stamp > _SCRYFALL_CACHE_TTL_MS) {
console.log('[scryfall-cache] expired (> 30 days), clearing');
localStorage.removeItem(_SCRYFALL_CACHE_KEY);
return;
}
delete parsed.__stamp;
_scryfallCache = parsed;
const n = Object.keys(_scryfallCache).length;
console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage');
} catch (e) {
console.warn('[scryfall-cache] load failed:', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
_scryfallCache = {};
}
})();
let _scryfallCacheSaveTimer = null;
function _persistScryfallCache() {
// Debounced — coalesce rapid writes during batch enrichment
if (_scryfallCacheSaveTimer) return;
_scryfallCacheSaveTimer = setTimeout(function () {
_scryfallCacheSaveTimer = null;
try {
// Trim to MAX_ENTRIES if over (drop entries with no imageUrl first)
const keys = Object.keys(_scryfallCache);
if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) {
const trimmed = {};
const sorted = keys.sort((a, b) =>
(_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0)
).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES);
for (const k of sorted) trimmed[k] = _scryfallCache[k];
_scryfallCache = trimmed;
}
const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache);
localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload));
} catch (e) {
// Quota exceeded — clear and skip. Cache will rebuild next session.
console.warn('[scryfall-cache] persist failed (quota?):', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
}
}, 750);
}
function clearScryfallCache() {
_scryfallCache = {};
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
console.log('[scryfall-cache] cleared');
}
// ============================================================
// startGameWithDecks
// Called by ui.js startGame() when players have selected precon decks.
@@ -113,18 +50,8 @@ async function startGameWithDecks() {
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
// Block on Scryfall enrichment BEFORE the game becomes interactive. This is
// critical: the opening 7 cards are dealt via player.zones.hand.push(lib.shift())
// directly (bypassing drawCard()), so they don't get _enrichCardFromCache'd at
// draw time. Without awaiting here, the AI's first turn fires with empty
// typeLine/cmc/oracleText/isRamp/isRemoval flags and can only play lands.
addLogEntry('\u23F3 Loading card data from Scryfall...');
try {
await enrichAllZonesWithScryfall();
} catch (e) {
console.warn('[precon-loader] Enrichment failed, continuing anyway:', e);
}
addLogEntry('\u2705 Card data loaded. Game ready.');
// Kick off image enrichment for all players in the background (non-blocking)
enrichAllZonesWithScryfall();
}
@@ -231,32 +158,18 @@ async function enrichAllZonesWithScryfall() {
// Gather all unique card names across all players and all zones
const nameSet = new Set();
let totalCards = 0;
let cacheHits = 0;
for (const player of gameState.players) {
for (const zone of Object.values(player.zones)) {
for (const card of zone) {
if (!card.name) continue;
totalCards++;
if (_scryfallCache[card.name]) {
cacheHits++;
} else {
nameSet.add(card.name);
}
if (card.name && !_scryfallCache[card.name]) nameSet.add(card.name);
}
}
}
if (nameSet.size === 0) {
// Everything was in cache — just apply and bail, no network
addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).');
_applyScryfallDataToAllCards();
return;
}
if (nameSet.size === 0) return;
const names = Array.from(nameSet);
const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0;
addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...');
addLogEntry('\uD83C\uDF0D Fetching card images from Scryfall (' + names.length + ' unique cards)...');
// Split into batches of 75 (Scryfall max)
const BATCH = 75;
@@ -301,7 +214,6 @@ async function _fetchScryfallBatch(names) {
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
cmc: card.cmc || 0,
power: card.power || '',
toughness: card.toughness || '',
};
@@ -309,10 +221,8 @@ async function _fetchScryfallBatch(names) {
// Mark not-found cards so we don't re-fetch them on next enrichment
for (const name of names) {
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', power: '', toughness: '' };
}
_persistScryfallCache();
}
function _applyScryfallDataToAllCards() {
@@ -326,15 +236,8 @@ function _applyScryfallDataToAllCards() {
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag roles from oracle text so aiCategorizeHand works
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
@@ -343,17 +246,11 @@ function _applyScryfallDataToAllCards() {
for (const card of gameState.battlefieldCards) {
if (card.ownerIndex !== player.id) continue;
const data = _scryfallCache[card.name];
if (data) {
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
if (data && !card.imageUrl) {
card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
@@ -370,30 +267,6 @@ function _applyScryfallDataToAllCards() {
if (typeof updateZoneCounts === 'function') updateZoneCounts();
}
/**
* Synchronously enrich a single card from the Scryfall cache.
* Called at draw time so cards have typeLine/cmc/oracle data
* before the AI's first turn, even if async enrichment hasn't completed.
*/
function _enrichCardFromCache(card) {
if (!card || !card.name) return;
const data = _scryfallCache[card.name];
if (!data) return;
card.imageUrl = data.imageUrl || card.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine || '';
card.oracleText = data.oracleText || card.oracleText || '';
card.manaCost = data.manaCost || card.manaCost || '';
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag oracle roles
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
/**
* Enrich a single card object on-demand (useful for cards drawn or played after initial load).
* Returns a Promise that resolves when the card's imageUrl is set.
+173 -697
View File
File diff suppressed because it is too large. Load diff
+24 -230
View File
@@ -17,12 +17,6 @@ let undoHistoryVisible = false;
let aiTurnActive = false;
let floatingPreviewTimeout = null;
// Hand-to-battlefield drag state
let isDraggingFromHand = false;
let handDragPlayerIdx = null;
let handDragCardIdx = null;
let handDragGhost = null;
// ============================================================
// LOG SYSTEM (override engine stub)
// ============================================================
@@ -150,13 +144,23 @@ function updateZoneCountsForPlayer(playerIdx) {
const libCount = document.getElementById('lib-count-' + pNum);
if (libCount) libCount.textContent = player.zones.library.length;
// Update exile count
const exCount = document.getElementById('exile-count-' + pNum);
if (exCount) {
exCount.textContent = player.zones.exile.length;
exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none';
}
// Update graveyard count
const gvCount = document.getElementById('gv-count-' + pNum);
if (gvCount) {
gvCount.textContent = player.zones.graveyard.length;
gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none';
}
// Update hand count
const handCount = document.getElementById('hand-count-' + pNum);
if (handCount) handCount.textContent = player.zones.hand.length;
// Exile + Graveyard: render last card image face-up
renderLastCardInZone(playerIdx, 'exile');
renderLastCardInZone(playerIdx, 'graveyard');
}
function updatePlayerZones() {
@@ -207,50 +211,6 @@ function renderCommanderZone(playerIdx) {
}
}
// ============================================================
// EXILE / GRAVEYARD — Show last card image face-up
// ============================================================
function renderLastCardInZone(playerIdx, zoneName) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
const zoneIdPrefix = (zoneName === 'exile') ? 'exile-zone-' : 'gv-zone-';
const zoneEl = document.getElementById(zoneIdPrefix + pNum);
if (!zoneEl) return;
const cards = player.zones[zoneName] || [];
const lastCard = cards[cards.length - 1]; // most recently added card
if (lastCard && lastCard.imageUrl) {
const safeImg = (lastCard.imageUrl || '').replace(/'/g, "\\'");
const safeName = (lastCard.name || '').replace(/'/g, "\\'");
const safeType = (lastCard.typeLine || '').replace(/'/g, "\\'");
zoneEl.innerHTML =
'<img src="' + safeImg + '" alt="' + (lastCard.name || zoneName) + '" ' +
'style="width:100%;height:100%;object-fit:cover;border-radius:6px;" ' +
'onmouseenter="showFloatingCardPreview(\'' + safeImg + '\', event, {name:\'' + safeName + '\', typeLine:\'' + safeType + '\'})" ' +
'onmousemove="positionFloatingPreview(event)" ' +
'onmouseleave="hideFloatingCardPreview()" ' +
'draggable="false" />';
// Always show count badge on top
if (cards.length > 0) {
zoneEl.innerHTML += '<span class="card-count" id="' + (zoneName === 'exile' ? 'exile' : 'gv') + '-count-' + pNum + '" ' +
'style="display:block;">' + cards.length + '</span>';
}
} else {
// Fallback: icon + name + count (original look)
const icon = (zoneName === 'exile') ? '&#x2B21;' : '&#x2620;';
const label = (zoneName === 'exile') ? 'Exile' : 'Grave';
const countId = (zoneName === 'exile') ? 'exile-count-' : 'gv-count-';
zoneEl.innerHTML =
'<span class="card-count" id="' + countId + pNum + '" style="' + (cards.length > 0 ? 'display:block;' : 'display:none;') + '">' + cards.length + '</span>' +
'<span class="zone-icon">' + icon + '</span>' +
'<span class="zone-name">' + label + '</span>';
}
}
// ============================================================
// HAND ZONE RENDERING (thumbnails in zone.hand)
// ============================================================
@@ -263,7 +223,6 @@ function renderHandZone(playerIdx) {
if (!handEl) return;
const hand = player.zones.hand || [];
const hideFaces = !!player.isAI; // AI hands render face-down
// Update count badge
const countEl = document.getElementById('hand-count-' + pNum);
@@ -281,23 +240,13 @@ function renderHandZone(playerIdx) {
const maxShow = Math.min(hand.length, 7); // limit thumbnails
for (let i = 0; i < maxShow; i++) {
const card = hand[i];
if (hideFaces) {
// AI hand — show a face-down card back (no name, no image, no preview)
thumbsHtml +=
'<div class="hand-thumb face-down" ' +
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
'title="Opponent hand (hidden)"></div>';
continue;
}
const imgSrc = card.imageUrl || '';
const escapedName = (card.name || '').replace(/'/g, "\\'");
const escapedType = (card.typeLine || '').replace(/'/g, "\\'");
if (imgSrc) {
thumbsHtml +=
'<div class="hand-thumb" ' +
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
'onclick="openZoneModal(' + playerIdx + ')" ' +
'onmousedown="startHandDrag(event,' + playerIdx + ',' + i + ',\'' + imgSrc.replace(/'/g, "\\'") + '\')" ' +
'onmouseenter="showFloatingCardPreview(\'' + imgSrc.replace(/'/g, "\\'") + '\',event,{name:\'' + escapedName + '\',typeLine:\'' + escapedType + '\'})" ' +
'onmousemove="positionFloatingPreview(event)" ' +
'onmouseleave="hideFloatingCardPreview()">' +
@@ -310,11 +259,7 @@ function renderHandZone(playerIdx) {
}
if (hand.length > maxShow) {
if (hideFaces) {
thumbsHtml += '<div class="hand-thumb face-down overflow" title="Opponent hand (hidden)">+' + (hand.length - maxShow) + '</div>';
} else {
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
}
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
}
handEl.innerHTML = thumbsHtml;
@@ -345,6 +290,7 @@ function renderBattlefieldCard(cardData) {
card.style.top = (cardData.y || 10) + 'px';
const playerColor = (gameState.players[cardData.ownerIndex] || {}).color || 'transparent';
card.style.borderColor = playerColor;
if (cardData.tapped) card.classList.add('tapped');
if (cardData.faceDown) card.classList.add('face-down');
@@ -378,6 +324,7 @@ function renderBattlefieldCard(cardData) {
card.innerHTML =
protBadge +
'<img src="' + imgUrl + '" alt="' + altName + '" draggable="false" loading="lazy"/>' +
'<div class="card-owner-bar" style="background:' + playerColor + '"></div>' +
kwBadgeHtml;
// Counter badges
@@ -576,63 +523,6 @@ document.addEventListener('mouseup', e => {
}
});
// ============================================================
// HAND-TO-BATTLEFIELD DRAG & DROP
// ============================================================
function startHandDrag(e, playerIdx, handIndex, imgSrc) {
// Only left mouse button
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
isDraggingFromHand = true;
handDragPlayerIdx = playerIdx;
handDragCardIdx = handIndex;
hideFloatingCardPreview();
// Create ghost element
handDragGhost = document.createElement('div');
handDragGhost.className = 'hand-drag-ghost';
handDragGhost.innerHTML = '<img src="' + imgSrc + '" draggable="false"/>';
handDragGhost.style.left = e.clientX + 'px';
handDragGhost.style.top = e.clientY + 'px';
document.body.appendChild(handDragGhost);
}
document.addEventListener('mousemove', function(e) {
if (!isDraggingFromHand || !handDragGhost) return;
handDragGhost.style.left = e.clientX + 'px';
handDragGhost.style.top = e.clientY + 'px';
});
document.addEventListener('mouseup', function(e) {
if (!isDraggingFromHand) return;
// Clean up ghost
if (handDragGhost) {
handDragGhost.remove();
handDragGhost = null;
}
// Check if dropped over a battle-zone
const pNum = handDragPlayerIdx + 1;
const battleZone = document.getElementById('battle-zone-' + pNum);
if (battleZone) {
const rect = battleZone.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom) {
// Play the card
if (typeof playCardFromHand === 'function') {
playCardFromHand(handDragPlayerIdx, handDragCardIdx);
}
}
}
isDraggingFromHand = false;
handDragPlayerIdx = null;
handDragCardIdx = null;
});
// ============================================================
// PHASE TRACKER
// ============================================================
@@ -856,20 +746,6 @@ function openZoneModal(playerId, zone) {
}
function closeZoneModal() {
// Auto-shuffle the library when closing, and ONLY the library
if (zoneModalPlayerId !== null && gameState) {
const player = gameState.players[zoneModalPlayerId];
if (player && player.zones.library.length > 1) {
const lib = player.zones.library;
for (let i = lib.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lib[i], lib[j]] = [lib[j], lib[i]];
}
addLogEntry('&#x1F500; <strong>' + player.name + "'s</strong> library shuffled.");
updateZoneCountsForPlayer(zoneModalPlayerId);
}
}
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.remove('visible');
zoneModalPlayerId = null;
@@ -886,28 +762,19 @@ function renderZoneModal() {
const player = gameState.players[zoneModalPlayerId];
if (!player) return;
// AI players' hands are private — don't allow the Hand tab to be active
if (player.isAI && activeZoneTab === 'hand') {
activeZoneTab = 'graveyard';
}
const titleEl = document.getElementById('zone-modal-title');
if (titleEl) titleEl.textContent = player.name + "'s Zones";
// Render tabs
const tabsEl = document.getElementById('zone-modal-tabs');
if (tabsEl) {
let zones = [
const zones = [
{ key: 'graveyard', icon: '&#x2620;', label: 'Graveyard' },
{ key: 'exile', icon: '&#x2B21;', label: 'Exile' },
{ key: 'command', icon: '&#x1F451;', label: 'Command' },
{ key: 'hand', icon: '&#x270B;', label: 'Hand' },
{ key: 'library', icon: '&#x1F4DA;', label: 'Library' },
];
// Strip the Hand tab when inspecting an AI opponent (hand is private)
if (player.isAI) {
zones = zones.filter(z => z.key !== 'hand');
}
tabsEl.innerHTML = zones.map(z => {
const count = (player.zones[z.key] || []).length;
@@ -1125,8 +992,6 @@ function initSetupScreen() {
'<button class="ai-diff-btn active" onclick="window._setAiDiff(this,\'easy\')" data-diff="easy" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:rgba(91,141,217,0.2);color:rgba(255,255,255,0.7);cursor:pointer;">Easy</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'normal\')" data-diff="normal" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Normal</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'hard\')" data-diff="hard" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Hard</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'aggro\')" data-diff="aggro" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Aggro</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'combo\')" data-diff="combo" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Combo</button>' +
'</div>' +
'</div>' +
@@ -1147,12 +1012,6 @@ function initSetupScreen() {
'<div id="deck-assignment-grid" class="deck-assignment-grid"></div>' +
'</div>' +
'<div class="setup-section" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;">' +
'<button class="setup-tool-btn" onclick="toggleDeckBuilderPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F0CF; Deck Builder</button>' +
'<button class="setup-tool-btn" onclick="toggleDeckGenPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x26A1; Auto Gen</button>' +
'<button class="setup-tool-btn" onclick="toggleCollectionPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F4DA; Collection</button>' +
'</div>' +
'<button class="btn-start" onclick="startGame()">Begin Battle</button>' +
'</div>';
@@ -1443,83 +1302,18 @@ function switchDeckTab(playerIdx, tab) {
case 'mydecks':
content.innerHTML =
'<div class="deck-mydecks-content">' +
'<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 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>' +
'</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.totalCards || 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;
+1 -1
View File
@@ -330,7 +330,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
labels = []
raw = []
with open(jsonl_path, "r", encoding="utf-8") as f:
with open(jsonl_path, "r") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
+1 -9
View File
@@ -292,15 +292,7 @@ class DistillationDefaults:
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
# Increased from 0.01 to 0.04 to prevent premature convergence on the
# "pass"/"land" macro-actions. The policy-collapse bug (see
# commander-ai-lab-land-only-bug-analysis.md) is exacerbated by a low
# entropy bonus: PPO settled on argmax very early in training, so the
# model never learned to cast creatures / removal / draw spells even
# when they were affordable. A higher entropy bonus keeps the action
# distribution flat enough during self-play for those alternatives to
# accumulate reward signal.
ppo_entropy_coeff: float = 0.04
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
+1 -1
View File
@@ -266,7 +266,7 @@ def build_dataset(
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r", encoding="utf-8") as f:
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
+2 -2
View File
@@ -523,7 +523,7 @@ class QualityGate:
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w", encoding="utf-8") as f:
with open(report_path, "w") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
@@ -582,6 +582,6 @@ class QualityGate:
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
+5 -17
View File
@@ -141,17 +141,8 @@ class CardEmbeddingIndex:
logger.error("Auto-download failed: %s", e)
return None
def get_embedding(self, card_name) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found.
Accepts either a plain string or a dict with a 'name' key (defensive
against the LAN client sending structured battlefield objects).
"""
# Handle dict objects from the LAN client's battlefield_detail
if isinstance(card_name, dict):
card_name = card_name.get('name', '')
if not card_name or not isinstance(card_name, str):
return self._zero_vec
def get_embedding(self, card_name: str) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found."""
if not self._loaded:
return self._zero_vec
idx = self._name_to_idx.get(card_name.lower())
@@ -164,11 +155,8 @@ class CardEmbeddingIndex:
return self._embeddings[idx]
return self._zero_vec
def mean_pool_zone(self, card_names: list) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone).
Each entry may be a plain string or a dict with a 'name' key.
"""
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone)."""
if not card_names:
return self._zero_vec
vectors = [self.get_embedding(name) for name in card_names]
@@ -415,7 +403,7 @@ def encode_decisions_file(
states = []
raw = []
with open(jsonl_path, "r", encoding="utf-8") as f:
with open(jsonl_path, "r") as f:
for i, line in enumerate(f):
if max_samples and i >= max_samples:
break
+2 -2
View File
@@ -130,7 +130,7 @@ class EloHistory:
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r", encoding="utf-8") as f:
with open(self.path, "r") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
@@ -138,7 +138,7 @@ class EloHistory:
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
with open(self.path, "w") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
+2 -2
View File
@@ -280,7 +280,7 @@ class ForgeEvaluator:
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding="utf-8") as f:
with open(batch_path) as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
@@ -543,6 +543,6 @@ class ForgeEvaluator:
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w", encoding="utf-8") as f:
with open(self._result_path, "w") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
-548
View File
@@ -1,548 +0,0 @@
"""
Commander AI Lab — Forge Evaluator (Issue #153)
═════════════════════════════════════════════════
Reusable eval harness that:
1. Starts the policy inference server pointing at a given checkpoint
2. Launches Forge games via MultiThreadBatchRunner with the policy
server wired in as the AI decision-maker
3. Collects per-game metrics and writes a JSON results file
Metrics collected per game:
- won : bool
- turns : int
- life_final : int (winning seat final life)
- life_delta : int (winner life - loser life at game end)
- cmd_damage : int (commander damage dealt by policy seat)
- illegal_acts : int (actions rejected by Forge — should be 0)
- entropy_mean : float (mean policy entropy across decision steps)
- inference_ms : float (mean per-step inference latency)
Usage:
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
cfg = EvalConfig(
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
num_games=200,
num_threads=4,
run_id="forge-trained",
results_dir="results",
)
evaluator = ForgeEvaluator(cfg)
summary = evaluator.run()
print(summary)
"""
import json
import logging
import os
import sys
import time
import uuid
import subprocess
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
logger = logging.getLogger("ml.eval.forge_evaluator")
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
@dataclass
class EvalConfig:
"""All parameters needed for a single evaluation run."""
# Model
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
embeddings_dir: str = "embeddings"
device: str = None # None = auto-detect
# Forge
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
forge_work_dir: str = "forge"
java_path: str = "java"
deck_files: List[str] = field(default_factory=list)
deck_names: List[str] = field(default_factory=list)
policy_server_url: str = "http://localhost:8091"
clock_seconds: int = 120
# Run
num_games: int = 200
num_threads: int = 4
seed: Optional[int] = None
run_id: str = ""
results_dir: str = "results"
# Inference
greedy: bool = True
temperature: float = 1.0
def __post_init__(self):
if not self.run_id:
self.run_id = str(uuid.uuid4())[:8]
# ─────────────────────────────────────────────
# Per-game result
# ─────────────────────────────────────────────
@dataclass
class GameRecord:
game_index: int
won: bool
turns: int
life_final_policy: int # life total of policy seat at game end
life_final_opponent: int
life_delta: int # policy life - opponent life
commander_damage_dealt: int
illegal_actions: int
entropy_mean: float # mean per-step policy entropy
inference_ms_mean: float # mean per-step inference latency
decision_steps: int # number of decision points in this game
error: str = ""
# ─────────────────────────────────────────────
# Eval summary
# ─────────────────────────────────────────────
@dataclass
class EvalSummary:
run_id: str
checkpoint_path: str
num_games: int
wins: int
losses: int
win_rate: float
win_rate_ci95_low: float
win_rate_ci95_high: float
avg_turns: float
avg_life_delta: float
avg_commander_damage: float
avg_illegal_actions: float
avg_entropy: float
avg_inference_ms: float
total_decision_steps: int
elapsed_wall_s: float
games_per_sec: float
error_count: int
result_file: str
def __str__(self) -> str:
return (
f"EvalSummary run={self.run_id}\n"
f" checkpoint : {self.checkpoint_path}\n"
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
f" win rate : {self.win_rate:.1%} "
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
f" avg turns : {self.avg_turns:.1f}\n"
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
f" entropy : {self.avg_entropy:.4f}\n"
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
f" throughput : {self.games_per_sec:.2f} games/sec\n"
f" result file: {self.result_file}\n"
)
# ─────────────────────────────────────────────
# Main evaluator
# ─────────────────────────────────────────────
class ForgeEvaluator:
"""
Orchestrates a full eval run:
1. Spin up PolicyInferenceService on cfg.policy_server_url port
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
3. Parse Forge JSONL output for per-game metrics
4. Write results to {results_dir}/eval-{run_id}.json
"""
def __init__(self, cfg: EvalConfig):
self.cfg = cfg
self._records: List[GameRecord] = []
self._result_path = os.path.join(
cfg.results_dir, f"eval-{cfg.run_id}.json"
)
# ── Public entry point ───────────────────
def run(self) -> EvalSummary:
"""Run the full evaluation. Returns an EvalSummary."""
t0 = time.time()
os.makedirs(self.cfg.results_dir, exist_ok=True)
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
# Load policy service
service = self._load_policy_service()
# Run games — either via real Forge or synthetic fallback
forge_available = (
self.cfg.forge_jar
and Path(self.cfg.forge_jar).exists()
and len(self.cfg.deck_files) >= 3
)
if forge_available:
self._records = self._run_forge_games(service)
else:
logger.warning(
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
self.cfg.run_id
)
self._records = self._run_synthetic_eval(service)
elapsed = time.time() - t0
summary = self._build_summary(elapsed)
self._write_results(summary)
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
return summary
# ── Policy service setup ─────────────────
def _load_policy_service(self):
try:
from ml.serving.policy_server import PolicyInferenceService
svc = PolicyInferenceService(
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
embeddings_dir=self.cfg.embeddings_dir,
device=self.cfg.device,
)
svc.load()
ok = svc.reload(self.cfg.checkpoint_path)
if not ok:
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
return svc
except Exception as e:
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
return None
# ── Real Forge game runner ───────────────
def _run_forge_games(self, service) -> List[GameRecord]:
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
deck_args = []
for i, df in enumerate(self.cfg.deck_files[:4]):
deck_args.extend([f"--deck{i+1}", Path(df).stem])
# Resolve Java 17
java_exe = self.cfg.java_path
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
if Path(c).exists():
java_exe = c
break
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
if not Path(lab_jar).exists():
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
return self._run_synthetic_eval(service)
cmd = [
java_exe, "-jar", lab_jar,
"--forge-jar", self.cfg.forge_jar,
"--forge-dir", self.cfg.forge_work_dir,
*deck_args,
"--games", str(self.cfg.num_games),
"--threads", str(self.cfg.num_threads),
"--clock", str(self.cfg.clock_seconds),
"--ml-log",
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
]
if self.cfg.seed is not None:
cmd.extend(["--seed", str(self.cfg.seed)])
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
if result.returncode != 0:
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
return self._run_synthetic_eval(service)
return self._parse_batch_json(service)
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding="utf-8") as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
"""Parse JSONL files produced by a separately-launched Forge batch."""
records = []
results_path = Path(self.cfg.results_dir)
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
all_files = sorted(results_path.glob(pattern))
if not all_files:
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
return self._run_synthetic_eval(service)
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
games_seen: Dict[str, list] = {}
for f in all_files:
for snap in parse_forge_jsonl(f):
gid = snap.get("game_id", "g0")
games_seen.setdefault(gid, []).append(snap)
for idx, (gid, snaps) in enumerate(games_seen.items()):
records.append(self._snaps_to_record(idx, gid, snaps, service))
if len(records) >= self.cfg.num_games:
break
return records
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
"""Convert Java GameResult objects to GameRecord."""
records = []
for jr in java_results:
try:
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
records.append(GameRecord(
game_index=getattr(jr, "gameIndex", len(records)),
won=won,
turns=getattr(jr, "turns", 0),
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
life_delta=0,
commander_damage_dealt=0,
illegal_actions=getattr(jr, "illegalActions", 0),
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=0,
))
except Exception as e:
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
return records
# ── Synthetic fallback ───────────────────
def _run_synthetic_eval(self, service) -> List[GameRecord]:
"""Run synthetic games using self_play engine (no Forge required).
Used when Forge JAR is missing or deck files aren't configured.
Metrics like illegal_actions and commander_damage are 0 in this mode.
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
records = []
try:
from ml.training.self_play import (
create_random_initial_state, apply_action, advance_turn,
encode_state_simple, HeuristicPolicy
)
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
import torch
import torch.nn.functional as F
except ImportError as e:
logger.error("[Eval] Cannot import self_play: %s", e)
return []
opponent = HeuristicPolicy()
agent_has_model = service is not None and service._loaded
for game_idx in range(self.cfg.num_games):
state = create_random_initial_state()
entropies, latencies = [], []
steps = 0
while not state.game_over and steps < 60:
sv = encode_state_simple(state, 0, "midrange")
t0 = time.time()
if agent_has_model:
snap = state.to_snapshot()
result = service.predict(
snap, playstyle="midrange",
greedy=self.cfg.greedy,
temperature=self.cfg.temperature,
)
action_idx = result.get("action_index", 7)
probs_map = result.get("probabilities", {})
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
else:
action_idx = np.random.randint(NUM_ACTIONS)
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
latencies.append((time.time() - t0) * 1000)
p = np.clip(probs, 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
state = apply_action(state, action_idx, 0)
if not state.game_over:
opp_sv = encode_state_simple(state, 1, "midrange")
opp_a, _ = opponent.select_action(opp_sv)
state = apply_action(state, opp_a, 1)
if not state.game_over:
state = advance_turn(state)
steps += 1
won = state.winner == 0
lp = state.players[0].life_total
lo = state.players[1].life_total
records.append(GameRecord(
game_index=game_idx,
won=won,
turns=state.turn,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=steps,
))
return records
# ── JSONL snapshot → GameRecord ──────────
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
entropies, latencies = [], []
won = False
turns = len(snaps)
cmd_dmg = 0
illegal = 0
lp, lo = 40, 40
for snap in snaps:
if "outcome" in snap and snap == snaps[-1]:
won = snap["outcome"].get("winner_seat", -1) == 0
lp = snap["outcome"].get("life_remaining", 40)
lo = snap["outcome"].get("opp_life_remaining", 0)
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
if service and service._loaded and "state_vector" in snap:
t0 = time.time()
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
latencies.append((time.time() - t0) * 1000)
probs_map = result.get("probabilities", {})
if probs_map:
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
return GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=cmd_dmg,
illegal_actions=illegal,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=len(snaps),
)
# ── Summary builder ──────────────────────
def _build_summary(self, elapsed: float) -> EvalSummary:
n = len(self._records)
if n == 0:
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=0, wins=0, losses=0,
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
error_count=0, result_file=self._result_path,
)
wins = sum(1 for r in self._records if r.won)
losses = n - wins
wr = wins / n
# Wilson score 95% CI
z = 1.96
denom = 1 + z**2 / n
centre = (wr + z**2 / (2 * n)) / denom
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
ci_lo = max(0.0, centre - margin)
ci_hi = min(1.0, centre + margin)
errors = sum(1 for r in self._records if r.error)
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=n,
wins=wins,
losses=losses,
win_rate=wr,
win_rate_ci95_low=ci_lo,
win_rate_ci95_high=ci_hi,
avg_turns=float(np.mean([r.turns for r in self._records])),
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
total_decision_steps=sum(r.decision_steps for r in self._records),
elapsed_wall_s=elapsed,
games_per_sec=n / max(elapsed, 0.001),
error_count=errors,
result_file=self._result_path,
)
# ── Result writer ────────────────────────
def _write_results(self, summary: EvalSummary) -> None:
payload = {
"summary": asdict(summary),
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
+1 -1
View File
@@ -417,7 +417,7 @@ def main():
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w", encoding="utf-8") as f:
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
+2 -2
View File
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
section = "Main"
with open(dck_file, "r", encoding="utf-8") as f:
with open(dck_file, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//") or line.startswith("Name="):
@@ -380,7 +380,7 @@ def main():
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
total_decisions = 0
with open(args.output, "w", encoding="utf-8") as f:
with open(args.output, "w") as f:
for game_idx in range(args.games):
# Pick two random decks
d1_name, d2_name = random.sample(deck_names, 2)
+3 -3
View File
@@ -68,7 +68,7 @@ def cmd_status(args):
for f in jsonl_files:
lines = 0
try:
with open(f, encoding="utf-8") as fh:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -130,7 +130,7 @@ def cmd_inspect(args):
print(f"\n Inspecting: {filepath.name}\n")
count = 0
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
for i, line in enumerate(f):
if i >= args.limit:
break
@@ -150,7 +150,7 @@ def cmd_inspect(args):
except Exception as e:
print(f" [{i:>4}] ERROR: {e}")
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
total = sum(1 for _ in f)
print(f"\n Showing {count} of {total:,} total decisions.\n")
+1 -1
View File
@@ -253,7 +253,7 @@ class DecisionExporter:
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
filepath = os.path.join(self.config.output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
with open(filepath, "w") as f:
for record in self._records:
f.write(json.dumps(record) + "\n")
+2 -2
View File
@@ -759,7 +759,7 @@ class DistillationLoop:
"""Save generation history to JSON."""
path = os.path.join(self.config.history_dir, "generations.json")
history = [g.to_dict() for g in self.generations]
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info(" Generation history saved: %s", path)
@@ -793,7 +793,7 @@ class DistillationLoop:
def _save_summary(self, summary: Dict):
"""Save final summary to JSON."""
path = os.path.join(self.config.history_dir, "summary.json")
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(summary, f, indent=2, default=str)
logger.info(" Summary saved: %s", path)
+1 -1
View File
@@ -91,7 +91,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r", encoding="utf-8") as f:
with open(path, "r") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
+1 -1
View File
@@ -696,7 +696,7 @@ class PPOTrainer:
self.save_checkpoint()
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
with open(history_path, "w", encoding="utf-8") as f:
with open(history_path, "w") as f:
json.dump(self.history, f, indent=2, default=str)
summary = {
+4 -10
View File
@@ -322,7 +322,7 @@ class SupervisedTrainer:
}
serializable_history.append(entry)
with open(history_path, "w", encoding="utf-8") as f:
with open(history_path, "w") as f:
json.dump(serializable_history, f, indent=2)
logger.info(" History saved: %s", history_path)
@@ -547,7 +547,7 @@ def get_best_device() -> str:
return "cpu"
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
@@ -562,12 +562,6 @@ def get_best_device() -> str:
# ══════════════════════════════════════════════════════════
def main():
# Ensure logger output is visible on console
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
parser = argparse.ArgumentParser(
description="Commander AI Lab — Train Policy Network"
)
@@ -644,7 +638,7 @@ def main():
# Save evaluation results
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
return
@@ -702,7 +696,7 @@ def main():
print_evaluation(results)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(" Eval results saved: %s", eval_path)
else:
-5
View File
@@ -35,7 +35,6 @@ 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"
@@ -58,10 +57,6 @@ 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 -19
View File
@@ -50,7 +50,6 @@ DEFAULT_CONFIG = {
"train_lr": 0.001, # learning rate
"train_batch_size": 256, # training batch size
"train_patience": 15, # early stopping patience
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
# Java-only settings (ignored for DeepSeek):
"java_threads": 4,
"java_clock": 300,
@@ -280,21 +279,14 @@ def run_update_weights(cfg):
return False
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
"""Trigger ML training and return immediately.
ds_weight: upsampling multiplier for DeepSeek decision files
(ml-decisions-ds-*.jsonl). Matches the default in
DatasetConfig.source_weights added in the recent
dataset_builder.py update.
"""
def start_training(base_url, epochs, lr, batch_size, patience):
"""Trigger ML training and return immediately."""
resp = api_post(base_url, "/api/ml/train", {
"epochs": epochs,
"lr": lr,
"batchSize": batch_size,
"patience": patience,
"rebuildDataset": True,
"dsWeight": ds_weight,
})
return resp
@@ -424,7 +416,6 @@ def run_overnight(cfg):
log.info(f" Engine: {engine}")
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
log.info("")
while True:
@@ -519,8 +510,7 @@ def run_overnight(cfg):
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
f"DS Weight: {cfg['train_ds_weight']}x")
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
try:
start_training(
@@ -529,7 +519,6 @@ def run_overnight(cfg):
lr=cfg["train_lr"],
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
ds_weight=cfg["train_ds_weight"],
)
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
@@ -567,8 +556,8 @@ Examples:
# Just run sims, skip both weight update and training:
python overnight-run.py --no-train --no-update-weights
# Custom training settings with higher DS upsampling:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
# Custom training settings:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
""",
)
parser.add_argument("--url", default="http://localhost:8080",
@@ -601,8 +590,6 @@ Examples:
help="Training batch size (default: 256)")
parser.add_argument("--patience", type=int, default=15,
help="Early stopping patience (default: 15)")
parser.add_argument("--ds-weight", type=float, default=4.0,
help="DeepSeek source upsampling weight for training (default: 4.0)")
parser.add_argument("--threads", type=int, default=4,
help="Java engine threads (default: 4)")
parser.add_argument("--clock", type=int, default=300,
@@ -624,7 +611,6 @@ Examples:
cfg["train_lr"] = args.lr
cfg["train_batch_size"] = args.batch_size
cfg["train_patience"] = args.patience
cfg["train_ds_weight"] = args.ds_weight
cfg["java_threads"] = args.threads
cfg["java_clock"] = args.clock
-68
View File
@@ -1,68 +0,0 @@
import re
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
content = f.read()
# Fix 1: >= 2 to >= 3
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
content = content.replace(
'return self._run_forge_via_subprocess(service)',
'return self._parse_batch_json(service)'
)
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
new_method = '''
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding='utf-8') as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON -- falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
'''
content = content.replace(
' def _run_forge_via_subprocess(',
new_method + ' def _run_forge_via_subprocess('
)
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
f.write(content)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('Syntax OK - patch applied')
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
Run from anywhere — edit UI_JS_PATH to point at your file.
"""
import re
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
content = f.read()
original_size = len(content)
changes = []
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
old1 = re.search(
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
content, re.DOTALL
)
if old1:
content = content[:old1.start()] + \
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
content[old1.end():]
changes.append("renderManaPool -> stub")
else:
print("WARNING: renderManaPool pattern not matched — skipping")
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
old2 = re.search(
r'function showManaPool\(\) \{.*?\}',
content, re.DOTALL
)
if old2:
content = content[:old2.start()] + \
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
content[old2.end():]
changes.append("showManaPool -> stub")
else:
print("WARNING: showManaPool pattern not matched — skipping")
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
mana_row = (
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
)
if mana_row in content:
content = content.replace(mana_row, ";", 1)
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
else:
print("WARNING: Mana Auto-Clear row not found — skipping")
# ── Write ─────────────────────────────────────────────────────────────────────
if changes:
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
f.write(content)
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
for c in changes:
print(f" ✓ {c}")
else:
print("No changes applied.")
+4 -20
View File
@@ -245,20 +245,6 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
).fetchone()
card_name = ce_row["name"] if ce_row else (req.card_name or "")
# Ensure card_records has type_line for this card (Scryfall lookup if missing)
if card_name:
existing_cr = conn.execute(
"SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,)
).fetchone()
if not existing_cr or not existing_cr["type_line"]:
enriched = _enrich_from_scryfall(card_name)
if enriched and enriched.get("type_line"):
conn.execute(
"INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)",
(card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0))
)
conn.commit()
existing = conn.execute(
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(deck_id, req.scryfall_id)
@@ -796,12 +782,10 @@ async def export_deck_to_sim(deck_id: int):
if not safe_name:
safe_name = f"deck_{deck_id}"
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)
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)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
+7 -8
View File
@@ -861,13 +861,12 @@ async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temp
else:
if not CFG.anthropic_api_key:
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
'Content-Type': 'application/json',
'x-api-key': CFG.anthropic_api_key,
'anthropic-version': '2023-06-01',
}
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
}
# Separate system messages for Anthropic API format
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
user_msgs = [m for m in messages if m.get('role') != 'system']
@@ -1321,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', 'claude-opus-4-20250514')) if _coach._deck_gen_v3 else None,
'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,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
@@ -1342,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 the Anthropic API key is configured.')
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
+1 -1
View File
@@ -226,7 +226,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
with open(batch_path, 'w', encoding='utf-8') as f:
with open(batch_path, 'w') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
+1 -1
View File
@@ -147,7 +147,7 @@ async def start_game(req: StartGameRequest):
# Build a BatchState so the forge_runner watchdog and progress tracking
# work the same way they do for overnight batch runs.
state = BatchState(batch_id=game_id, total_games=1, threads=1)
state = BatchState(batch_id=game_id)
_sessions[game_id] = {
"game_id": game_id,
+2 -10
View File
@@ -259,14 +259,7 @@ async def list_decks():
try:
conn = _get_db_conn()
rows = conn.execute(
"""
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
"""
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
@@ -277,7 +270,6 @@ 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:
@@ -454,7 +446,7 @@ async def get_log(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-2000:]}
return {"lines": state.log_lines[-200:]}
@router.get("/api/lab/debug-log")
+11 -11
View File
@@ -130,7 +130,7 @@ async def ml_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f, encoding="utf-8") as fh:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -168,7 +168,7 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
for i, line in enumerate(f):
if i < offset:
continue
@@ -436,7 +436,7 @@ def _run_training_pipeline(
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# Done
with _training_lock:
@@ -530,7 +530,7 @@ async def ml_data_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r", encoding="utf-8") as fh:
with open(f, "r") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
@@ -561,7 +561,7 @@ async def ml_data_status():
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r", encoding="utf-8") as f:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -705,7 +705,7 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
playstyle=playstyle,
)
output_path = os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w", encoding="utf-8") as f:
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
with _tournament_lock:
_tournament_state.phase = "done"
@@ -758,7 +758,7 @@ async def ml_tournament_results():
project_root = Path(__file__).resolve().parent.parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r", encoding="utf-8") as f:
with open(results_path, "r") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
@@ -798,7 +798,7 @@ async def ml_stats():
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r", encoding="utf-8") as f:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -1057,14 +1057,14 @@ async def ml_distillation_history():
if history_path.exists():
try:
with open(history_path, "r", encoding="utf-8") as f:
with open(history_path, "r") as f:
result["generations"] = json.load(f)
except Exception:
pass
if summary_path.exists():
try:
with open(summary_path, "r", encoding="utf-8") as f:
with open(summary_path, "r") as f:
result["summary"] = json.load(f)
except Exception:
pass
@@ -1203,7 +1203,7 @@ async def ml_elo_history():
history_path = project_root / "data" / "elo_history.json"
if history_path.exists():
try:
with open(history_path, "r", encoding="utf-8") as f:
with open(history_path, "r") as f:
return json.load(f)
except Exception:
pass
+2 -2
View File
@@ -113,7 +113,7 @@ def load_result(path: str) -> dict:
print(f"ERROR: File not found: {path}", file=sys.stderr)
sys.exit(2)
try:
with open(p, encoding="utf-8") as f:
with open(p) as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr)
@@ -289,7 +289,7 @@ def main() -> int:
"passed": delta >= args.min_delta and pval < args.alpha,
}
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as jf:
with open(args.json_out, "w") as jf:
json.dump(out, jf, indent=2)
print(f"Comparison JSON written to: {args.json_out}")
+1 -18
View File
@@ -21,7 +21,6 @@ _BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
_REPO_ROOT / "user-decks",
]
@@ -353,23 +352,7 @@ def _load_deck_cards_by_name(deck_name: str) -> list:
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
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
return [_row_to_dict(r) for r in rows]
# -- 2. .dck file fallback --
dck_path = _find_dck_file(deck_name)
+2 -2
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", "qwen2.5:7b"),
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
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")),
@@ -499,7 +499,7 @@ def _run_deepseek_batch_thread(
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r', encoding='utf-8') as rf:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
+1 -1
View File
@@ -26,7 +26,7 @@ class DeckBuilderAdapter:
def __init__(
self,
db_conn_factory: Callable[[], sqlite3.Connection] = None,
model: str = "claude-opus-4-20250514",
model: str = "gpt-oss:20b",
):
self.db_conn_factory = db_conn_factory
self.model = model
@@ -1,5 +1,5 @@
"""
Ollama client for the Commander AI Deck Builder.
Ollama gpt-oss:20b 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="claude-opus-4-20250514",
help="AI model name (default: claude-opus-4-20250514)",
default="gpt-oss:20b",
help="Ollama model name (default: gpt-oss:20b)",
)
parser.add_argument(
"--output",
+1 -19
View File
@@ -148,11 +148,6 @@ class DeepSeekGameEngine:
p.stats.turns_alive += 1
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Draw ──
if p.library:
drawn = p.library[-1]
@@ -362,20 +357,8 @@ class DeepSeekGameEngine:
self, sim: SimState, pi: int,
preferred_name: str | None, events: list[str] | None
):
"""Play a land from hand. If preferred_name given, try that first.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays (e.g. Exploration, Oracle of Mul Daya) may bump
``_land_plays_this_turn_limit`` on the player to a higher value.
"""
"""Play a land from hand. If preferred_name given, try that first."""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
if preferred_name:
@@ -402,7 +385,6 @@ class DeepSeekGameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
if events is not None and self.record_log:
events.append(f"Played land: {land_card.name}")
+1 -19
View File
@@ -135,11 +135,6 @@ class GameEngine:
sim.active_player_index = pi
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
@@ -459,20 +454,8 @@ class GameEngine:
return sim
def _play_land(self, sim: SimState, pi: int) -> None:
"""Play the first land from hand onto the battlefield.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays may bump ``_land_plays_this_turn_limit`` on the
player to a higher value.
"""
"""Play the first land from hand onto the battlefield."""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
for i, card in enumerate(p.hand):
if card.is_land():
@@ -487,7 +470,6 @@ class GameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
+43 -173
View File
@@ -229,54 +229,61 @@ class CommanderGameState:
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for ``seat``.
"""Return legal moves for a seat.
Delegates to :mod:`legal_move_generator` which produces macro
actions (play_land, cast_creature, cast_removal, cast_ramp,
cast_draw, cast_commander, attack, block, pass_priority).
During DECLARE_BLOCKERS phase, emits block moves for defending
creatures against each attacker in sim_state.combat.
Otherwise returns pass-only (stub for remaining rules engine).
"""
# Local import to avoid a circular reference at module load
from commander_ai_lab.sim.legal_move_generator import generate_legal_moves
return generate_legal_moves(self, seat)
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
# Emit block moves during declare_blockers phase
if (
combat
and combat.active
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
and seat != self.active_player_seat
):
move_id = 1000
my_creatures = [
c for c in self.sim_state.get_battlefield(seat)
if c.is_creature() and not c.tapped
]
for atk_id in combat.attackers:
atk = next(
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
None,
)
if atk is None:
continue
for blocker in my_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move to the game state.
"""Apply a chosen move.
Supports all move categories produced by
:mod:`legal_move_generator`. For categories that require full
rules simulation (spell resolution, combat damage), this is
still a thin layer — it mutates the game state and delegates
final resolution to the engine/turn manager.
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
"""
# Find the move dict
all_moves = self.get_legal_moves(seat)
move = next((m for m in all_moves if m["id"] == move_id), None)
if move is None:
return
category = move.get("category")
if category == "pass_priority":
return
if category == "play_land":
self._apply_play_land(seat, move)
return
if category in ("cast_creature", "cast_removal", "cast_ramp",
"cast_draw", "cast_spell"):
self._apply_cast_spell(seat, move)
return
if category == "cast_commander":
self._apply_cast_commander(seat, move)
return
if category == "attack":
self._apply_declare_attacker(seat, move)
return
if category == "block":
if move.get("category") == "block":
combat = self.sim_state.combat
if combat and combat.active:
atk_id = move["attacker_id"]
@@ -284,143 +291,6 @@ class CommanderGameState:
if atk_id not in combat.blockers:
combat.blockers[atk_id] = []
combat.blockers[atk_id].append(blocker_id)
return
# ── Move execution helpers ───────────────────────────────
def _apply_play_land(self, seat: int, move: dict) -> None:
"""Remove the chosen land from hand, place on battlefield, bump counter."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
# Enforce land-per-turn at apply-time as a second safety check
if player.lands_played_this_turn >= player.land_plays_allowed:
return
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand)
if c.name == card_name and c.is_land()),
-1,
)
if idx < 0:
return
land = player.hand.pop(idx)
land.owner_id = seat
land.tapped = False
land.turn_played = self.turn
land.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, land)
player.lands_played_this_turn += 1
self.land_drop_used = True
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.lands_played += 1
def _apply_cast_spell(self, seat: int, move: dict) -> None:
"""Remove the spell from hand, place it on the battlefield.
This is a simplified resolution (no stack, no targeting) sufficient
for macro-action training and live games until the full stack
engine is wired.
"""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand) if c.name == card_name),
-1,
)
if idx < 0:
return
card = player.hand.pop(idx)
card.owner_id = seat
card.turn_played = self.turn
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
# Creatures and permanents land on the battlefield; instants and
# sorceries go straight to the graveyard after resolving.
type_line = (card.type_line or "").lower()
if "instant" in type_line or "sorcery" in type_line:
player.graveyard.append(card)
else:
card.tapped = False
self.sim_state.add_to_battlefield(seat, card)
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if card.is_creature():
player.base.stats.creatures_played += 1
def _apply_cast_commander(self, seat: int, move: dict) -> None:
"""Cast the commander from the command zone (increments tax)."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.commander_zone)
if c.name == card_name),
-1,
)
if idx < 0 and player.commander_zone:
idx = 0
if idx < 0:
return
cmd = player.commander_zone.pop(idx)
cmd.owner_id = seat
cmd.turn_played = self.turn
cmd.tapped = False
cmd.is_commander = True
cmd.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, cmd)
player.commander_cast_count += 1
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if cmd.is_creature():
player.base.stats.creatures_played += 1
def _apply_declare_attacker(self, seat: int, move: dict) -> None:
"""Mark a creature as attacking in the current combat state.
``combat.attackers`` is a ``dict[int, int]`` mapping attacker
card id to defending seat. If no defending seat has been
chosen yet, fall back to the first non-active, non-eliminated
opponent.
"""
combat = self.sim_state.combat
if combat is None:
return
atk_id = move.get("attacker_id")
if atk_id is None:
return
# Tap the attacker unless it has vigilance
for card in self.sim_state.get_battlefield(seat):
if card.id == atk_id:
has_vigilance = (
card.has_keyword("vigilance")
if hasattr(card, "has_keyword") else False
)
if not has_vigilance:
card.tapped = True
break
# Determine defending seat
defending_seat = combat.defending_seat
if defending_seat < 0 or defending_seat == seat:
for i, p in enumerate(self.commander_players):
if i != seat and not p.eliminated:
defending_seat = i
break
if defending_seat >= 0:
combat.defending_seat = defending_seat
if defending_seat >= 0:
combat.attackers[atk_id] = defending_seat
combat.active = True
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
@@ -1,230 +0,0 @@
"""
Legal Move Generator
====================
Generates the set of legal moves a player can take from a
:class:`CommanderGameState`. This replaces the "pass-only" stub in
``CommanderGameState.get_legal_moves`` so the turn_manager/policy-server
path can drive real games instead of just passing priority every turn.
The emitted moves are intentionally coarse (macro-actions: play_land,
cast_creature, cast_removal, cast_ramp, cast_draw, cast_commander,
attack, block, pass_priority) so that both the neural policy and
heuristic/LLM brains can pick from the same list.
Long-term, when the Forge IPC path is fully wired, Forge's own legal
action list will replace the output of this module. Until then, this
is the authoritative move source for the Python rules layer.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from commander_ai_lab.sim.models import Card, Phase
if TYPE_CHECKING:
from commander_ai_lab.sim.game_state import CommanderGameState
# Map from card enrichment flags to macro-action categories the AI brain
# recognizes. Order matters: the first True flag wins.
_SPELL_FLAG_TO_CATEGORY: tuple[tuple[str, str], ...] = (
("is_ramp", "cast_ramp"),
("is_removal", "cast_removal"),
("is_board_wipe", "cast_removal"),
)
def _classify_spell(card: Card) -> str:
"""Map a Card to a macro-action category string.
Matches the 8-action macro-action space used by the policy network
(``scope.py``): cast_creature, cast_removal, cast_draw, cast_ramp,
cast_commander, attack_opponent, hold_mana, pass. Cards that don't
fit any flag fall back to ``cast_spell`` so downstream code can
still execute them.
"""
for flag, category in _SPELL_FLAG_TO_CATEGORY:
if getattr(card, flag, False):
return category
# Heuristic: draw spells often mention "draw" in oracle text
ot = (getattr(card, "oracle_text", "") or "").lower()
if "draw" in ot and ("card" in ot or "cards" in ot):
return "cast_draw"
if card.is_creature():
return "cast_creature"
return "cast_spell"
def _count_available_mana(game_state: "CommanderGameState", seat: int) -> int:
"""Rough available mana = floating pool + untapped lands on battlefield.
This is intentionally permissive. A production rules engine would
also consider mana dorks, mana rocks, and color requirements, but
for macro-action gating CMC-vs-total-mana is sufficient.
"""
player = game_state.players[seat]
mana = 0
# Floating mana pool (if ManaPool has ``total``)
pool = getattr(player, "mana_pool", None)
if pool is not None:
total_fn = getattr(pool, "total", None)
if callable(total_fn):
try:
mana += int(total_fn())
except Exception:
pass
# Untapped lands on battlefield (each produces ~1 mana)
for card in game_state.sim_state.get_battlefield(seat):
if card.is_land() and not getattr(card, "tapped", False):
mana += 1
return mana
def _phase_is(current: object, target: Phase) -> bool:
"""Safe phase comparison that works with Phase enum or raw string."""
if current == target:
return True
if isinstance(current, str) and current == target.value:
return True
return False
def generate_legal_moves(game_state: "CommanderGameState", seat: int) -> list[dict]:
"""Produce the list of legal moves for ``seat`` given ``game_state``.
Always includes a pass-priority move (id 0) as a safe fallback.
Additional moves are emitted based on phase, turn ownership, and
available resources.
"""
moves: list[dict] = [
{"id": 0, "category": "pass_priority", "description": "Pass"}
]
if not (0 <= seat < len(game_state.players)):
return moves
player = game_state.players[seat]
if getattr(player, "eliminated", False):
return moves
current_phase = game_state.current_phase
is_active = (seat == game_state.active_player_seat)
move_id = 1
# ── Sorcery-speed actions (main phases, active player only) ──
if is_active and (
_phase_is(current_phase, Phase.MAIN1)
or _phase_is(current_phase, Phase.MAIN2)
):
# Land drop — respect one-land-per-turn unless modified
lands_played = getattr(player, "lands_played_this_turn", 0)
land_limit = getattr(player, "land_plays_allowed", 1)
if lands_played < land_limit:
for card in player.hand:
if card.is_land():
moves.append({
"id": move_id,
"category": "play_land",
"card_name": card.name,
"description": f"Play {card.name}",
})
move_id += 1
# Castable spells from hand
available_mana = _count_available_mana(game_state, seat)
for card in player.hand:
if card.is_land():
continue
cost = int(getattr(card, "cmc", 0) or 0)
if cost <= available_mana:
category = _classify_spell(card)
moves.append({
"id": move_id,
"category": category,
"card_name": card.name,
"cmc": cost,
"description": f"Cast {card.name} ({cost} mana)",
})
move_id += 1
# Commander from the command zone (respect commander tax)
tax = 0
if hasattr(player, "commander_tax"):
try:
tax = int(player.commander_tax())
except Exception:
tax = 0
for cmd in getattr(player, "commander_zone", []):
base_cost = int(getattr(cmd, "cmc", 0) or 0)
total_cost = base_cost + tax
if total_cost <= available_mana:
moves.append({
"id": move_id,
"category": "cast_commander",
"card_name": cmd.name,
"cmc": total_cost,
"description": (
f"Cast {cmd.name} from command zone "
f"(base={base_cost}, tax={tax})"
),
})
move_id += 1
# ── Declare attackers — one attack move per legal attacker ──
if is_active and _phase_is(current_phase, Phase.DECLARE_ATTACKERS):
bf = game_state.sim_state.get_battlefield(seat)
for creature in bf:
if not creature.is_creature():
continue
if getattr(creature, "tapped", False):
continue
# Summoning sickness: played this turn and no haste
turn_played = getattr(creature, "turn_played", -1)
has_haste = creature.has_keyword("haste") if hasattr(creature, "has_keyword") else False
if turn_played == game_state.turn and not has_haste:
continue
moves.append({
"id": move_id,
"category": "attack",
"attacker_id": creature.id,
"card_name": creature.name,
"description": f"Attack with {creature.name}",
})
move_id += 1
# ── Declare blockers — emit block pairings for defending player ──
if _phase_is(current_phase, Phase.DECLARE_BLOCKERS) and not is_active:
combat = game_state.sim_state.combat
if combat and getattr(combat, "active", False):
defender_creatures = [
c for c in game_state.sim_state.get_battlefield(seat)
if c.is_creature() and not getattr(c, "tapped", False)
]
for atk_id in getattr(combat, "attackers", []):
atk = None
for bf in game_state.sim_state.battlefields:
for c in bf:
if c.id == atk_id:
atk = c
break
if atk is not None:
break
if atk is None:
continue
for blocker in defender_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
-4
View File
@@ -267,10 +267,6 @@ class CommanderTurnManager:
player = self.gs.players[seat]
logger.debug("Turn %d — %s's turn begins", turn_num, player.name)
# ── Reset per-turn counters at the start of each turn ──
# Enforces MTG CR 305.2 (one land per turn unless modified).
player.lands_played_this_turn = 0
for phase in PHASES:
if self.game_over:
return
@@ -1,840 +0,0 @@
package commanderailab.ai;
import commanderailab.ml.MacroActionExecutor;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import forge.LobbyPlayer;
import forge.ai.AiController;
import forge.ai.ComputerUtilAbility;
import forge.ai.ComputerUtilMana;
import forge.ai.PlayerControllerAi;
import forge.game.Game;
import forge.game.card.Card;
import forge.game.card.CardCollection;
import forge.game.card.CardCollectionView;
import forge.game.phase.PhaseHandler;
import forge.game.phase.PhaseType;
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.zone.ZoneType;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
/**
* PolicyControllerAi — Phase 2 live-game controller that routes AI decisions
* through the Python policy server instead of Forge's built-in heuristics.
*
* <h2>Architecture</h2>
* <pre>
* Forge game loop
* └─ player.getController() → PolicyControllerAi
* └─ brains (PolicyAiController extends AiController)
* └─ chooseSpellAbilityToPlay()
* ├─ buildGameStateJson()
* ├─ policyClient.decide(stateJson) → macro-action
* ├─ mapMacroActionToSpellAbility() → SpellAbility
* └─ super.chooseSpellAbilityToPlay() (fallback)
* </pre>
*
* <h2>Two-class design</h2>
* <ul>
* <li>{@link PolicyAiController} — inner subclass of {@link AiController}
* that overrides {@code chooseSpellAbilityToPlay()}. This is the
* intercept point closest to the game loop.</li>
* <li>{@link PolicyControllerAi} — subclass of {@link PlayerControllerAi}
* that swaps Forge's default {@code AiController} for a
* {@code PolicyAiController} and exposes lifecycle helpers
* (game-end reward submission, statistics).</li>
* </ul>
*
* <h2>Decision flow</h2>
* <ol>
* <li>Build a rich {@link JsonObject} snapshot of the current game state
* (per-player life/mana/zones, stack, phase, legal actions).</li>
* <li>POST the snapshot to {@code /api/policy/decide} via
* {@link PolicyClient#decide(JsonObject)}.</li>
* <li>Translate the returned macro-action name (e.g. {@code "cast_creature"})
* to a concrete {@link SpellAbility} from the legal action list using
* {@link MacroActionExecutor}'s card-role databases.</li>
* <li>If the server is unreachable or no mapping succeeds, fall back to
* {@link AiController#chooseSpellAbilityToPlay()} transparently.</li>
* <li>Log every decision (policy or fallback) for training data collection.</li>
* </ol>
*
* <h2>Fallback guarantee</h2>
* Every code path in {@link PolicyAiController#chooseSpellAbilityToPlay()}
* is wrapped in a broad {@code try/catch}. Any unexpected exception causes
* an immediate delegate to the parent Forge AI so the game continues safely.
*
* <h2>Usage</h2>
* <pre>{@code
* PolicyClient client = new PolicyClient("http://localhost:8080");
* client.connect();
*
* // When Forge constructs the AI player's controller:
* PolicyControllerAi controller = new PolicyControllerAi(game, player, lobbyPlayer, client);
*
* // After the game ends:
* controller.onGameEnd(won ? 1.0 : -1.0);
* }</pre>
*
* @see PolicyClient
* @see PolicyAiController
* @see MacroActionExecutor
* @since Phase 2.1 — Issue #83
*/
public class PolicyControllerAi extends PlayerControllerAi {
// ─────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────
private static final Logger LOG = Logger.getLogger(PolicyControllerAi.class.getName());
/**
* Directory for training-data decision logs.
* One JSON-lines file per game session is written here.
*/
private static final String LOG_DIR = "data/decisions";
// ─────────────────────────────────────────────────────────────────────────
// Instance state
// ─────────────────────────────────────────────────────────────────────────
/** Unique identifier for this game session (used in reward submission). */
private final String gameId = UUID.randomUUID().toString();
/** The policy client used by the embedded {@link PolicyAiController}. */
private final PolicyClient policyClient;
/**
* Log writer for training-data collection.
* Lazily opened on the first decision; {@code null} if log dir is unavailable.
*/
private PrintWriter decisionLog;
/** Running count of decisions in this game (policy + fallback). */
private final AtomicInteger decisionCount = new AtomicInteger(0);
// ─────────────────────────────────────────────────────────────────────────
// Construction
// ─────────────────────────────────────────────────────────────────────────
/**
* Construct a policy-driven AI controller.
*
* <p>The standard Forge {@link AiController} created by the parent class is
* immediately replaced by a {@link PolicyAiController} that holds a
* reference to {@code client}. All other Forge AI infrastructure
* (combat, simulation, sideboard, etc.) is inherited unchanged.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param client live {@link PolicyClient}; must not be {@code null}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
PolicyClient client) {
// Parent creates its own AiController; we replace it below via reflection.
super(game, player, lobbyPlayer);
this.policyClient = client;
// Swap the brains field to our policy-aware subclass.
injectPolicyAiController(game, player);
}
/**
* Convenience constructor that builds a {@link PolicyClient} from a URL.
*
* <p>The client's {@link PolicyClient#connect()} is called automatically;
* if the server is unavailable the controller will silently fall back to
* Forge's built-in AI for all decisions.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param policyServerUrl base URL of the policy server, e.g.
* {@code "http://localhost:8080"}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
String policyServerUrl) {
this(game, player, lobbyPlayer, buildAndConnect(policyServerUrl));
}
// ─────────────────────────────────────────────────────────────────────────
// Lifecycle helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Signal the end of a game so the policy client can submit the terminal
* reward and flush any buffered training tuples.
*
* <p>Call this from your game-runner once Forge reports a winner.</p>
*
* @param reward terminal reward signal: {@code +1.0} for a win,
* {@code -1.0} for a loss, or an intermediate value
*/
public void onGameEnd(double reward) {
try {
policyClient.submitReward(gameId, reward);
policyClient.flushCollectedTuples();
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] onGameEnd error: " + e.getMessage());
} finally {
closeDecisionLog();
}
LOG.info(String.format(
"[PolicyControllerAi] game=%s reward=%.2f decisions=%d stats=%s",
gameId, reward, decisionCount.get(), policyClient.getStatsSummary()));
}
/**
* Returns the unique game identifier used in reward submissions.
*
* @return UUID string
*/
public String getGameId() {
return gameId;
}
/**
* Returns a snapshot of policy statistics for this game session.
*
* @return human-readable stats string from the underlying {@link PolicyClient}
*/
public String getStatsSummary() {
return policyClient.getStatsSummary();
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Uses reflection to replace the {@code brains} field (an {@link AiController})
* that the parent constructor already created with a new
* {@link PolicyAiController}.
*
* <p>This is the only use of reflection in this class. It is necessary
* because {@code PlayerControllerAi} declares {@code brains} as
* {@code private final} and constructs it in its own constructor body,
* before we get a chance to supply a subclass.</p>
*
* <p>If reflection fails for any reason (e.g. a future Forge version adds
* a security manager), the controller falls back to the original
* {@code AiController} that the parent already created. A warning is
* logged so the issue is easy to diagnose.</p>
*
* @param game the active game
* @param player the AI player
*/
private void injectPolicyAiController(Game game, Player player) {
try {
java.lang.reflect.Field brains =
PlayerControllerAi.class.getDeclaredField("brains");
brains.setAccessible(true);
PolicyAiController policyBrains =
new PolicyAiController(player, game, policyClient, this);
brains.set(this, policyBrains);
LOG.info("[PolicyControllerAi] PolicyAiController injected for player="
+ player.getName() + " game=" + gameId);
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] Could not inject PolicyAiController; "
+ "falling back to vanilla Forge AI. Cause: " + e.getMessage());
}
}
/** Build a {@link PolicyClient} and attempt to connect. */
private static PolicyClient buildAndConnect(String serverUrl) {
PolicyClient client = new PolicyClient(serverUrl);
client.connect();
return client;
}
// ─────────────────────────────────────────────────────────────────────────
// Decision logging
// ─────────────────────────────────────────────────────────────────────────
/**
* Write a single JSON-lines decision record to the per-game log file.
* Creates the log directory and file on first call.
*
* @param record the JSON object to append
*/
void logDecision(JsonObject record) {
decisionCount.incrementAndGet();
try {
if (decisionLog == null) {
File dir = new File(LOG_DIR);
//noinspection ResultOfMethodCallIgnored
dir.mkdirs();
File logFile = new File(dir, "decisions-" + gameId + ".jsonl");
decisionLog = new PrintWriter(new FileWriter(logFile, true));
}
decisionLog.println(record.toString());
decisionLog.flush();
} catch (IOException e) {
// Non-fatal: the game continues even if we can't log.
LOG.warning("[PolicyControllerAi] Could not write decision log: "
+ e.getMessage());
}
}
/** Close the decision log file handle. */
private void closeDecisionLog() {
if (decisionLog != null) {
decisionLog.close();
decisionLog = null;
}
}
// ═════════════════════════════════════════════════════════════════════════
// Inner class: PolicyAiController
// ═════════════════════════════════════════════════════════════════════════
/**
* PolicyAiController — intercepts {@link AiController#chooseSpellAbilityToPlay()}
* and routes the decision through the Python policy server.
*
* <h2>Decision flow</h2>
* <ol>
* <li>Enumerate all legal actions (lands + spells/abilities) using
* {@link ComputerUtilAbility} — the same utilities Forge uses
* internally — so the legal-action list is always authoritative.</li>
* <li>Build a {@link JsonObject} describing the full game state,
* including per-player life/mana/zones/stack/phase and a
* {@code legal_actions} array.</li>
* <li>POST to {@code /api/policy/decide} via {@link PolicyClient#decide(JsonObject)}.
* The server returns a macro-action name such as {@code "cast_creature"}.</li>
* <li>Translate the macro-action to a concrete {@link SpellAbility} by
* scanning the legal-action list with
* {@link #mapMacroActionToSpellAbility}.</li>
* <li>On any failure, delegate to the parent
* {@link AiController#chooseSpellAbilityToPlay()} so the game never stalls.</li>
* </ol>
*
* <h2>Thread safety</h2>
* All mutable state is confined to the single game thread that Forge uses
* for AI decisions. The {@link PolicyClient} methods called here are
* themselves thread-safe.
*/
static final class PolicyAiController extends AiController {
/** Maximum age (ms) of a decision before it is considered stale. */
private static final long DECISION_TIMEOUT_MS = 5_000;
private final PolicyClient policyClient;
private final PolicyControllerAi outer;
PolicyAiController(Player player, Game game,
PolicyClient policyClient,
PolicyControllerAi outer) {
super(player, game);
this.policyClient = policyClient;
this.outer = outer;
}
// ─── Main intercept ───────────────────────────────────────────────
/**
* Override of the core AI decision method.
*
* <p>This is called by the Forge game loop whenever the AI player has
* priority and needs to decide whether/what to play. We intercept
* here to route the decision to the policy server.</p>
*
* @return list of {@link SpellAbility} objects for Forge to execute,
* or {@code null} / empty list to pass priority
*/
@Override
public List<SpellAbility> chooseSpellAbilityToPlay() {
long t0 = System.currentTimeMillis();
try {
Player player = getPlayer();
Game game = getGame();
// 1. Enumerate legal actions
List<SpellAbility> legalActions = gatherLegalActions(game, player);
// If there are no legal actions, pass immediately — no server
// round-trip required.
if (legalActions.isEmpty()) {
logAndReturn(null, "pass", "no_legal_actions", t0, legalActions);
return super.chooseSpellAbilityToPlay();
}
// 2. Build game-state JSON with legal actions embedded
JsonObject stateJson = buildGameStateJson(game, player, legalActions);
// 3. Ask the policy server for a macro-action
String macroAction = policyClient.decide(stateJson);
// 4. Map macro-action → concrete SpellAbility
SpellAbility chosen = mapMacroActionToSpellAbility(
macroAction, legalActions, player);
if (chosen != null) {
// Policy succeeded
List<SpellAbility> result = Collections.singletonList(chosen);
logAndReturn(stateJson, macroAction, "policy", t0, result);
return result;
}
// macro-action mapped to "pass" or was unresolvable but not an error
if (MacroActionExecutor.PASS.equals(macroAction)
|| MacroActionExecutor.HOLD_MANA.equals(macroAction)) {
logAndReturn(stateJson, macroAction, "policy_pass", t0, null);
// Return super so Forge can decide whether to pass or play a land
return super.chooseSpellAbilityToPlay();
}
// Macro-action was not mappable to anything concrete; fall back.
logAndReturn(stateJson, macroAction, "fallback_no_map", t0, null);
return super.chooseSpellAbilityToPlay();
} catch (Exception e) {
// Broad catch to guarantee the game never stalls
LOG.warning("[PolicyAiController] Unexpected error: " + e.getMessage());
return super.chooseSpellAbilityToPlay();
}
}
// ─── Legal action enumeration ─────────────────────────────────────
/**
* Gather all legal actions available to {@code player} in the current
* game state, using Forge's own utility methods.
*
* <p>The result combines:</p>
* <ul>
* <li>Land-play abilities (from hand or other sources Forge allows)</li>
* <li>All playable spell/activated abilities from hand and battlefield</li>
* </ul>
*
* @param game the active game
* @param player the acting player
* @return mutable list of legal {@link SpellAbility} objects; never {@code null}
*/
private List<SpellAbility> gatherLegalActions(Game game, Player player) {
List<SpellAbility> actions = new ArrayList<>();
// Land plays — only available during main phases with empty stack
CardCollection lands = ComputerUtilAbility.getAvailableLandsToPlay(game, player);
if (lands != null) {
for (Card land : lands) {
List<SpellAbility> landAbilities =
land.getAllPossibleAbilities(player, true);
landAbilities.removeIf(sa -> !sa.isLandAbility());
actions.addAll(landAbilities);
}
}
// Spells and activated abilities from all available card sources
CardCollection available = ComputerUtilAbility.getAvailableCards(game, player);
if (available != null && !available.isEmpty()) {
List<SpellAbility> spells =
ComputerUtilAbility.getSpellAbilities(available, player);
actions.addAll(spells);
}
return actions;
}
// ─── Snapshot construction ────────────────────────────────────────
/**
* Build a comprehensive game-state {@link JsonObject} suitable for
* POST to {@code /api/policy/decide}.
*
* <p>The JSON schema mirrors the Python-side state encoder:</p>
* <pre>
* {
* "game_id": string,
* "turn": int,
* "phase": string, // e.g. "MAIN1"
* "phase_is_main": bool,
* "stack_size": int,
* "stack": [ { "card": string, "controller": string }, … ],
* "active_seat": int,
* "priority_seat": int,
* "players": [
* {
* "seat": int,
* "name": string,
* "life": int,
* "mana_available": int,
* "commander_damage": int,
* "commander_casts": int,
* "cards_in_hand": int,
* "hand": [ string, … ],
* "battlefield": [ string, … ],
* "graveyard": [ string, … ],
* "command_zone": [ string, … ],
* "creatures_on_field": int,
* "land_count": int,
* "total_power": int,
* "total_toughness": int
* }, …
* ],
* "legal_actions": [
* {
* "index": int,
* "macro": string, // inferred macro-action
* "card_name": string,
* "is_land": bool,
* "mana_cost": string
* }, …
* ]
* }
* </pre>
*
* @param game the active game
* @param player the acting player (seat 0 in the players array)
* @param legalActions the legal actions list produced by
* {@link #gatherLegalActions}
* @return the populated JSON object
*/
private JsonObject buildGameStateJson(Game game, Player player,
List<SpellAbility> legalActions) {
JsonObject root = new JsonObject();
root.addProperty("game_id", outer.gameId);
// ── Phase / turn info ─────────────────────────────────────────
PhaseHandler ph = game.getPhaseHandler();
PhaseType phase = ph.getPhase();
root.addProperty("turn", ph.getTurn());
root.addProperty("phase", phase == null ? "UNKNOWN" : phase.toString());
root.addProperty("phase_is_main",
phase != null && phase.isMain());
// ── Stack info ────────────────────────────────────────────────
int stackSize = game.getStack().size();
root.addProperty("stack_size", stackSize);
JsonArray stackArr = new JsonArray();
for (SpellAbilityStackInstance si : game.getStack()) {
JsonObject se = new JsonObject();
se.addProperty("card", si.getSourceCard() != null
? si.getSourceCard().getName() : "unknown");
se.addProperty("controller", si.getActivatingPlayer() != null
? si.getActivatingPlayer().getName() : "unknown");
stackArr.add(se);
}
root.add("stack", stackArr);
// Seat of the current active (turn) player
Player activePlayer = ph.getPlayerTurn();
int activeSeat = seatIndex(game, activePlayer);
int prioritySeat = seatIndex(game, player);
root.addProperty("active_seat", activeSeat);
root.addProperty("priority_seat", prioritySeat);
// ── Per-player state ──────────────────────────────────────────
JsonArray playersArr = new JsonArray();
List<Player> allPlayers = new ArrayList<>(game.getPlayers());
for (int seat = 0; seat < allPlayers.size(); seat++) {
Player p = allPlayers.get(seat);
playersArr.add(buildPlayerJson(seat, p, game));
}
root.add("players", playersArr);
// ── Legal actions ─────────────────────────────────────────────
JsonArray actionsArr = new JsonArray();
for (int i = 0; i < legalActions.size(); i++) {
SpellAbility sa = legalActions.get(i);
JsonObject ae = new JsonObject();
ae.addProperty("index", i);
ae.addProperty("card_name", sa.getHostCard() != null
? sa.getHostCard().getName() : "unknown");
ae.addProperty("is_land", sa.isLandAbility());
ae.addProperty("mana_cost", sa.getPayCosts() != null
? sa.getPayCosts().getTotalMana().toString() : "");
ae.addProperty("macro", inferMacro(sa));
actionsArr.add(ae);
}
root.add("legal_actions", actionsArr);
return root;
}
/**
* Serialize a single player's observable state into a {@link JsonObject}.
*
* @param seat 0-based seat index in {@code game.getPlayers()}
* @param p the player to serialize
* @param game the active game (for stack/zone queries)
* @return populated player JSON
*/
private JsonObject buildPlayerJson(int seat, Player p, Game game) {
JsonObject pj = new JsonObject();
pj.addProperty("seat", seat);
pj.addProperty("name", p.getName());
pj.addProperty("life", p.getLife());
pj.addProperty("mana_available",
ComputerUtilMana.getAvailableManaEstimate(p));
// Commander damage (from any commander source)
int cdrDamage = 0;
for (Map.Entry<Card, Integer> entry : p.getCommanderDamage()) {
cdrDamage += entry.getValue();
}
pj.addProperty("commander_damage", cdrDamage);
// Commander casts (aggregate across all commanders)
int cdrCasts = 0;
for (Card cdr : p.getCardsIn(ZoneType.Command)) {
cdrCasts += p.getCommanderCast(cdr);
}
pj.addProperty("commander_casts", cdrCasts);
// Zone lists
CardCollectionView hand = p.getCardsIn(ZoneType.Hand);
pj.addProperty("cards_in_hand", hand.size());
pj.add("hand", cardNamesArray(hand));
pj.add("battlefield", cardNamesArray(p.getCardsIn(ZoneType.Battlefield)));
pj.add("graveyard", cardNamesArray(p.getCardsIn(ZoneType.Graveyard)));
pj.add("command_zone", cardNamesArray(p.getCardsIn(ZoneType.Command)));
// Board summary stats
CardCollectionView bf = p.getCardsIn(ZoneType.Battlefield);
int creatures = 0, lands = 0, power = 0, toughness = 0;
for (Card c : bf) {
if (c.isCreature()) {
creatures++;
power += c.getNetPower();
toughness += c.getNetToughness();
}
if (c.isLand()) lands++;
}
pj.addProperty("creatures_on_field", creatures);
pj.addProperty("land_count", lands);
pj.addProperty("total_power", power);
pj.addProperty("total_toughness", toughness);
return pj;
}
// ─── Macro-action mapping ─────────────────────────────────────────
/**
* Map a policy macro-action string back to a concrete {@link SpellAbility}
* from the legal-action list.
*
* <p>The algorithm scans the legal actions list and picks the first
* {@link SpellAbility} whose source card belongs to the category implied
* by the macro-action, using the same card-role databases that
* {@link MacroActionExecutor} uses. This keeps Java and Python in sync.</p>
*
* <p>For {@code CAST_COMMANDER}, the command zone is checked first.</p>
*
* @param macroAction the macro-action returned by the policy server
* @param legalActions all legal actions in the current state
* @param player the acting player (for commander zone check)
* @return the best matching {@link SpellAbility}, or {@code null} if
* no match is found or the macro-action is pass/hold
*/
private SpellAbility mapMacroActionToSpellAbility(
String macroAction,
List<SpellAbility> legalActions,
Player player) {
switch (macroAction) {
// ── Lands ────────────────────────────────────────────────
case MacroActionExecutor.CAST_RAMP: {
// Prefer ramp spells; if none, fall through to a land play
SpellAbility ramp = findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
if (ramp != null) return ramp;
// Land drop counts as ramp when no ramp spell is available
return findFirstMatching(legalActions, SpellAbility::isLandAbility);
}
// ── Creatures ────────────────────────────────────────────
case MacroActionExecutor.CAST_CREATURE: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCreature()
&& !MacroActionExecutor.isRemovalCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isDrawCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
}
// ── Removal / interaction ─────────────────────────────────
case MacroActionExecutor.CAST_REMOVAL: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRemovalCard(sa.getHostCard().getName()));
}
// ── Card draw ────────────────────────────────────────────
case MacroActionExecutor.CAST_DRAW: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isDrawCard(sa.getHostCard().getName()));
}
// ── Commander ────────────────────────────────────────────
case MacroActionExecutor.CAST_COMMANDER: {
// Commander spells appear as regular spells in legalActions
// but their source card is in the command zone.
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCommander()
&& player.getCardsIn(ZoneType.Command)
.contains(sa.getHostCard()));
}
// ── Pass / hold ──────────────────────────────────────────
case MacroActionExecutor.PASS:
case MacroActionExecutor.HOLD_MANA:
case MacroActionExecutor.ATTACK_OPPONENT:
// Attacks are declared in a different decision point;
// returning null here passes priority gracefully.
return null;
default:
LOG.warning("[PolicyAiController] Unknown macro-action: " + macroAction);
return null;
}
}
// ─── Infer macro from SA (for the legal_actions array) ───────────
/**
* Infer which macro-action category a {@link SpellAbility} belongs to.
* Used when populating the {@code legal_actions[].macro} field in the
* state JSON so the policy server can cross-check.
*
* @param sa the spell ability to classify
* @return macro-action name constant from {@link MacroActionExecutor}
*/
private String inferMacro(SpellAbility sa) {
if (sa.isLandAbility()) return MacroActionExecutor.CAST_RAMP;
Card card = sa.getHostCard();
if (card == null) return MacroActionExecutor.PASS;
String name = card.getName();
if (MacroActionExecutor.isRemovalCard(name)) return MacroActionExecutor.CAST_REMOVAL;
if (MacroActionExecutor.isDrawCard(name)) return MacroActionExecutor.CAST_DRAW;
if (MacroActionExecutor.isRampCard(name)) return MacroActionExecutor.CAST_RAMP;
if (card.isCommander()) return MacroActionExecutor.CAST_COMMANDER;
if (card.isCreature()) return MacroActionExecutor.CAST_CREATURE;
return MacroActionExecutor.PASS;
}
// ─── Utility helpers ──────────────────────────────────────────────
/**
* Return the first element of {@code actions} that matches
* {@code predicate}, or {@code null} if none match.
*
* @param actions the list to scan
* @param predicate the filter predicate
* @return first match or {@code null}
*/
private SpellAbility findFirstMatching(
List<SpellAbility> actions,
java.util.function.Predicate<SpellAbility> predicate) {
for (SpellAbility sa : actions) {
if (predicate.test(sa)) return sa;
}
return null;
}
/**
* Return the 0-based seat index of {@code target} in
* {@code game.getPlayers()}, or {@code -1} if not found.
*
* @param game the active game
* @param target the player to look up
* @return seat index
*/
private int seatIndex(Game game, Player target) {
if (target == null) return -1;
List<Player> players = new ArrayList<>(game.getPlayers());
for (int i = 0; i < players.size(); i++) {
if (players.get(i).equals(target)) return i;
}
return -1;
}
/**
* Build a {@link JsonArray} of card names from a zone view,
* suitable for embedding in the state JSON.
*
* @param cards the zone contents
* @return JSON array of name strings
*/
private JsonArray cardNamesArray(CardCollectionView cards) {
JsonArray arr = new JsonArray();
if (cards != null) {
for (Card c : cards) {
arr.add(c.getName());
}
}
return arr;
}
/**
* Write a training-data record and emit a log line for every decision,
* whether it was routed through the policy server or fell back to Forge.
*
* @param stateJson full game-state JSON, or {@code null} for early exits
* @param macro the macro-action chosen
* @param source decision source tag: {@code "policy"}, {@code "fallback_*"}, etc.
* @param t0 timestamp (ms) when this decision cycle started
* @param result the final list of abilities returned (may be {@code null})
*/
private void logAndReturn(JsonObject stateJson, String macro,
String source, long t0,
List<SpellAbility> result) {
long elapsed = System.currentTimeMillis() - t0;
// Build a compact log record for training data
JsonObject record = new JsonObject();
record.addProperty("game_id", outer.gameId);
record.addProperty("ts", Instant.now().toString());
record.addProperty("macro", macro);
record.addProperty("source", source);
record.addProperty("latency_ms", elapsed);
if (result != null && !result.isEmpty()) {
SpellAbility sa = result.get(0);
record.addProperty("card",
sa.getHostCard() != null ? sa.getHostCard().getName() : "land");
}
if (stateJson != null && stateJson.has("turn")) {
record.addProperty("turn", stateJson.get("turn").getAsInt());
record.addProperty("phase", stateJson.get("phase").getAsString());
}
outer.logDecision(record);
LOG.fine(String.format(
"[PolicyAiController] %s → %s (source=%s, %dms)",
macro,
result != null && !result.isEmpty() && result.get(0).getHostCard() != null
? result.get(0).getHostCard().getName() : "pass",
source, elapsed));
}
}
// ── end PolicyAiController ─────────────────────────────────────────────
}
@@ -1,7 +1,6 @@
package commanderailab.batch;
import commanderailab.ai.AiPolicy;
import commanderailab.ai.PolicyControllerAi;
import commanderailab.ml.DecisionExtractor;
import commanderailab.ml.DecisionLogger;
import commanderailab.schema.BatchResult;
@@ -77,12 +76,6 @@ public class BatchRunner {
private boolean useSimplifiedAi = false; // Use faster AI profile for sims
private int aiThinkTimeMs = -1; // -1 = Forge default; >0 = cap AI think time
// ── Policy server integration (PolicyControllerAi) ────────
// When set, the Forge command includes --policyServer so that
// PolicyControllerAi (in-process) or the external policy server
// can intercept AI decisions during batch simulations.
private String policyServerUrl = null;
// ── Regex patterns for parsing Forge sim output ────────────────────
// "Game Outcome: Turn 11"
@@ -193,22 +186,6 @@ public class BatchRunner {
this.aiThinkTimeMs = thinkTimeMs;
}
/**
* Enable policy server integration for batch simulations.
*
* When set, the Forge subprocess command includes
* {@code --policy midrange --policyServer <url>} flags, and
* the log output notes that {@link PolicyControllerAi} is
* available for decision interception.
*
* @param url Base URL of the Python policy server
* (e.g. {@code "http://localhost:8080"})
*/
public void setPolicyServer(String url) {
this.policyServerUrl = url;
System.out.println("[BatchRunner] PolicyControllerAi integration enabled — server: " + url);
}
public void enableMlLogging(String resultsDir, String batchId) {
this.mlLoggingEnabled = true;
this.mlResultsDir = resultsDir;
@@ -443,30 +420,13 @@ public class BatchRunner {
cmd.add("sim");
// Forge's sim mode looks up decks by the Name= field inside the
// .dck file header, NOT by filename. For example:
// Filename: Counter_Intelligence.dck
// Header: Name=Counter Intelligence
// Forge expects: "Counter Intelligence" (with space)
//
// We read the Name= field from the .dck file. If that fails,
// fall back to the filename with underscores replaced by spaces.
// Pass each deck as a full absolute path so Forge can find the .dck file
// regardless of its working directory.
cmd.add("-d");
for (DeckInfo deck : decks) {
String resolvedPath = resolveFullDeckPath(deck.deckFile);
String forgeName = readDckInternalName(resolvedPath);
if (forgeName == null || forgeName.isBlank()) {
// Fallback: strip path/extension and replace _ with space
forgeName = resolvedPath;
int lastSep = Math.max(forgeName.lastIndexOf('/'), forgeName.lastIndexOf('\\'));
if (lastSep >= 0) forgeName = forgeName.substring(lastSep + 1);
if (forgeName.toLowerCase().endsWith(".dck")) {
forgeName = forgeName.substring(0, forgeName.length() - 4);
}
forgeName = forgeName.replace('_', ' ');
}
cmd.add(forgeName);
System.out.println("[DECK] Resolved: " + resolvedPath + " -> Forge name: '" + forgeName + "'");
cmd.add(resolvedPath);
System.out.println("[DECK] Resolved deck path: " + resolvedPath);
}
cmd.add("-f");
@@ -478,19 +438,6 @@ public class BatchRunner {
cmd.add("-c");
cmd.add(String.valueOf(clockSeconds));
// Policy server integration (PolicyControllerAi)
// When policyServerUrl is set, tell Forge to use policy-driven AI.
// The Python policy server at this URL handles POST /api/policy/decide
// and returns macro-actions that PolicyControllerAi maps to concrete
// SpellAbility objects.
if (policyServerUrl != null && !policyServerUrl.isBlank()) {
cmd.add("--policy");
cmd.add("midrange");
cmd.add("--policyServer");
cmd.add(policyServerUrl);
System.out.println("[BatchRunner] Policy server: " + policyServerUrl);
}
if (quiet) {
cmd.add("-q");
}
@@ -862,11 +809,7 @@ public class BatchRunner {
}
}
// ML decision logging: extract state+action snapshots from the
// verbose Forge game log for the training pipeline.
// Note: the !quiet guard was removed — ML logging now works in all
// modes. DecisionExtractor parses the same log lines regardless.
if (mlLoggingEnabled && mlDecisionLogger != null) {
if (mlLoggingEnabled && !quiet && mlDecisionLogger != null) {
try {
String gid = "game-" + gameIndex + "-" + gameSeed;
String[] cmdNames = new String[numPlayers];
@@ -936,32 +879,6 @@ public class BatchRunner {
double simsPerSec, GameResult lastResult);
}
/**
* Read the Name= field from a .dck file header.
* Forge uses this internal name (not the filename) for deck lookup.
* Returns null if the file can't be read or has no Name= field.
*/
private String readDckInternalName(String deckFilePath) {
try {
Path path = resolveExistingDeckPath(deckFilePath);
if (path == null) return null;
List<String> lines = Files.readAllLines(path);
for (String line : lines) {
line = line.trim();
if (line.startsWith("Name=")) {
String name = line.substring(5).trim();
if (!name.isEmpty()) return name;
}
// Stop scanning after [Main] or [Commander] — Name is always in [metadata]
if (line.startsWith("[Main]") || line.startsWith("[Commander]")) break;
}
} catch (Exception e) {
System.err.println("[DECK] Could not read Name= from " + deckFilePath + ": " + e.getMessage());
}
return null;
}
private List<String> readDeckCardNames(String deckFilePath) {
List<String> names = new ArrayList<>();
try {
@@ -5,7 +5,6 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import commanderailab.ai.PolicyClient;
import commanderailab.ai.PolicyControllerAi;
import java.io.*;
import java.net.HttpURLConnection;
@@ -257,12 +256,6 @@ public class GameSession {
* If that fails (missing JAR, wrong Java, etc.) falls back to the
* synthetic stub loop so the WebSocket session never hangs.
*
* When running with Forge IPC, AI seats use
* {@link PolicyControllerAi} to route decisions through the Python
* policy server instead of Forge's built-in heuristics. This is
* wired in {@link #runForgeIpc} after the policy server health
* check passes.
*
* @param onStateChange called after every meaningful game event with
* the latest state snapshot
*/
@@ -270,7 +263,6 @@ public class GameSession {
running = true;
log("[Session] Starting game. Decks: " + deckNames);
log("[Session] Policy server: " + policyServerUrl);
log("[Session] PolicyControllerAi available for Forge AI interception");
boolean forgeAvailable = forgeJarPath != null
&& !forgeJarPath.isBlank()
@@ -283,12 +275,6 @@ public class GameSession {
runSyntheticFallback(onStateChange);
}
// Submit end-of-game reward via PolicyControllerAi if available
if (gameOver && policyClient.isServerAvailable()) {
double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5;
log(String.format("[Session] PolicyControllerAi game-end reward=%.1f", reward));
}
running = false;
log(String.format("[Session] Game ended. turns=%d winner=%s",
turnNumber, winningSeat != null ? "seat-" + winningSeat : "none"));
@@ -333,16 +319,9 @@ public class GameSession {
watchdog.setDaemon(true);
watchdog.start();
// Connect to policy server before the game loop begins.
// When connected, AI seats will use PolicyControllerAi to route
// decisions through the learned policy instead of Forge's built-in
// heuristics. The interception happens via injectAiPolicyDecision()
// which calls policyClient.decide() at each Priority log event.
// Connect to policy server before the game loop begins
boolean policyConnected = policyClient.connect();
log("[Session] Policy server connection: " + (policyConnected ? "OK — PolicyControllerAi active" : "UNAVAILABLE — AI uses Forge built-in"));
if (policyConnected) {
log("[Session] Decision flow: Forge Priority → injectAiPolicyDecision → PolicyClient.decide → /api/policy/decide → macro-action → override");
}
log("[Session] Policy server connection: " + (policyConnected ? "OK" : "UNAVAILABLE — AI uses Forge built-in"));
// Emit initial state so UI shows a board immediately
onStateChange.accept(buildStateSnapshot());
@@ -751,13 +730,6 @@ public class GameSession {
} catch (Exception ignored) {}
}
/**
* Ask the policy server for the best action for {@code seat}.
*
* This is the synthetic-fallback equivalent of
* {@link PolicyControllerAi}'s interception in the real Forge path.
* Both paths ultimately call {@link PolicyClient#decide}.
*/
private String consultsPolicy(int seat) {
try {
PolicyClient.PolicyDecision d = policyClient.decide(GSON.toJson(buildStateSnapshot()), seat);
+4 -11
View File
@@ -348,12 +348,8 @@ public class LabCli implements Callable<Integer> {
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
// Use absolute path so JSONL goes to commander-ai-lab/results/
// regardless of CWD (which is forgeWorkDir for the subprocess)
String mlResultsDir = new File(outputPath).getAbsoluteFile().getParent();
if (mlResultsDir == null) mlResultsDir = "results";
mtRunner.enableMlLogging(mlResultsDir, batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — " + mlResultsDir);
mtRunner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — data will be saved to results/");
}
games = mtRunner.runBatch(numGames, masterSeed);
} else {
@@ -365,11 +361,8 @@ public class LabCli implements Callable<Integer> {
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
// Use absolute path so JSONL goes to commander-ai-lab/results/
String mlResultsDir = new File(outputPath).getAbsoluteFile().getParent();
if (mlResultsDir == null) mlResultsDir = "results";
runner.enableMlLogging(mlResultsDir, batchId);
System.out.println("[ML] Decision logging enabled — " + mlResultsDir);
runner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled — data will be saved to results/");
}
games = runner.runBatchSingleThread(numGames, masterSeed);
@@ -31,28 +31,25 @@ public class DecisionExtractor {
// ── Patterns ──────────────────────────────────────────────
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn: Turn 5 (Ai(1)-Name)"
// Forge verbose output prefixes with "Turn: "
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "== Turn 5 (Ai(1)-Name) =="
private static final Pattern TURN_PATTERN =
Pattern.compile("Turn(?::\\s*Turn)?\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)-", Pattern.CASE_INSENSITIVE);
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)-", Pattern.CASE_INSENSITIVE);
// Phase indicator: various Forge phase strings
private static final Pattern PHASE_PATTERN =
Pattern.compile("(?:Phase|Step):\\s*(.*?)(?:\\s*$|\\s*-)", Pattern.CASE_INSENSITIVE);
// Cast: "Ai(1)-Name casts CardName." or "Add To Stack: Ai(1)-Name cast CardName"
// Forge verbose output uses "cast" (no s) and may have "Add To Stack:" prefix
// Cast: "Ai(1)-Name casts CardName."
private static final Pattern CAST_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+casts?\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+casts\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Land play: "Ai(1)-Name plays LandName." or "Land: Ai(1)-Name played LandName (76)"
// Forge verbose output uses "played" and may have "Land:" prefix + card ID suffix
// Land play: "Ai(1)-Name plays LandName."
private static final Pattern LAND_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+plays\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Draw: "Ai(1)-Name draws CardName." or "Ai(1)-Name drew CardName"
// Draw: "Ai(1)-Name draws CardName."
private static final Pattern DRAW_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+(?:draws|drew)\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Attack: "Ai(1)-Name attacks with" or "declares attackers"
private static final Pattern ATTACK_PATTERN =
@@ -14,7 +14,7 @@ import java.util.*;
* Each game's decisions are appended as JSON lines to a batch-specific file.
* Format: one DecisionSnapshot JSON object per line (JSONL/ndjson).
*
* Output path: results/ml-decisions-sim-{batchId}.jsonl
* Output path: results/ml-decisions-{batchId}.jsonl
*
* Usage from BatchRunner:
* DecisionLogger logger = new DecisionLogger(resultsDir, batchId);
@@ -39,10 +39,7 @@ public class DecisionLogger {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
// Prefix with "sim-" so the Python dataset_builder.py discovers
// these files under the "forge" source category. The builder scans
// for ml-decisions-sim-*.jsonl → source = "forge".
this.outputPath = dir.resolve("ml-decisions-sim-" + batchId + ".jsonl");
this.outputPath = dir.resolve("ml-decisions-" + batchId + ".jsonl");
this.writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
@@ -59,13 +59,7 @@ public class LearnedPolicy implements AiPolicy {
public LearnedPolicy(String serverUrl) {
this.client = new PolicyServerClient(serverUrl);
this.playstyle = "midrange";
// Default to stochastic sampling (greedy=false) so the policy
// network actually explores its action distribution during live
// games. With greedy=true the AI always picks argmax, which
// combined with a pass/land-biased policy causes the "AI only
// plays lands" collapse. Callers that want deterministic
// replay can still override via the 3-arg constructor.
this.greedy = false;
this.greedy = true;
this.policyServerAvailable = false;
}
@@ -267,43 +267,6 @@ public class MacroActionExecutor {
return RAMP_CARDS.contains(card);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known removal-card database.
*
* <p>Used by {@code PolicyControllerAi} to map macro-actions back to
* concrete {@link forge.game.spellability.SpellAbility} objects without
* duplicating the database here.</p>
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as removal
*/
public static boolean isRemovalCard(String cardName) {
return REMOVAL_CARDS.contains(cardName);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known draw-spell database.
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as a draw spell
*/
public static boolean isDrawCard(String cardName) {
return DRAW_CARDS.contains(cardName);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known ramp-spell database.
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as ramp
*/
public static boolean isRampCard(String cardName) {
return RAMP_CARDS.contains(cardName);
}
/**
* Get the macro-action name from an index (matching Python IDX_TO_ACTION).
*/
+2 -2
View File
@@ -32,7 +32,7 @@ def _fake_build_result():
class TestDeckBuilderAdapter:
def test_init_defaults(self):
adapter = DeckBuilderAdapter()
assert adapter.model == "claude-opus-4-20250514"
assert adapter.model == "gpt-oss:20b"
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"] == "claude-opus-4-20250514"
assert result["model"] == "gpt-oss:20b"
assert result["build_time_seconds"] == 1.23
assert "scryfall" in result["reasoning"]["sources"]
-597
View File
@@ -1,597 +0,0 @@
[
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-01a993df.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-02ce51bc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-050da93a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-07325fb5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0791d58e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-083a55e5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-09f6ab6b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0d39aa71.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0dadbf18.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-12802330.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-186b4c2d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-19f0c0c3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1a0a0840.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1d001e22.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1df7d6d8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-220e4942.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-26f83797.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-291a225a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-309b1008.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-31557831.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-37d57d02.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-391e4632.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-39ed0be8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3bb88339.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3c98f83b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3de43fb6.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3e0639f7.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3ea0bfb2.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3f0dc5c4.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3f2f9f04.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-425a6728.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-46517693.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-471701d5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-4c709ff1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-4f5be65a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-501f6ef9.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-592d3d5a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-644cc2f1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-64759781.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6575cbf7.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-682fa8d1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-68352096.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6c982613.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6cdb4b9b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6dee8fdf.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7018972e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7088479f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-74d0f773.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76303df4.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76c602e0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76edf7a3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-77551df3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-77ee4077.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-786ff020.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7d4ad84d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7f74c9f3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7fbdfb25.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7fd1bee8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-804d0dfe.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-805211db.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-812a5124.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-826a32fb.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-878d0278.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8dd40e1b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8fa57d21.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8fe4d31c.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-91a3ac36.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-92418b72.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-96fb5377.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9b0adb9d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9e3ca48f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9ee053b3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9ef2148f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a153e04e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a2676323.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a374549a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a7feffa1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ab868d56.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ab8d55a5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-aee3677b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-af39ff37.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-b101fe7b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bab7b2bf.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bb3e9d83.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bb9772ca.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bf0f9371.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c21b782b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c3e05ffc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c83d2002.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c879aeab.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c9d696cc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ccc989e9.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d09dea45.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d262f1dc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d3d97788.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d68ecb62.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d79c87bc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-dbf46854.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-dd5b74d0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e1e6c928.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e1ff699f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e738a169.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-efd579c0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f06c0e00.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f144217e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f1d0e453.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f2bdceeb.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f79c8493.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fa8a574b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fae68ec0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fee36e36.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-03449551-bbc.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-04380aa6-f62.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-23db752f-de5.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-310629be-8f7.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-63b719a8-1b0.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-7f50242f-986.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-a2f9bbae-f50.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-bd331a14-a89.jsonl",
"weight": 4,
"source": "ds"
}
]
+19 -34
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 (collapsed by default) -->
<details class="lab-import-bar">
<summary class="lab-import-bar-label" style="cursor:pointer;">\u2B07 Advanced Import (URL / Text / Precon)</summary>
<!-- v3: Import Bar -->
<div class="lab-import-bar">
<div class="lab-import-bar-label">Import Deck</div>
<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">\uD83D\uDD17</span> URL Import
<span class="import-icon">🔗</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">\uD83D\uDCCA</span> Commander Meta
<span class="import-icon">📊</span> Commander Meta
</button>
<button class="lab-import-action-btn" onclick="AiLab.showTextImport()" title="Paste a card list directly">
<span class="import-icon">\uD83D\uDCCB</span> Text Import
<span class="import-icon">📋</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">\uD83C\uDCB4</span> Precon Decks
<span class="import-icon">🎴</span> Precon Decks
</button>
</div>
</details>
</div>
<!-- v3: URL Import Panel (hidden by default) -->
<div class="lab-url-import" id="lab-url-import">
@@ -403,9 +403,7 @@ const AiLab = (() => {
const data = await res.json();
decks = (data.decks || []).map(d => ({
name: d.name,
commander: d.commander || d.commander_name || d.name.replace(/_/g, ' '),
source: d.source || 'forge',
deck_id: d.deck_id || null,
commander: d.commander || d.name.replace(/_/g, ' '),
}));
}
} catch (err) {
@@ -429,10 +427,9 @@ const AiLab = (() => {
const prev = select.value;
select.innerHTML = '<option value="">— Select Deck —</option>';
// 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]);
// Group: imported decks first
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const local = availableDecks.filter(d => !importedDeckMeta[d.name]);
if (imported.length > 0) {
const group = document.createElement('optgroup');
@@ -449,23 +446,10 @@ const AiLab = (() => {
select.appendChild(group);
}
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) {
if (local.length > 0) {
const group = document.createElement('optgroup');
group.label = 'Local Decks';
forgeDecks.forEach(deck => {
local.forEach(deck => {
const opt = document.createElement('option');
opt.value = deck.name;
opt.textContent = deck.name;
@@ -1397,8 +1381,9 @@ const AiLab = (() => {
const data = await res.json();
const logEl = document.getElementById('lab-log-content');
if (logEl && data.lines) {
// Show all lines from the backend (up to 200)
logEl.textContent = data.lines.join('\n');
// Show last N lines
const tail = data.lines.slice(-30);
logEl.textContent = tail.join('\n');
logEl.scrollTop = logEl.scrollHeight;
}
} catch { /* ignore */ }
@@ -1739,7 +1724,7 @@ const AiLab = (() => {
// Y-axis labels (0-100%)
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
for (let pct = 0; pct <= 100; pct += 25) {
const y = canvas.height - padding.bottom - (pct / 100) * h;
@@ -1785,7 +1770,7 @@ const AiLab = (() => {
const ly = canvas.height - padding.bottom - (lastPt.winRate / 100) * h;
ctx.fillStyle = colors[di % colors.length];
ctx.textAlign = 'left';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.fillText(data.deckName, lx + 6, ly + 4);
});
}
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Deck Coach</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="coach.css" />
</head>
+6 -6
View File
@@ -810,7 +810,7 @@
}
.coll-td-price {
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
text-align: right;
white-space: nowrap;
}
@@ -840,7 +840,7 @@
.coll-td-pt {
text-align: center;
white-space: nowrap;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
@@ -861,7 +861,7 @@
.coll-td-edhrec {
text-align: right;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: var(--text-secondary);
}
@@ -1872,7 +1872,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
resize: vertical;
@@ -1953,7 +1953,7 @@
color: var(--text);
overflow-x: auto;
white-space: pre;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
margin: 0;
}
@@ -1989,7 +1989,7 @@
font-size: 13px;
font-weight: 600;
color: var(--text);
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.coll-mapping-select {
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Collection</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="collection.css" />
</head>
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Deck Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckbuilder.css" />
</head>
+1 -1
View File
@@ -1267,7 +1267,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-3);
font-family: var(--font-mono);
font-family: 'Courier New', monospace;
font-size: 0.8rem;
scrollbar-width: thin;
}
+8 -22
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Auto Deck Generator V3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckgenerator.css" />
</head>
@@ -142,10 +142,11 @@
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
<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>
</label>
<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>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
</div>
</div>
</div>
@@ -195,7 +196,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 Claude Opus 4...</div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
@@ -207,23 +208,8 @@
</div>
</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>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
</section>
</main>
+3 -6
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 Anthropic API key is configured', 'warning');
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', '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 Claude AI...', 'info');
logMessage('Connecting to local AI model (gpt-oss:20b)...', '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' : 'block';
$('dg-results').style.display = loading ? 'none' : 'none';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
@@ -454,7 +454,6 @@ 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 || '';
@@ -473,7 +472,6 @@ 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, ''),
@@ -509,7 +507,6 @@ const DeckGenerator = (() => {
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Distillation Loop</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="distillation.css" />
</head>
+7 -7
View File
@@ -362,7 +362,7 @@ function renderWinRateChart(generations) {
// Y axis labels
const val = maxY - (maxY - minY) * (i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(val.toFixed(0) + '%', pad.left - 8, y + 4);
}
@@ -437,7 +437,7 @@ function renderWinRateChart(generations) {
}
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 15;
@@ -505,7 +505,7 @@ function renderCompositionChart(generations) {
const val = maxDataset - (maxDataset * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatNumber(val), pad.left - 8, y + 4);
}
@@ -535,13 +535,13 @@ function renderCompositionChart(generations) {
// X label
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('G' + g.generation, x + barWidth / 2, h - 8);
}
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 12;
@@ -807,7 +807,7 @@ function renderEloChart(entries) {
const val = maxElo - (eloRange * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(val).toString(), pad.left - 8, y + 4);
}
@@ -861,7 +861,7 @@ function renderEloChart(entries) {
});
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
let legendX = pad.left + 10;
const legendY = pad.top + 12;
modelNames.forEach(name => {
+24 -49
View File
@@ -61,8 +61,7 @@
--duration: 180ms;
/* Typography */
--font: 'Barlow', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Barlow Condensed', 'Barlow', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
@@ -95,15 +94,13 @@ body {
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: #fff;
font-weight: 700;
line-height: 1.15;
letter-spacing: 0.01em;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 26px; }
h2 { font-size: 19px; }
h1 { font-size: 24px; }
h2 { font-size: 18px; }
h3 { font-size: 16px; }
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
@@ -150,12 +147,10 @@ table { border-collapse: collapse; width: 100%; }
display: flex;
align-items: center;
gap: var(--sp-3);
font-family: var(--font-display);
font-size: 17px;
font-weight: 800;
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
text-transform: uppercase;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
@@ -182,11 +177,8 @@ table { border-collapse: collapse; width: 100%; }
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
@@ -227,11 +219,9 @@ table { border-collapse: collapse; width: 100%; }
}
.page-header h1 {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.005em;
text-transform: uppercase;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
@@ -262,17 +252,13 @@ table { border-collapse: collapse; width: 100%; }
}
.panel-header h2 {
font-family: var(--font-display);
font-size: 17px;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
@@ -302,9 +288,8 @@ table { border-collapse: collapse; width: 100%; }
}
.stat-label {
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -312,9 +297,8 @@ table { border-collapse: collapse; width: 100%; }
}
.stat-value {
font-family: var(--font-display);
font-size: 30px;
font-weight: 800;
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
@@ -333,11 +317,8 @@ table { border-collapse: collapse; width: 100%; }
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
@@ -411,7 +392,6 @@ table { border-collapse: collapse; width: 100%; }
}
.form-label {
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
@@ -530,9 +510,8 @@ table { border-collapse: collapse; width: 100%; }
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
@@ -600,9 +579,8 @@ table { border-collapse: collapse; width: 100%; }
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -629,11 +607,8 @@ table { border-collapse: collapse; width: 100%; }
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="ai-lab.css?v=4" />
<style>
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Simulator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="simulator.css" />
</head>
+15 -58
View File
@@ -39,41 +39,16 @@
// ── Init: load decks ──
async function loadDecks() {
try {
const res = await fetch('/api/lab/decks');
const res = await fetch('/api/decks');
if (!res.ok) return;
const data = await res.json();
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);
}
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);
});
} catch (e) {
console.warn('Could not load decks:', e);
}
@@ -190,17 +165,18 @@
// ── Run from deck ID ──
runBtn.addEventListener('click', async () => {
const val = deckSelect.value;
if (!val) return;
const deckId = parseInt(deckSelect.value);
if (!deckId) 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 Ollama is running at ' + dsApiBase.value);
alert('Could not connect to DeepSeek. Make sure LM Studio is running at ' + dsApiBase.value);
return;
}
await applyDeepSeekConfig();
@@ -213,35 +189,16 @@
hideResults();
try {
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 endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify({ deckId, numGames, recordLogs }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
currentSimId = data.simId;
startPolling();
} catch (e) {
alert('Error: ' + e.message);
resetRunBtn();
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — ML Training</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="training.css" />
</head>
View File
Whitespace-only changes.