From 2a6e3ac8937d34fee048541336b6f2a8eacec311 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 3 Apr 2026 12:46:11 +0000 Subject: [PATCH] fix: refine LAN battlefield integration with full JSDoc, proper phase IDs - Add comprehensive JSDoc comments to all ai-bridge.js classes - Use optional chaining for PHASES lookup in GameStateBridge - Direct addLogEntry calls in player-panels.js (loaded after engine.js) - Remove leftover mana pool CSS that was missed in initial pass - Minor style.css cleanup for consistency Co-Authored-By: Claude Opus 4.6 --- lan-client/ai-bridge.js | 120 +++++++++++++++++++++++++++++------- lan-client/engine.js | 42 ++++++++++--- lan-client/player-panels.js | 23 +++---- lan-client/style.css | 2 - lan-client/ui.js | 9 ++- 5 files changed, 148 insertions(+), 48 deletions(-) diff --git a/lan-client/ai-bridge.js b/lan-client/ai-bridge.js index 686f24a..77629a2 100644 --- a/lan-client/ai-bridge.js +++ b/lan-client/ai-bridge.js @@ -1,6 +1,5 @@ -// ai-bridge.js — Commander-AI-Lab Backend Integration Bridge -// Mediates between the LAN Battlefield App and the Commander-AI-Lab backend. -// Contains: BackendConnector, GameStateBridge, MacroActionMapper, DeckSourceClient +// ai-bridge.js — Commander-AI-Lab Backend Bridge +// Manages REST + WebSocket connectivity to the Commander-AI-Lab backend. 'use strict'; @@ -52,6 +51,10 @@ class BackendConnector { // ── Connection Management ───────────────────────────────── + /** + * Test connectivity to the backend. Returns true if the backend + * is reachable and the health endpoint responds. + */ async checkHealth() { try { const controller = new AbortController(); @@ -82,6 +85,9 @@ class BackendConnector { } } + /** + * Check if the ML policy model is loaded and ready for inference. + */ async checkModelStatus() { if (!this._connected) return false; try { @@ -100,6 +106,10 @@ class BackendConnector { } } + /** + * Full initialization: health check + model status. + * Returns { connected, modelLoaded }. + */ async initialize() { const connected = await this.checkHealth(); let modelLoaded = false; @@ -112,6 +122,10 @@ class BackendConnector { // ── REST API Calls ───────────────────────────────────────── + /** + * POST to any backend endpoint with JSON body. + * Includes retry logic and timeout handling. + */ async _post(path, body, timeoutMs) { const url = `${this.config.backendUrl}${path}`; let lastError = null; @@ -148,6 +162,9 @@ class BackendConnector { throw lastError; } + /** + * GET from any backend endpoint. + */ async _get(path, timeoutMs) { const url = `${this.config.backendUrl}${path}`; const controller = new AbortController(); @@ -168,6 +185,10 @@ class BackendConnector { // ── WebSocket Management ────────────────────────────────── + /** + * Open a WebSocket connection for a game session. + * Receives state deltas, decision events, and game-over signals. + */ connectWebSocket(gameId, clientId) { if (!this.config.enableWebSocket) return; if (this._ws) this.disconnectWebSocket(); @@ -322,6 +343,10 @@ class GameStateBridge { // ── Session Lifecycle ────────────────────────────────────── + /** + * Create a game session on the backend when a new game starts. + * Maps the LAN app's player setup to SeatConfig objects. + */ async initSession(gameState) { if (!this.connector.connected) return null; @@ -353,6 +378,9 @@ class GameStateBridge { } } + /** + * Map LAN app difficulty to backend AI profile. + */ _mapAiProfile(difficulty) { const map = { easy: 'AI', @@ -364,6 +392,10 @@ class GameStateBridge { // ── Game State Serialization ────────────────────────────── + /** + * Convert the JS gameState to a DecisionSnapshot-compatible + * object for the /api/policy/decide endpoint. + */ serializeGameState(gameState, playerIdx) { const players = gameState.players.map((p, i) => { // Count creatures on battlefield @@ -434,9 +466,7 @@ class GameStateBridge { }); // Map current phase to backend format - const phaseId = typeof PHASES !== 'undefined' && PHASES[gameState.currentPhaseIndex] - ? PHASES[gameState.currentPhaseIndex].id - : 'main1'; + const phaseId = PHASES[gameState.currentPhaseIndex]?.id || 'main1'; const phaseMap = { beginning: 'main_1', main1: 'main_1', @@ -460,6 +490,9 @@ class GameStateBridge { }; } + /** + * Get all battlefield cards for a player. + */ _getBattlefieldCards(player, gameState) { if (!gameState || !gameState.battlefield) return []; return (gameState.battlefield || []).filter( @@ -467,6 +500,9 @@ class GameStateBridge { ); } + /** + * Infer a playstyle hint for the AI based on the deck composition. + */ _inferPlaystyle(gameState, playerIdx) { const player = gameState.players[playerIdx]; if (!player) return 'midrange'; @@ -493,6 +529,10 @@ class GameStateBridge { // ── AI Decision Request ─────────────────────────────────── + /** + * Request an AI decision from the backend policy server. + * Returns the macro-action prediction or null if unavailable. + */ async requestAiDecision(gameState, playerIdx) { if (!this.connector.connected || !this.connector.modelLoaded) { return null; @@ -520,6 +560,9 @@ class GameStateBridge { // ── State Sync ──────────────────────────────────────────── + /** + * Push current game state to the backend WebSocket layer. + */ async pushState(gameState) { if (!this._sessionGameId || !this.connector.connected) return; if (!AI_BRIDGE_CONFIG.enableStateSync) return; @@ -534,12 +577,16 @@ class GameStateBridge { snapshot ); } catch (e) { + // Non-critical — log but don't fail if (AI_BRIDGE_CONFIG.verbose) { console.warn('[GameStateBridge] State push failed:', e); } } } + /** + * Push an AI decision event to connected WebSocket clients. + */ async pushDecision(decision) { if (!this._sessionGameId || !this.connector.connected) return; @@ -586,6 +633,15 @@ class GameStateBridge { // ============================================================ class MacroActionMapper { + /** + * Execute a macro-action by calling the appropriate engine.js functions. + * + * @param {string} action - One of the 8 MacroAction values + * @param {number} playerIdx - Index of the AI player + * @param {object} gameState - Current game state + * @param {object} decision - Full decision response from backend + * @returns {boolean} Whether the action was successfully executed + */ static execute(action, playerIdx, gameState, decision) { const player = gameState.players[playerIdx]; if (!player) return false; @@ -595,22 +651,32 @@ class MacroActionMapper { switch (action) { case 'cast_creature': return MacroActionMapper._castCreature(playerIdx, player, gameState, confidence); + case 'cast_removal': return MacroActionMapper._castRemoval(playerIdx, player, gameState); + case 'cast_draw': return MacroActionMapper._castDraw(playerIdx, player, gameState); + case 'cast_ramp': return MacroActionMapper._castRamp(playerIdx, player, gameState); + case 'cast_commander': return MacroActionMapper._castCommander(playerIdx, player, gameState); + case 'attack_opponent': return MacroActionMapper._attackOpponent(playerIdx, player, gameState); + case 'hold_mana': - MacroActionMapper._log(playerIdx, player, 'holding mana open', confidence); + MacroActionMapper._log(playerIdx, player, + 'holding mana open', confidence); return true; + case 'pass': - MacroActionMapper._log(playerIdx, player, 'passing', confidence); + MacroActionMapper._log(playerIdx, player, + 'passing', confidence); return true; + default: console.warn('[MacroActionMapper] Unknown action:', action); return false; @@ -633,7 +699,7 @@ class MacroActionMapper { if (MacroActionMapper._canAfford(playerIdx, card)) { const idx = hand.indexOf(card); if (idx !== -1) { - if (!card.imageUrl && typeof generateAiCardSVG === 'function') card.imageUrl = generateAiCardSVG(card); + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); playCardFromHand(playerIdx, idx); MacroActionMapper._log(playerIdx, player, `cast ${card.name} (creature slot)`, confidence); @@ -654,7 +720,7 @@ class MacroActionMapper { if (MacroActionMapper._canAfford(playerIdx, card)) { const idx = hand.indexOf(card); if (idx !== -1) { - if (!card.imageUrl && typeof generateAiCardSVG === 'function') card.imageUrl = generateAiCardSVG(card); + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); playCardFromHand(playerIdx, idx); MacroActionMapper._log(playerIdx, player, `cast ${card.name}`, confidence); @@ -678,7 +744,7 @@ class MacroActionMapper { if (MacroActionMapper._canAfford(playerIdx, card)) { const idx = hand.indexOf(card); if (idx !== -1) { - if (!card.imageUrl && typeof generateAiCardSVG === 'function') card.imageUrl = generateAiCardSVG(card); + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); playCardFromHand(playerIdx, idx); MacroActionMapper._log(playerIdx, player, `cast removal ${card.name}`); @@ -707,7 +773,7 @@ class MacroActionMapper { if (MacroActionMapper._canAfford(playerIdx, card)) { const idx = hand.indexOf(card); if (idx !== -1) { - if (!card.imageUrl && typeof generateAiCardSVG === 'function') card.imageUrl = generateAiCardSVG(card); + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); playCardFromHand(playerIdx, idx); MacroActionMapper._log(playerIdx, player, `cast draw spell ${card.name}`); @@ -728,7 +794,7 @@ class MacroActionMapper { const landIdx = hand.findIndex(c => MacroActionMapper._isLand(c)); if (landIdx !== -1) { const land = hand[landIdx]; - if (!land.imageUrl && typeof generateAiCardSVG === 'function') land.imageUrl = generateAiCardSVG(land); + if (!land.imageUrl) land.imageUrl = generateAiCardSVG(land); playCardFromHand(playerIdx, landIdx); player.aiLandPlayedThisTurn = true; MacroActionMapper._log(playerIdx, player, @@ -737,7 +803,7 @@ class MacroActionMapper { } } - // Then try ramp spells + // Then try ramp spells (mana rocks, rampant growth effects) const rampSpells = hand.filter(c => MacroActionMapper._looksLikeRamp(c) ); @@ -752,7 +818,7 @@ class MacroActionMapper { if (MacroActionMapper._canAfford(playerIdx, card)) { const idx = hand.indexOf(card); if (idx !== -1) { - if (!card.imageUrl && typeof generateAiCardSVG === 'function') card.imageUrl = generateAiCardSVG(card); + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); playCardFromHand(playerIdx, idx); MacroActionMapper._log(playerIdx, player, `cast ramp ${card.name}`); @@ -808,7 +874,7 @@ class MacroActionMapper { } static _canAfford(playerIdx, card) { - if (typeof gameState === 'undefined' || !gameState) return false; + if (!gameState) return false; const available = typeof aiCountUntappedLands === 'function' ? aiCountUntappedLands(playerIdx) : 0; @@ -850,11 +916,9 @@ class MacroActionMapper { const confStr = confidence ? ` (${Math.round(confidence * 100)}% conf.)` : ''; - if (typeof addLogEntry === 'function') { - addLogEntry( - `\u{1F9E0} ${player.name} ${message}${confStr} [ML]` - ); - } + addLogEntry( + `\u{1F9E0} ${player.name} ${message}${confStr} [ML]` + ); } } @@ -870,9 +934,14 @@ class DeckSourceClient { this._cacheTtlMs = 5 * 60 * 1000; // 5 minutes } + /** + * Fetch precon decks from the backend. + * Returns array: [{ fileName, deckName, commander, colors, set, ... }] + */ async fetchPrecons(forceRefresh = false) { if (!this.connector.connected) return []; + // Check cache if (!forceRefresh && this._preconCache && (Date.now() - this._preconCacheTime) < this._cacheTtlMs) { return this._preconCache; @@ -889,6 +958,9 @@ class DeckSourceClient { } } + /** + * Fetch EDHREC recommendations for a commander. + */ async fetchEdhrecDecks(commanderName) { if (!this.connector.connected) return []; try { @@ -901,6 +973,9 @@ class DeckSourceClient { } } + /** + * Get deck content (card list) for a specific precon. + */ async fetchPreconDeck(fileName) { if (!this.connector.connected) return null; try { @@ -931,6 +1006,9 @@ window.AIBridge = { deckSource: aiBridgeDeckSource, MacroActionMapper: MacroActionMapper, + /** + * Convenience: initialize the bridge and report status. + */ async init() { const status = await aiBridgeConnector.initialize(); if (status.connected) { diff --git a/lan-client/engine.js b/lan-client/engine.js index 7a25f71..fce6293 100644 --- a/lan-client/engine.js +++ b/lan-client/engine.js @@ -248,6 +248,9 @@ function createInitialState(playerCount, playerNames, startingLife) { startingLife, poison: 0, energy: 0, + experience: 0, + rad: 0, + emblems: [], eliminated: false, commanderDamage: {}, // keyed by opponent player id zones: { @@ -262,9 +265,6 @@ function createInitialState(playerCount, playerNames, startingLife) { commanderCardName: '', commanderCastCount2: 0, commanderCardName2: '', - experience: 0, - rad: 0, - emblems: [], }; // Initialize commander damage counters for (let j = 0; j < playerCount; j++) { @@ -5144,8 +5144,6 @@ function createPlayerPanel(player, isActive) { - -
CMD Damage @@ -5244,8 +5242,6 @@ function adjustLife(playerId, amount) { } setLife(playerId, player.life + amount); - // Sync enhanced player panels - if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels(); } function setLife(playerId, newLife) { @@ -5294,6 +5290,8 @@ function setLife(playerId, newLife) { eliminatePlayer(playerId, 'life total reached 0'); } checkStateBasedActions(); + // Sync enhanced player panels + if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels(); } // ============================================================ @@ -12300,6 +12298,36 @@ function toggleManaAutoClear() { function renderManaPool() { // Mana pool display removed — internal tracking only. + // No visual update needed. +} + +// Legacy renderManaPool body preserved for reference (removed from UI) +function _renderManaPoolLegacy() { + if (!gameState) return; + const pid = getSelectedManaPlayerId(); + const player = gameState.players[pid]; + if (!player) return; + + let total = 0; + MANA_COLORS.forEach(c => { + const val = player.manaPool[c] || 0; + total += val; + const el = document.getElementById(`mana-${c}`); + if (el) { + el.textContent = val; + el.classList.toggle('has-mana', val > 0); + } + }); + + const totalEl = document.getElementById('mana-total'); + if (totalEl) totalEl.textContent = total; + + // Refresh hand castability badges when mana changes + if (typeof renderHandArea === 'function') { + // Debounce to avoid excessive rerenders + if (renderManaPool._handTimer) clearTimeout(renderManaPool._handTimer); + renderManaPool._handTimer = setTimeout(function() { renderHandArea(); }, 100); + } } function populateManaPlayerSelect() { diff --git a/lan-client/player-panels.js b/lan-client/player-panels.js index c026797..cce9fc0 100644 --- a/lan-client/player-panels.js +++ b/lan-client/player-panels.js @@ -5,6 +5,11 @@ /** * Create an enhanced player panel widget for a quadrant. + * Replaces the basic life counter with a full tracking panel. + * + * @param {object} player - Player object from gameState + * @param {number} quadrant - 1-4 (matches CSS class p1-p4) + * @param {boolean} isActive - Whether this is the active player */ function createEnhancedPlayerPanel(player, quadrant, isActive) { var panel = document.createElement('div'); @@ -178,9 +183,7 @@ function adjustEnergy(playerId, amount) { var player = gameState.players[playerId]; if (!player) return; player.energy = Math.max(0, (player.energy || 0) + amount); - if (typeof addLogEntry === 'function') { - addLogEntry('' + player.name + ' energy: ' + player.energy); - } + addLogEntry('' + player.name + ' energy: ' + player.energy); renderEnhancedPanels(); } @@ -189,9 +192,7 @@ function adjustExperience(playerId, amount) { var player = gameState.players[playerId]; if (!player) return; player.experience = Math.max(0, (player.experience || 0) + amount); - if (typeof addLogEntry === 'function') { - addLogEntry('' + player.name + ' experience: ' + player.experience); - } + addLogEntry('' + player.name + ' experience: ' + player.experience); renderEnhancedPanels(); } @@ -200,9 +201,7 @@ function adjustRad(playerId, amount) { var player = gameState.players[playerId]; if (!player) return; player.rad = Math.max(0, (player.rad || 0) + amount); - if (typeof addLogEntry === 'function') { - addLogEntry('' + player.name + ' rad counters: ' + player.rad); - } + addLogEntry('' + player.name + ' rad counters: ' + player.rad); renderEnhancedPanels(); } @@ -214,9 +213,7 @@ function showAddEmblemDialog(playerId) { if (!player) return; if (!player.emblems) player.emblems = []; player.emblems.push({ name: name.trim() }); - if (typeof addLogEntry === 'function') { - addLogEntry('' + player.name + ' gained emblem: ' + name.trim()); - } + addLogEntry('' + player.name + ' gained emblem: ' + name.trim()); renderEnhancedPanels(); } @@ -225,7 +222,7 @@ function removeEmblem(playerId, idx) { var player = gameState.players[playerId]; if (!player || !player.emblems) return; var removed = player.emblems.splice(idx, 1); - if (removed.length > 0 && typeof addLogEntry === 'function') { + if (removed.length > 0) { addLogEntry('' + player.name + ' lost emblem: ' + removed[0].name); } renderEnhancedPanels(); diff --git a/lan-client/style.css b/lan-client/style.css index a7e6fdb..a3e3c24 100644 --- a/lan-client/style.css +++ b/lan-client/style.css @@ -1460,8 +1460,6 @@ body.stack-pick-mode .battlefield-card { line-height: 1; } -/* Mana pool display removed — internal tracking only */ - /* ============================================================ TRIGGER STACK PANEL ============================================================ */ diff --git a/lan-client/ui.js b/lan-client/ui.js index 0ed11d2..9ceb38a 100644 --- a/lan-client/ui.js +++ b/lan-client/ui.js @@ -1188,6 +1188,7 @@ function updateDeckAssignmentGrid(playerCount) { } function _buildPreconTabContent(playerIdx) { + // Local precons from precons.js (PRECON_DATA) var preconList = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : []; if (preconList.length === 0) { @@ -1297,11 +1298,9 @@ function filterPrecons(playerIdx, query) { var q = query.toLowerCase().trim(); list.querySelectorAll('.deck-precon-item').forEach(function(item) { - var name = item.querySelector('.deck-precon-name'); - var cmdr = item.querySelector('.deck-precon-cmdr'); - var nameText = name ? name.textContent.toLowerCase() : ''; - var cmdrText = cmdr ? cmdr.textContent.toLowerCase() : ''; - var match = !q || nameText.includes(q) || cmdrText.includes(q); + var name = item.querySelector('.deck-precon-name')?.textContent?.toLowerCase() || ''; + var cmdr = item.querySelector('.deck-precon-cmdr')?.textContent?.toLowerCase() || ''; + var match = !q || name.includes(q) || cmdr.includes(q); item.style.display = match ? '' : 'none'; });