- Fix AI turn pipeline: add cmc to Scryfall cache, tag isRamp/isRemoval/ isBoardWipe from oracle text during enrichment, enable post-combat spell casting for all difficulty levels - Migrate deck builder, deck generator, and collection from ui/ to lan-client/ as iframe-embedded panels with LAB_API_BASE patching - Fix ai-bridge.js: turnNumber, deck_name, battlefield serialization, 5-playstyle mapping, window exports for cross-script access - Fix engine.js: battlefieldCards flat array (not player.battlefield), aiDeclareAttacks extraction, combat overlay fixes - Fix collection.js: template literal, column visibility alignment, set_code key, oracle text column, sortable category - Add exile/graveyard last-card image rendering in ui.js - Make state_encoder.py resilient to mixed input types - Enrich type_line from Scryfall at card insert in deckbuilder.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1045 lines
32 KiB
JavaScript
1045 lines
32 KiB
JavaScript
// ai-bridge.js — Commander-AI-Lab Backend Bridge
|
|
// Manages REST + WebSocket connectivity to the Commander-AI-Lab backend.
|
|
|
|
'use strict';
|
|
|
|
const AI_BRIDGE_VERSION = '1.0.0';
|
|
|
|
// ============================================================
|
|
// CONFIGURATION
|
|
// ============================================================
|
|
|
|
const AI_BRIDGE_CONFIG = {
|
|
// Backend URL — empty string = same origin (recommended when served from backend)
|
|
// Falls back to localhost:8080 if served from a different origin
|
|
backendUrl: '',
|
|
wsUrl: '',
|
|
|
|
// Timeouts (ms)
|
|
healthCheckTimeout: 3000,
|
|
decisionTimeout: 5000,
|
|
reconnectInterval: 10000,
|
|
|
|
// Retry policy
|
|
maxRetries: 2,
|
|
retryDelayMs: 500,
|
|
|
|
// Feature flags
|
|
enableWebSocket: true,
|
|
enableStateSync: true,
|
|
fallbackToLocalAi: true,
|
|
|
|
// Logging
|
|
verbose: false,
|
|
};
|
|
|
|
// ============================================================
|
|
// BACKEND CONNECTOR
|
|
// ============================================================
|
|
|
|
class BackendConnector {
|
|
constructor(config = {}) {
|
|
this.config = { ...AI_BRIDGE_CONFIG, ...config };
|
|
|
|
// Derive URLs from current page origin when empty (same-origin mode)
|
|
if (!this.config.backendUrl) {
|
|
this.config.backendUrl = window.location.origin; // e.g. http://localhost:8080
|
|
}
|
|
if (!this.config.wsUrl) {
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
this.config.wsUrl = `${proto}//${window.location.host}`;
|
|
}
|
|
|
|
this._connected = false;
|
|
this._modelLoaded = false;
|
|
this._ws = null;
|
|
this._gameId = null;
|
|
this._reconnectTimer = null;
|
|
this._healthCheckTimer = null;
|
|
this._lastHealthCheck = 0;
|
|
this._eventListeners = {};
|
|
}
|
|
|
|
// ── 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();
|
|
const timeout = setTimeout(
|
|
() => controller.abort(),
|
|
this.config.healthCheckTimeout
|
|
);
|
|
|
|
const resp = await fetch(`${this.config.backendUrl}/api/health`, {
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this._connected = data.status === 'ok';
|
|
this._lastHealthCheck = Date.now();
|
|
return this._connected;
|
|
}
|
|
this._connected = false;
|
|
return false;
|
|
} catch (e) {
|
|
this._connected = false;
|
|
if (this.config.verbose) {
|
|
console.warn('[AI Bridge] Health check failed:', e.message);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the ML policy model is loaded and ready for inference.
|
|
*/
|
|
async checkModelStatus() {
|
|
if (!this._connected) return false;
|
|
try {
|
|
const resp = await fetch(
|
|
`${this.config.backendUrl}/api/policy/health`
|
|
);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this._modelLoaded = data.ready === true;
|
|
return this._modelLoaded;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
this._modelLoaded = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full initialization: health check + model status.
|
|
* Returns { connected, modelLoaded }.
|
|
*/
|
|
async initialize() {
|
|
const connected = await this.checkHealth();
|
|
let modelLoaded = false;
|
|
if (connected) {
|
|
modelLoaded = await this.checkModelStatus();
|
|
}
|
|
this._emit('status', { connected, modelLoaded });
|
|
return { connected, modelLoaded };
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(
|
|
() => controller.abort(),
|
|
timeoutMs || this.config.decisionTimeout
|
|
);
|
|
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
if (resp.ok) {
|
|
return await resp.json();
|
|
}
|
|
lastError = new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
} catch (e) {
|
|
lastError = e;
|
|
if (attempt < this.config.maxRetries) {
|
|
await new Promise(r =>
|
|
setTimeout(r, this.config.retryDelayMs * (attempt + 1))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* GET from any backend endpoint.
|
|
*/
|
|
async _get(path, timeoutMs) {
|
|
const url = `${this.config.backendUrl}${path}`;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(
|
|
() => controller.abort(),
|
|
timeoutMs || this.config.healthCheckTimeout
|
|
);
|
|
try {
|
|
const resp = await fetch(url, { signal: controller.signal });
|
|
clearTimeout(timeout);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`HTTP ${resp.status}`);
|
|
} catch (e) {
|
|
clearTimeout(timeout);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ── 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();
|
|
|
|
this._gameId = gameId;
|
|
const cid = clientId || `lan-${Date.now().toString(36)}`;
|
|
const url = `${this.config.wsUrl}/ws/game/${gameId}?client_id=${cid}`;
|
|
|
|
try {
|
|
this._ws = new WebSocket(url);
|
|
|
|
this._ws.onopen = () => {
|
|
if (this.config.verbose) {
|
|
console.log('[AI Bridge] WebSocket connected:', gameId);
|
|
}
|
|
this._emit('ws:connected', { gameId });
|
|
};
|
|
|
|
this._ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
this._handleWsMessage(msg);
|
|
} catch (e) {
|
|
console.warn('[AI Bridge] WS message parse error:', e);
|
|
}
|
|
};
|
|
|
|
this._ws.onclose = (event) => {
|
|
if (this.config.verbose) {
|
|
console.log('[AI Bridge] WebSocket closed:', event.code);
|
|
}
|
|
this._ws = null;
|
|
this._emit('ws:disconnected', { gameId, code: event.code });
|
|
|
|
// Auto-reconnect if game is still active
|
|
if (this._gameId && this.config.enableWebSocket) {
|
|
this._reconnectTimer = setTimeout(() => {
|
|
this.connectWebSocket(gameId, cid);
|
|
}, this.config.reconnectInterval);
|
|
}
|
|
};
|
|
|
|
this._ws.onerror = (error) => {
|
|
console.warn('[AI Bridge] WebSocket error:', error);
|
|
this._emit('ws:error', { error });
|
|
};
|
|
} catch (e) {
|
|
console.warn('[AI Bridge] WebSocket connection failed:', e);
|
|
}
|
|
}
|
|
|
|
disconnectWebSocket() {
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
if (this._ws) {
|
|
this._ws.onclose = null; // prevent auto-reconnect
|
|
this._ws.close();
|
|
this._ws = null;
|
|
}
|
|
this._gameId = null;
|
|
}
|
|
|
|
_handleWsMessage(msg) {
|
|
switch (msg.type) {
|
|
case 'snapshot':
|
|
this._emit('state:snapshot', msg);
|
|
break;
|
|
case 'delta':
|
|
this._emit('state:delta', msg);
|
|
break;
|
|
case 'decision':
|
|
this._emit('ai:decision', msg);
|
|
break;
|
|
case 'game_over':
|
|
this._emit('game:over', msg);
|
|
break;
|
|
case 'pong':
|
|
break;
|
|
case 'heartbeat':
|
|
break;
|
|
default:
|
|
if (this.config.verbose) {
|
|
console.log('[AI Bridge] Unknown WS message type:', msg.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Event System ──────────────────────────────────────────
|
|
|
|
on(event, callback) {
|
|
if (!this._eventListeners[event]) {
|
|
this._eventListeners[event] = [];
|
|
}
|
|
this._eventListeners[event].push(callback);
|
|
return () => this.off(event, callback);
|
|
}
|
|
|
|
off(event, callback) {
|
|
const listeners = this._eventListeners[event];
|
|
if (listeners) {
|
|
this._eventListeners[event] = listeners.filter(cb => cb !== callback);
|
|
}
|
|
}
|
|
|
|
_emit(event, data) {
|
|
const listeners = this._eventListeners[event] || [];
|
|
listeners.forEach(cb => {
|
|
try {
|
|
cb(data);
|
|
} catch (e) {
|
|
console.error(`[AI Bridge] Event handler error (${event}):`, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Status ────────────────────────────────────────────────
|
|
|
|
get connected() { return this._connected; }
|
|
get modelLoaded() { return this._modelLoaded; }
|
|
get wsConnected() { return this._ws && this._ws.readyState === WebSocket.OPEN; }
|
|
get gameId() { return this._gameId; }
|
|
|
|
getStatus() {
|
|
return {
|
|
connected: this._connected,
|
|
modelLoaded: this._modelLoaded,
|
|
wsConnected: this.wsConnected,
|
|
gameId: this._gameId,
|
|
lastHealthCheck: this._lastHealthCheck,
|
|
};
|
|
}
|
|
|
|
destroy() {
|
|
this.disconnectWebSocket();
|
|
if (this._healthCheckTimer) clearInterval(this._healthCheckTimer);
|
|
this._eventListeners = {};
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GAME STATE BRIDGE
|
|
// ============================================================
|
|
|
|
class GameStateBridge {
|
|
constructor(connector) {
|
|
this.connector = connector;
|
|
this._sessionGameId = null;
|
|
this._stateTracker = null;
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
const seats = gameState.players.map((p, i) => ({
|
|
seat_index: i,
|
|
seat_type: p.isAI
|
|
? this._mapAiProfile(gameState.aiDifficulty)
|
|
: 'Human',
|
|
deck_name: p.deckFileName || (
|
|
p.commanderCardName
|
|
? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
|
|
: ''
|
|
),
|
|
player_name: p.name,
|
|
}));
|
|
|
|
try {
|
|
const resp = await this.connector._post('/api/game/start', {
|
|
seats,
|
|
});
|
|
this._sessionGameId = resp.game_id;
|
|
|
|
// Connect WebSocket for this session
|
|
this.connector.connectWebSocket(resp.game_id);
|
|
|
|
if (AI_BRIDGE_CONFIG.verbose) {
|
|
console.log('[GameStateBridge] Session created:', resp.game_id);
|
|
}
|
|
return resp;
|
|
} catch (e) {
|
|
console.warn('[GameStateBridge] Failed to create session:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map LAN app difficulty to backend AI profile.
|
|
*/
|
|
_mapAiProfile(difficulty) {
|
|
const map = {
|
|
easy: 'AI',
|
|
normal: 'AI Midrange',
|
|
hard: 'AI Control',
|
|
combo: 'AI Combo',
|
|
aggro: 'AI Aggro',
|
|
};
|
|
return map[difficulty] || 'AI Midrange';
|
|
}
|
|
|
|
// ── 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
|
|
const bfCards = this._getBattlefieldCards(p, gameState);
|
|
const creatures = bfCards.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('creature')
|
|
);
|
|
const totalPower = creatures.reduce((sum, c) => {
|
|
const pow = parseInt(c.power) || 0;
|
|
return sum + pow;
|
|
}, 0);
|
|
|
|
// Count lands on battlefield
|
|
const lands = bfCards.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('land')
|
|
);
|
|
|
|
// Build mana availability from untapped lands
|
|
const untappedLands = lands.filter(c => !c.tapped);
|
|
const manaAvailable = untappedLands.length +
|
|
(p.manaPool ? getManaPoolTotal(p.manaPool) : 0);
|
|
|
|
// Commander damage received (sum from all opponents)
|
|
const cmdrDmg = {};
|
|
if (p.commanderDamage) {
|
|
for (const [oppId, dmg] of Object.entries(p.commanderDamage)) {
|
|
cmdrDmg[String(oppId)] = dmg;
|
|
}
|
|
}
|
|
|
|
return {
|
|
seat: i,
|
|
name: p.name,
|
|
life: p.life,
|
|
poison: p.poison || 0,
|
|
cmdr_dmg: cmdrDmg,
|
|
cmdr_tax: (p.commanderCastCount || 0) * 2,
|
|
commanderTax: (p.commanderCastCount || 0) * 2,
|
|
mana_available: manaAvailable,
|
|
manaAvailable: manaAvailable,
|
|
|
|
// 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 => ({
|
|
name: c.name || 'Unknown',
|
|
type: c.typeLine || '',
|
|
power: c.power || '0',
|
|
toughness: c.toughness || '0',
|
|
tapped: !!c.tapped,
|
|
})),
|
|
graveyard: p.zones.graveyard.map(c => c.name || 'Unknown'),
|
|
exile: p.zones.exile.map(c => c.name || 'Unknown'),
|
|
command_zone: p.zones.command.map(c => c.name || 'Unknown'),
|
|
library_count: p.zones.library.length,
|
|
|
|
// Pre-computed board stats
|
|
creaturesOnField: creatures.length,
|
|
totalPowerOnBoard: totalPower,
|
|
landCount: lands.length,
|
|
artifactsOnField: bfCards.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('artifact')
|
|
).length,
|
|
enchantmentsOnField: bfCards.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('enchantment')
|
|
).length,
|
|
};
|
|
});
|
|
|
|
// Map current phase to backend format
|
|
const phaseId = PHASES[gameState.currentPhaseIndex]?.id || 'main1';
|
|
const phaseMap = {
|
|
beginning: 'main_1',
|
|
main1: 'main_1',
|
|
combat: 'combat',
|
|
main2: 'main_2',
|
|
end: 'end',
|
|
};
|
|
|
|
return {
|
|
game_id: this._sessionGameId || '',
|
|
turn: gameState.turnNumber || gameState.turnCounter || 1,
|
|
phase: phaseMap[phaseId] || 'main_1',
|
|
active_player: playerIdx,
|
|
players: players,
|
|
stack: [],
|
|
legal_actions: [],
|
|
playstyle: this._inferPlaystyle(gameState, playerIdx),
|
|
greedy: false,
|
|
temperature: 1.0,
|
|
commander: gameState.players[playerIdx]?.commanderCardName || '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all battlefield cards for a player.
|
|
*/
|
|
_getBattlefieldCards(player, gameState) {
|
|
if (!gameState || !gameState.battlefieldCards) return [];
|
|
return gameState.battlefieldCards.filter(
|
|
c => c.ownerIndex === player.id
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Infer a playstyle hint for the AI based on the deck composition.
|
|
*/
|
|
_inferPlaystyle(gameState, playerIdx) {
|
|
const player = gameState.players[playerIdx];
|
|
if (!player) return 'midrange';
|
|
|
|
const cmdr = player.commanderCardName || '';
|
|
if (!cmdr) return 'midrange';
|
|
|
|
const allCards = [
|
|
...player.zones.hand,
|
|
...player.zones.graveyard,
|
|
...player.zones.library,
|
|
];
|
|
if (allCards.length === 0) return 'midrange';
|
|
|
|
const creatures = allCards.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('creature')
|
|
);
|
|
const creatureRatio = creatures.length / allCards.length;
|
|
|
|
if (creatureRatio > 0.35) return 'aggro';
|
|
if (creatureRatio < 0.20) return 'control';
|
|
return 'midrange';
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
try {
|
|
const snapshot = this.serializeGameState(gameState, playerIdx);
|
|
const result = await this.connector._post(
|
|
'/api/policy/decide',
|
|
snapshot,
|
|
AI_BRIDGE_CONFIG.decisionTimeout
|
|
);
|
|
|
|
if (result.error) {
|
|
console.warn('[GameStateBridge] Decision error:', result.error);
|
|
return null;
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
console.warn('[GameStateBridge] Decision request failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
try {
|
|
const snapshot = this.serializeGameState(
|
|
gameState,
|
|
gameState.currentPlayerIndex
|
|
);
|
|
await this.connector._post(
|
|
`/api/ws/game/${this._sessionGameId}/push`,
|
|
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;
|
|
|
|
try {
|
|
await this.connector._post(
|
|
`/api/ws/game/${this._sessionGameId}/decision`,
|
|
{
|
|
action: decision.action,
|
|
action_index: decision.action_index,
|
|
confidence: decision.confidence,
|
|
probabilities: decision.probabilities || {},
|
|
inference_ms: decision.inference_ms || 0,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
// ── Cleanup ───────────────────────────────────────────────
|
|
|
|
async endSession() {
|
|
if (this._sessionGameId && this.connector.connected) {
|
|
try {
|
|
await this.connector._post(
|
|
`/api/ws/game/${this._sessionGameId}/end`,
|
|
{
|
|
winner_seat: -1,
|
|
reason: 'session_ended',
|
|
turns_played: 0,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
// Non-critical
|
|
}
|
|
}
|
|
this.connector.disconnectWebSocket();
|
|
this._sessionGameId = null;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// MACRO ACTION MAPPER
|
|
// ============================================================
|
|
|
|
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;
|
|
|
|
const confidence = decision?.confidence || 0;
|
|
|
|
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);
|
|
return true;
|
|
|
|
case 'pass':
|
|
MacroActionMapper._log(playerIdx, player,
|
|
'passing', confidence);
|
|
return true;
|
|
|
|
default:
|
|
console.warn('[MacroActionMapper] Unknown action:', action);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Cast Creature ─────────────────────────────────────────
|
|
|
|
static _castCreature(playerIdx, player, gameState, confidence) {
|
|
const hand = player.zones.hand;
|
|
const creatures = hand.filter(c =>
|
|
c.typeLine && c.typeLine.toLowerCase().includes('creature')
|
|
);
|
|
|
|
if (creatures.length === 0) {
|
|
const castable = hand.filter(c => !MacroActionMapper._isLand(c));
|
|
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)) {
|
|
const idx = hand.indexOf(card);
|
|
if (idx !== -1) {
|
|
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
|
playCardFromHand(playerIdx, idx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast <strong>${card.name}</strong> (creature slot)`, confidence);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
creatures.sort((a, b) => {
|
|
const aScore = MacroActionMapper._creatureScore(a);
|
|
const bScore = MacroActionMapper._creatureScore(b);
|
|
return bScore - aScore;
|
|
});
|
|
|
|
for (const card of creatures) {
|
|
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
|
const idx = hand.indexOf(card);
|
|
if (idx !== -1) {
|
|
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
|
playCardFromHand(playerIdx, idx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast <strong>${card.name}</strong>`, confidence);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Cast Removal ──────────────────────────────────────────
|
|
|
|
static _castRemoval(playerIdx, player, gameState) {
|
|
const hand = player.zones.hand;
|
|
const removal = hand.filter(c => c.isRemoval || c.isBoardWipe ||
|
|
MacroActionMapper._looksLikeRemoval(c));
|
|
|
|
if (removal.length === 0) return false;
|
|
|
|
for (const card of removal) {
|
|
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
|
const idx = hand.indexOf(card);
|
|
if (idx !== -1) {
|
|
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
|
playCardFromHand(playerIdx, idx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast removal <strong>${card.name}</strong>`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Cast Draw ─────────────────────────────────────────────
|
|
|
|
static _castDraw(playerIdx, player, gameState) {
|
|
const hand = player.zones.hand;
|
|
const drawSpells = hand.filter(c =>
|
|
MacroActionMapper._looksLikeDraw(c)
|
|
);
|
|
|
|
if (drawSpells.length === 0) return false;
|
|
|
|
drawSpells.sort((a, b) =>
|
|
MacroActionMapper._cmc(a) - MacroActionMapper._cmc(b)
|
|
);
|
|
|
|
for (const card of drawSpells) {
|
|
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
|
const idx = hand.indexOf(card);
|
|
if (idx !== -1) {
|
|
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
|
playCardFromHand(playerIdx, idx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast draw spell <strong>${card.name}</strong>`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Cast Ramp ─────────────────────────────────────────────
|
|
|
|
static _castRamp(playerIdx, player, gameState) {
|
|
const hand = player.zones.hand;
|
|
|
|
// First try to play a land if we haven't this turn
|
|
if (!player.aiLandPlayedThisTurn) {
|
|
const landIdx = hand.findIndex(c => MacroActionMapper._isLand(c));
|
|
if (landIdx !== -1) {
|
|
const land = hand[landIdx];
|
|
if (!land.imageUrl) land.imageUrl = generateAiCardSVG(land);
|
|
playCardFromHand(playerIdx, landIdx);
|
|
player.aiLandPlayedThisTurn = true;
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`played land <strong>${land.name}</strong>`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Then try ramp spells (mana rocks, rampant growth effects)
|
|
const rampSpells = hand.filter(c =>
|
|
MacroActionMapper._looksLikeRamp(c)
|
|
);
|
|
|
|
if (rampSpells.length === 0) return false;
|
|
|
|
rampSpells.sort((a, b) =>
|
|
MacroActionMapper._cmc(a) - MacroActionMapper._cmc(b)
|
|
);
|
|
|
|
for (const card of rampSpells) {
|
|
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
|
const idx = hand.indexOf(card);
|
|
if (idx !== -1) {
|
|
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
|
playCardFromHand(playerIdx, idx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast ramp <strong>${card.name}</strong>`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Cast Commander ────────────────────────────────────────
|
|
|
|
static _castCommander(playerIdx, player, gameState) {
|
|
if (player.zones.command.length === 0) return false;
|
|
|
|
const result = typeof aiTryCastCommander === 'function'
|
|
? aiTryCastCommander(playerIdx)
|
|
: false;
|
|
|
|
if (result) {
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`cast commander from command zone`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Attack Opponent ───────────────────────────────────────
|
|
|
|
static _attackOpponent(playerIdx, player, gameState) {
|
|
if (typeof aiDeclareAttacks === 'function') {
|
|
aiDeclareAttacks(playerIdx);
|
|
MacroActionMapper._log(playerIdx, player,
|
|
`declaring attacks`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Helper Methods ────────────────────────────────────────
|
|
|
|
static _isLand(card) {
|
|
if (!card) return false;
|
|
if (card.typeLine && card.typeLine.toLowerCase().includes('land')) return true;
|
|
if (card.type_line && card.type_line.toLowerCase().includes('land')) return true;
|
|
return typeof aiIsLand === 'function' ? aiIsLand(card) : false;
|
|
}
|
|
|
|
static _cmc(card) {
|
|
if (!card) return 0;
|
|
if (typeof card.cmc === 'number') return card.cmc;
|
|
if (typeof aiGetCmc === 'function') return aiGetCmc(card);
|
|
return 0;
|
|
}
|
|
|
|
static _canAfford(playerIdx, card, gameState) {
|
|
if (!gameState) return false;
|
|
const available = typeof aiCountUntappedLands === 'function'
|
|
? aiCountUntappedLands(playerIdx)
|
|
: 0;
|
|
return available >= MacroActionMapper._cmc(card);
|
|
}
|
|
|
|
static _creatureScore(card) {
|
|
let score = MacroActionMapper._cmc(card) * 2;
|
|
const text = (card.oracleText || card.oracle_text || '').toLowerCase();
|
|
if (text.includes('draw')) score += 3;
|
|
if (text.includes('destroy')) score += 3;
|
|
if (text.includes('flying')) score += 2;
|
|
if (text.includes('trample')) score += 2;
|
|
if (text.includes('haste')) score += 3;
|
|
return score;
|
|
}
|
|
|
|
static _looksLikeRemoval(card) {
|
|
const text = (card.oracleText || card.oracle_text || '').toLowerCase();
|
|
return text.includes('destroy') || text.includes('exile') ||
|
|
text.includes('damage to') || text.includes('-x/-x') ||
|
|
text.includes('return target');
|
|
}
|
|
|
|
static _looksLikeDraw(card) {
|
|
const text = (card.oracleText || card.oracle_text || '').toLowerCase();
|
|
return text.includes('draw') && !MacroActionMapper._isLand(card);
|
|
}
|
|
|
|
static _looksLikeRamp(card) {
|
|
const text = (card.oracleText || card.oracle_text || '').toLowerCase();
|
|
const type = (card.typeLine || card.type_line || '').toLowerCase();
|
|
return (text.includes('add {') || text.includes('search your library for a basic land') ||
|
|
text.includes('land onto the battlefield') ||
|
|
(type.includes('artifact') && text.includes('add')));
|
|
}
|
|
|
|
static _log(playerIdx, player, message, confidence) {
|
|
const confStr = confidence
|
|
? ` <span style="color:var(--color-text-faint)">(${Math.round(confidence * 100)}% conf.)</span>`
|
|
: '';
|
|
addLogEntry(
|
|
`\u{1F9E0} <strong>${player.name}</strong> ${message}${confStr} [ML]`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// DECK SOURCE CLIENT
|
|
// ============================================================
|
|
|
|
class DeckSourceClient {
|
|
constructor(connector) {
|
|
this.connector = connector;
|
|
this._preconCache = null;
|
|
this._preconCacheTime = 0;
|
|
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;
|
|
}
|
|
|
|
try {
|
|
const data = await this.connector._get('/api/lab/precons');
|
|
this._preconCache = data.precons || [];
|
|
this._preconCacheTime = Date.now();
|
|
return this._preconCache;
|
|
} catch (e) {
|
|
console.warn('[DeckSourceClient] Failed to fetch precons:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch EDHREC recommendations for a commander.
|
|
*/
|
|
async fetchEdhrecDecks(commanderName) {
|
|
if (!this.connector.connected) return [];
|
|
try {
|
|
const data = await this.connector._get(
|
|
`/api/deckgen/templates?commander=${encodeURIComponent(commanderName)}`
|
|
);
|
|
return data.templates || [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get deck content (card list) for a specific precon.
|
|
*/
|
|
async fetchPreconDeck(fileName) {
|
|
if (!this.connector.connected) return null;
|
|
try {
|
|
const data = await this.connector._get(
|
|
`/api/lab/precons/deck?fileName=${encodeURIComponent(fileName)}`
|
|
);
|
|
return data;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GLOBAL SINGLETON INSTANCES
|
|
// ============================================================
|
|
|
|
const aiBridgeConnector = new BackendConnector();
|
|
const aiBridgeGameState = new GameStateBridge(aiBridgeConnector);
|
|
const aiBridgeDeckSource = new DeckSourceClient(aiBridgeConnector);
|
|
|
|
// Export to window for use by engine.js and ui.js
|
|
window.AIBridge = {
|
|
VERSION: AI_BRIDGE_VERSION,
|
|
config: AI_BRIDGE_CONFIG,
|
|
connector: aiBridgeConnector,
|
|
gameState: aiBridgeGameState,
|
|
deckSource: aiBridgeDeckSource,
|
|
MacroActionMapper: MacroActionMapper,
|
|
|
|
/**
|
|
* Convenience: initialize the bridge and report status.
|
|
*/
|
|
async init() {
|
|
const status = await aiBridgeConnector.initialize();
|
|
if (status.connected) {
|
|
console.log(
|
|
`[AI Bridge] Connected to Commander-AI-Lab backend` +
|
|
(status.modelLoaded ? ' (ML model loaded)' : ' (ML model not loaded)')
|
|
);
|
|
} else {
|
|
console.log('[AI Bridge] Backend not available \u2014 using local AI only');
|
|
}
|
|
return status;
|
|
},
|
|
};
|