Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f479dcd0 | ||
|
|
0e49f46513 | ||
|
|
b828c474c7 | ||
|
|
105c8db4ab | ||
|
|
a5a66a81f2 | ||
|
|
bce0d50e39 |
No files matched your search
+3
-4
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>📤</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>🎮</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
@@ -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">×</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 & 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">⚡</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">⚡</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">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 Save to Deck Builder</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 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>
|
||||
@@ -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 + '">×</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">🎮</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, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
DeckGenerator.init();
|
||||
});
|
||||
+105
-200
@@ -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, '<') + '</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... // Commander 1 Atraxa, Praetors\' Voice // Deck 1 Sol Ring ..."></textarea>' +
|
||||
'<button class="deck-select-import-btn" onclick="importSetupDeck(' + idx + ')">\uD83D\uDCE5 Validate & Use This List</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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, '<') + '</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;
|
||||
@@ -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
@@ -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">⚙</button>
|
||||
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</button>
|
||||
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">🃏</button>
|
||||
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">✕</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">−</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">−</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">−</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">−</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">−</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">—</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">−</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">—</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">−</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">—</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">−</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">—</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">‹</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">✕</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">✨ Create Token</button>
|
||||
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">⇄ 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">›</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">×</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">×</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">×</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,'<');
|
||||
var snip = (t.oracleSnippet || '').replace(/</g,'<');
|
||||
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();">✕</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 || '—';
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+24
-230
@@ -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') ? '⬡' : '☠';
|
||||
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('🔀 <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: '☠', label: 'Graveyard' },
|
||||
{ key: 'exile', icon: '⬡', label: 'Exile' },
|
||||
{ key: 'command', icon: '👑', label: 'Command' },
|
||||
{ key: 'hand', icon: '✋', label: 'Hand' },
|
||||
{ key: 'library', icon: '📚', 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;">🃏 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;">⚡ 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;">📚 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, '<') + '</span>' +
|
||||
'<span class="deck-precon-cmdr">' + (cmdr ? cmdr.replace(/</g, '<') + ' \u2014 ' : '') + count + ' cards</span>' +
|
||||
'</button>';
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.warn('[_loadMyDecksTab] Error:', e);
|
||||
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Could not load decks. Is the server running?</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function selectUserDeck(playerIdx, deckId, deckName) {
|
||||
var statusEl = document.getElementById('deck-status-' + playerIdx);
|
||||
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
|
||||
|
||||
// Highlight selected button
|
||||
var listEl = document.getElementById('mydecks-list-' + playerIdx);
|
||||
if (listEl) {
|
||||
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) { b.classList.remove('selected'); });
|
||||
// Find the clicked button
|
||||
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) {
|
||||
if (b.querySelector('.deck-precon-name') && b.querySelector('.deck-precon-name').textContent === deckName) {
|
||||
b.classList.add('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export to .dck then store the choice
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(result) {
|
||||
if (result.success) {
|
||||
if (typeof setupDeckChoices !== 'undefined') {
|
||||
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
|
||||
} else {
|
||||
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[selectUserDeck] Export failed:', e);
|
||||
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
|
||||
});
|
||||
}
|
||||
|
||||
function filterPrecons(playerIdx, query) {
|
||||
var list = document.getElementById('precon-list-' + playerIdx);
|
||||
if (!list) return;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 Save to Deck Builder</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
|
||||
<div class="dg-results" id="dg-results" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+3
-6
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user