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 <noreply@anthropic.com>
This commit is contained in:
Claude Code committed 2026-04-03 12:46:11 +00:00
1 parent 36742d3cd5
commit 2a6e3ac893
5 files changed
+148 -48

No files matched your search

+99 -21
View File
@@ -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 <strong>${card.name}</strong> (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 <strong>${card.name}</strong>`, 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 <strong>${card.name}</strong>`);
@@ -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 <strong>${card.name}</strong>`);
@@ -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 <strong>${card.name}</strong>`);
@@ -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
? ` <span style="color:var(--color-text-faint)">(${Math.round(confidence * 100)}% conf.)</span>`
: '';
if (typeof addLogEntry === 'function') {
addLogEntry(
`\u{1F9E0} <strong>${player.name}</strong> ${message}${confStr} [ML]`
);
}
addLogEntry(
`\u{1F9E0} <strong>${player.name}</strong> ${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) {
+35 -7
View File
@@ -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) {
<button class="life-btn" onclick="adjustLife(${player.id}, 1)" oncontextmenu="event.preventDefault();adjustLife(${player.id}, 5)" title="Left: +1 | Right: +5">+</button>
</div>
<!-- Mana pool display removed -->
<div class="cmd-damage-section">
<div class="cmd-damage-label">
<span>CMD Damage</span>
@@ -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() {
+10 -13
View File
@@ -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('<strong>' + player.name + '</strong> energy: ' + player.energy);
}
addLogEntry('<strong>' + player.name + '</strong> 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('<strong>' + player.name + '</strong> experience: ' + player.experience);
}
addLogEntry('<strong>' + player.name + '</strong> 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('<strong>' + player.name + '</strong> rad counters: ' + player.rad);
}
addLogEntry('<strong>' + player.name + '</strong> 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('<strong>' + player.name + '</strong> gained emblem: ' + name.trim());
}
addLogEntry('<strong>' + player.name + '</strong> 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('<strong>' + player.name + '</strong> lost emblem: ' + removed[0].name);
}
renderEnhancedPanels();
-2
View File
@@ -1460,8 +1460,6 @@ body.stack-pick-mode .battlefield-card {
line-height: 1;
}
/* Mana pool display removed — internal tracking only */
/* ============================================================
TRIGGER STACK PANEL
============================================================ */
+4 -5
View File
@@ -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';
});