diff --git a/lan-client/ai-bridge.js b/lan-client/ai-bridge.js new file mode 100644 index 0000000..77629a2 --- /dev/null +++ b/lan-client/ai-bridge.js @@ -0,0 +1,1024 @@ +// 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 — defaults to localhost:8080, configurable via settings + backendUrl: 'http://localhost:8080', + wsUrl: 'ws://localhost:8080', + + // 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 }; + 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.commanderCardName || '', + 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 Aggro', + hard: 'AI Control', + }; + return map[difficulty] || 'AI'; + } + + // ── 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, + battlefield: 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.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.battlefield) return []; + return (gameState.battlefield || []).filter( + c => c.controller === player.id || c.owner === 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)) { + const idx = hand.indexOf(card); + if (idx !== -1) { + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); + playCardFromHand(playerIdx, idx); + MacroActionMapper._log(playerIdx, player, + `cast ${card.name} (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)) { + const idx = hand.indexOf(card); + if (idx !== -1) { + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); + playCardFromHand(playerIdx, idx); + MacroActionMapper._log(playerIdx, player, + `cast ${card.name}`, 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)) { + const idx = hand.indexOf(card); + if (idx !== -1) { + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); + playCardFromHand(playerIdx, idx); + MacroActionMapper._log(playerIdx, player, + `cast removal ${card.name}`); + 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)) { + 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 ${card.name}`); + 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 ${land.name}`); + 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)) { + const idx = hand.indexOf(card); + if (idx !== -1) { + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); + playCardFromHand(playerIdx, idx); + MacroActionMapper._log(playerIdx, player, + `cast ramp ${card.name}`); + 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) { + 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 + ? ` (${Math.round(confidence * 100)}% conf.)` + : ''; + addLogEntry( + `\u{1F9E0} ${player.name} ${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; + }, +}; diff --git a/lan-client/engine.js b/lan-client/engine.js new file mode 100644 index 0000000..fce6293 --- /dev/null +++ b/lan-client/engine.js @@ -0,0 +1,25612 @@ +/** + * MTG Commander Simulator — engine.js + * Full game engine ported to the 4-quadrant battlefield layout. + * All game logic, state management, rules, AI, and combat systems. + * DOM rendering is handled by ui.js. + * ============================================================ + */ + +'use strict'; + +// ============================================================ +// CONSTANTS +// ============================================================ + +// Updated player colors to match the new battlefield aesthetic: +// P1=Crimson, P2=Sapphire, P3=Emerald, P4=Gold +const PLAYER_COLORS = [ + '#c0392b', // Crimson (P1) + '#2980b9', // Sapphire (P2) + '#27ae60', // Emerald (P3) + '#d4930d', // Gold (P4) + '#9B59B6', // Purple (P5) + '#E67E22', // Orange (P6) +]; + +const PLAYER_COLOR_NAMES = ['Crimson', 'Sapphire', 'Emerald', 'Gold', 'Purple', 'Orange']; + +const PHASES = [ + { id: 'beginning', label: 'Beginning', short: 'BEGIN', icon: '☀', color: '#C8A843', tip: 'Untap, Upkeep, Draw — permanents untap, upkeep triggers fire, draw a card' }, + { id: 'main1', label: 'Main Phase 1', short: 'MAIN 1', icon: '🃏', color: '#5b8dd9', tip: 'Play lands, cast spells, activate abilities' }, + { id: 'combat', label: 'Combat', short: 'COMBAT', icon: '⚔', color: '#D3202A', tip: 'Declare attackers and blockers, resolve combat damage' }, + { id: 'main2', label: 'Main Phase 2', short: 'MAIN 2', icon: '🃏', color: '#5b8dd9', tip: 'Second main phase — play lands (if not played), cast spells' }, + { id: 'end', label: 'End Step', short: 'END', icon: '🌙', color: '#9B59B6', tip: 'End step triggers fire, then cleanup — discard to hand size' }, +]; + +const COMBAT_PHASES = [ + { id: 'begin-combat', label: 'Begin Combat', short: 'BEGIN', icon: '⚔', color: '#D3202A' }, + { id: 'attackers', label: 'Declare Attackers', short: 'ATK', icon: '🗡', color: '#ff6b6b' }, + { id: 'blockers', label: 'Declare Blockers', short: 'BLK', icon: '🛡', color: '#5b8dd9' }, + { id: 'damage', label: 'Combat Damage', short: 'DMG', icon: '💥', color: '#ff4500' }, + { id: 'end-combat', label: 'End of Combat', short: 'END', icon: '🏁', color: '#999' }, +]; + +const SCRYFALL_BASE = 'https://api.scryfall.com'; + +// ============================================================ +// KEYWORD ABILITIES DATABASE +// ============================================================ + +const KEYWORD_ABILITIES = { + // Evergreen keywords + deathtouch: { icon: '☠', color: '#7B3F00', desc: 'Any amount of damage this deals to a creature is enough to destroy it.' }, + defender: { icon: '🏰', color: '#8B7355', desc: 'This creature can\'t attack.' }, + 'double strike':{ icon: '⚔⚔', color: '#D4AF37', desc: 'This creature deals both first-strike and regular combat damage.' }, + enchant: { icon: '✦', color: '#7B68EE', desc: 'Attached to target permanent.' }, + equip: { icon: '🗡', color: '#A0A0A0', desc: 'Attach this Equipment to target creature you control.' }, + 'first strike': { icon: '⚡', color: '#FFD700', desc: 'This creature deals combat damage before creatures without first strike.' }, + flash: { icon: '💫', color: '#00CED1', desc: 'You may cast this spell any time you could cast an instant.' }, + flying: { icon: '🕊', color: '#87CEEB', desc: 'Can only be blocked by creatures with flying or reach.' }, + haste: { icon: '💨', color: '#FF4500', desc: 'Can attack and {T} the turn it enters the battlefield.' }, + hexproof: { icon: '🛡', color: '#2E8B57', desc: 'Can\'t be the target of spells or abilities your opponents control.' }, + indestructible: { icon: '💎', color: '#FFD700', desc: 'Can\'t be destroyed by damage or "destroy" effects.' }, + intimidate: { icon: '👁', color: '#8B0000', desc: 'Can only be blocked by artifact creatures or creatures that share a color.' }, + lifelink: { icon: '❤', color: '#FF69B4', desc: 'Damage dealt by this creature also causes you to gain that much life.' }, + menace: { icon: '😈', color: '#B22222', desc: 'Can only be blocked by two or more creatures.' }, + protection: { icon: '🔒', color: '#DAA520', desc: 'Can\'t be damaged, enchanted, equipped, blocked, or targeted by sources of the chosen quality.' }, + reach: { icon: '🌿', color: '#228B22', desc: 'Can block creatures with flying.' }, + shroud: { icon: '🌫', color: '#708090', desc: 'Can\'t be the target of spells or abilities.' }, + trample: { icon: '🦏', color: '#8B4513', desc: 'Excess combat damage is dealt to the defending player or planeswalker.' }, + vigilance: { icon: '👁‍🗨', color: '#F0E68C', desc: 'Attacking doesn\'t cause this creature to tap.' }, + ward: { icon: '🔷', color: '#4169E1', desc: 'Whenever this becomes the target of a spell or ability an opponent controls, counter it unless that player pays the ward cost.' }, + // Deciduous / frequent keywords + affinity: { icon: '🔗', color: '#A0A0A0', desc: 'Costs {1} less for each permanent of the chosen type you control.' }, + annihilator: { icon: '💀', color: '#4B0082', desc: 'When this attacks, defending player sacrifices that many permanents.' }, + cascade: { icon: '🌊', color: '#4682B4', desc: 'Exile cards from the top of your library until you exile a nonland card with lesser mana value. Cast it without paying its mana cost.' }, + convoke: { icon: '🤝', color: '#DAA520', desc: 'Your creatures can help cast this spell. Each creature tapped pays for {1} or one mana of that creature\'s color.' }, + cycling: { icon: '🔄', color: '#CD853F', desc: 'Discard this card: Draw a card.' }, + 'death\'s shadow':{ icon: '☠', color: '#1a1a2e', desc: '' }, + delve: { icon: '⛏', color: '#2F4F4F', desc: 'Exile cards from your graveyard to help pay for this spell.' }, + emerge: { icon: '🐙', color: '#2E8B57', desc: 'You may cast this spell by sacrificing a creature and paying the emerge cost minus that creature\'s mana value.' }, + embalm: { icon: '🏛', color: '#F5DEB3', desc: 'Exile this card from your graveyard: Create a token that\'s a copy of it, except it\'s white and a Zombie.' }, + enrage: { icon: '🔥', color: '#FF4500', desc: 'Whenever this creature is dealt damage...' }, + eternalize: { icon: '⏳', color: '#DAA520', desc: 'Exile this card from your graveyard: Create a token that\'s a copy of it, except it\'s 4/4 and a Zombie.' }, + evoke: { icon: '💭', color: '#9370DB', desc: 'You may cast this spell for its evoke cost. If you do, sacrifice it when it enters.' }, + exploit: { icon: '🗡', color: '#696969', desc: 'When this enters, you may sacrifice a creature.' }, + exalted: { icon: '👑', color: '#FFD700', desc: 'Whenever a creature you control attacks alone, it gets +1/+1.' }, + fabricate: { icon: '⚙', color: '#A0A0A0', desc: 'Put +1/+1 counters on this or create Servo tokens.' }, + flanking: { icon: '🐎', color: '#8B4513', desc: 'Whenever a creature without flanking blocks this, the blocker gets -1/-1.' }, + flashback: { icon: '♻', color: '#9370DB', desc: 'You may cast this card from your graveyard for its flashback cost.' }, + foodchain: { icon: '🍖', color: '#8B4513', desc: '' }, + fear: { icon: '😨', color: '#2F2F2F', desc: 'Can only be blocked by artifact creatures or black creatures.' }, + foretell: { icon: '🔮', color: '#4169E1', desc: 'During your turn, pay {2} and exile this card face down. Cast it on a later turn for its foretell cost.' }, + infect: { icon: '☣', color: '#32CD32', desc: 'Deals damage to creatures as -1/-1 counters and to players as poison counters.' }, + kicker: { icon: '🦶', color: '#CD853F', desc: 'You may pay an additional cost as you cast this spell for an enhanced effect.' }, + landfall: { icon: '🏔', color: '#228B22', desc: 'Whenever a land enters the battlefield under your control...' }, + madness: { icon: '🤪', color: '#8B0000', desc: 'If you discard this card, you may cast it for its madness cost instead.' }, + miracle: { icon: '✨', color: '#FFD700', desc: 'If this is the first card you drew this turn, you may cast it for its miracle cost.' }, + morph: { icon: '❓', color: '#808080', desc: 'You may cast this face down as a 2/2 creature for {3}. Turn it face up any time for its morph cost.' }, + mutate: { icon: '🧬', color: '#7B68EE', desc: 'If you cast this for its mutate cost, put it over or under target non-Human creature.' }, + ninjutsu: { icon: '🥷', color: '#2F2F2F', desc: 'Return an unblocked attacker to hand: Put this card onto the battlefield tapped and attacking.' }, + overload: { icon: '⚡', color: '#4169E1', desc: 'Cast for the overload cost to affect all valid targets instead of one.' }, + persist: { icon: '↩', color: '#2F4F4F', desc: 'When this dies, if it had no -1/-1 counters, return it to the battlefield with a -1/-1 counter.' }, + phasing: { icon: '〰', color: '#808080', desc: 'This phases out at the beginning of your untap step and phases in on your next untap step.' }, + proliferate: { icon: '➕', color: '#7B68EE', desc: 'Choose any number of permanents and/or players, then give each another counter of each kind already there.' }, + prowess: { icon: '📖', color: '#4169E1', desc: 'Whenever you cast a noncreature spell, this gets +1/+1 until end of turn.' }, + rebound: { icon: '🔙', color: '#4169E1', desc: 'Exile this spell as it resolves. Cast it again at the beginning of your next upkeep.' }, + regenerate: { icon: '💚', color: '#228B22', desc: 'Next time this creature would be destroyed, instead tap it, remove all damage, and remove it from combat.' }, + retrace: { icon: '🗺', color: '#8B4513', desc: 'You may cast this card from your graveyard by discarding a land card as an additional cost.' }, + riot: { icon: '🔥', color: '#FF4500', desc: 'This enters with a +1/+1 counter or haste, your choice.' }, + scry: { icon: '🔍', color: '#4169E1', desc: 'Look at the top cards of your library, then put any number on the bottom and the rest on top.' }, + storm: { icon: '🌩', color: '#9370DB', desc: 'When you cast this spell, copy it for each spell cast before it this turn.' }, + surge: { icon: '⚡', color: '#FF6347', desc: 'You may cast this for its surge cost if you or a teammate has cast another spell this turn.' }, + suspend: { icon: '⏸', color: '#4169E1', desc: 'Exile this card with time counters. Remove one each upkeep. When the last is removed, cast it.' }, + 'totem armor': { icon: '🛡', color: '#DAA520', desc: 'If enchanted permanent would be destroyed, remove all damage from it and destroy this Aura instead.' }, + 'toxic': { icon: '☣', color: '#32CD32', desc: 'Players dealt combat damage by this creature get that many poison counters.' }, + undying: { icon: '↑', color: '#2F4F4F', desc: 'When this dies, if it had no +1/+1 counters, return it to the battlefield with a +1/+1 counter.' }, + unearth: { icon: '💀', color: '#696969', desc: 'Return this card from your graveyard to the battlefield. Exile it at end of turn or if it would leave the battlefield.' }, + wither: { icon: '🥀', color: '#556B2F', desc: 'This deals damage to creatures in the form of -1/-1 counters.' }, +}; + +/** Comprehensive ability type classifications */ +const ABILITY_TYPES = { + KEYWORD: 'keyword', + ACTIVATED: 'activated', + TRIGGERED: 'triggered', + STATIC: 'static', + MANA: 'mana', + LOYALTY: 'loyalty', + REPLACEMENT: 'replacement', +}; + +// Mana symbol display styles for formatted text +const MANA_DISPLAY_MAP = { + '{W}': { text: 'W', color: '#F9FAF4', bg: '#F9E48B' }, + '{U}': { text: 'U', color: '#fff', bg: '#0E68AB' }, + '{B}': { text: 'B', color: '#fff', bg: '#2a2a2a' }, + '{R}': { text: 'R', color: '#fff', bg: '#D3202A' }, + '{G}': { text: 'G', color: '#fff', bg: '#00733E' }, + '{C}': { text: 'C', color: '#fff', bg: '#999' }, + '{T}': { text: 'T', color: '#fff', bg: '#555' }, + '{X}': { text: 'X', color: '#fff', bg: '#777' }, + '{0}': { text: '0', color: '#333', bg: '#ccc' }, + '{1}': { text: '1', color: '#333', bg: '#ccc' }, + '{2}': { text: '2', color: '#333', bg: '#ccc' }, + '{3}': { text: '3', color: '#333', bg: '#ccc' }, + '{4}': { text: '4', color: '#333', bg: '#ccc' }, + '{5}': { text: '5', color: '#333', bg: '#ccc' }, + '{6}': { text: '6', color: '#333', bg: '#ccc' }, + '{7}': { text: '7', color: '#333', bg: '#ccc' }, + '{8}': { text: '8', color: '#333', bg: '#ccc' }, + '{9}': { text: '9', color: '#333', bg: '#ccc' }, + '{10}': { text: '10', color: '#333', bg: '#ccc' }, +}; + + +// ============================================================ +// COUNTER TYPE DEFINITIONS +// ============================================================ +const COUNTER_TYPES = { + '+1/+1': { icon: '⬆', color: '#2ea043', label: '+1/+1', shortLabel: '+1' }, + '-1/-1': { icon: '⬇', color: '#d3202a', label: '-1/-1', shortLabel: '-1' }, + 'loyalty': { icon: '◆', color: '#d4a017', label: 'Loyalty', shortLabel: 'Loy' }, + 'charge': { icon: '⚡', color: '#4dabf7', label: 'Charge', shortLabel: 'Chg' }, + 'shield': { icon: '🛡', color: '#63c5da', label: 'Shield', shortLabel: 'Shd' }, + 'time': { icon: '⏳', color: '#c084fc', label: 'Time', shortLabel: 'Time' }, + 'lore': { icon: '📖', color: '#f59e0b', label: 'Lore', shortLabel: 'Lore' }, + 'ki': { icon: '☯', color: '#e879f9', label: 'Ki', shortLabel: 'Ki' }, + 'quest': { icon: '❗', color: '#f97316', label: 'Quest', shortLabel: 'Qst' }, + 'energy': { icon: '⚛', color: '#22d3ee', label: 'Energy', shortLabel: 'Eng' }, + 'experience': { icon: '★', color: '#a78bfa', label: 'Experience', shortLabel: 'Exp' }, + 'bounty': { icon: '🎯', color: '#ef4444', label: 'Bounty', shortLabel: 'Bty' }, + 'flood': { icon: '🌊', color: '#3b82f6', label: 'Flood', shortLabel: 'Fld' }, + 'fate': { icon: '🔮', color: '#c084fc', label: 'Fate', shortLabel: 'Fate' }, + 'filibuster': { icon: '📋', color: '#9ca3af', label: 'Filibuster', shortLabel: 'Fil' }, + 'fungus': { icon: '🍄', color: '#65a30d', label: 'Fungus', shortLabel: 'Fun' }, + 'depletion': { icon: '⊘', color: '#78716c', label: 'Depletion', shortLabel: 'Dpl' }, + 'divinity': { icon: '✦', color: '#fbbf24', label: 'Divinity', shortLabel: 'Div' }, + 'page': { icon: '📄', color: '#d1d5db', label: 'Page', shortLabel: 'Pg' }, + 'storage': { icon: '🔋', color: '#a3e635', label: 'Storage', shortLabel: 'Str' }, + 'verse': { icon: '♫', color: '#f472b6', label: 'Verse', shortLabel: 'Vrs' }, +}; + +// ============================================================ +// GAME STATE +// ============================================================ + +let gameState = null; + +/** Convert a hex color to rgba string. Works with #RGB, #RRGGBB, or named fallback. */ +// ============================================================ +// FEATURE 2: MANA SPENDING VALIDATION +// ============================================================ + +function canPayManaCost(manaCost, manaPool) { + if (!manaCost || !manaPool) return false; + var pool = { W: manaPool.W||0, U: manaPool.U||0, B: manaPool.B||0, R: manaPool.R||0, G: manaPool.G||0, C: manaPool.C||0 }; + var generic = 0; + var tokens = manaCost.match(/\{[^}]+\}/g) || []; + for (var i = 0; i < tokens.length; i++) { + var sym = tokens[i].replace(/[{}]/g, '').toUpperCase(); + if (sym === 'W' || sym === 'U' || sym === 'B' || sym === 'R' || sym === 'G') { + if (pool[sym] > 0) { pool[sym]--; } + else { return false; } + } else if (sym === 'C') { + if (pool.C > 0) { pool.C--; } + else { return false; } + } else if (/^\d+$/.test(sym)) { + generic += parseInt(sym, 10); + } else if (sym === 'X') { + // X costs — treat as 0 for castability display + } + } + if (generic > 0) { + var available = pool.W + pool.U + pool.B + pool.R + pool.G + pool.C; + if (available < generic) return false; + } + return true; +} + +function getManaPoolTotal(manaPool) { + if (!manaPool) return 0; + return (manaPool.W||0)+(manaPool.U||0)+(manaPool.B||0)+(manaPool.R||0)+(manaPool.G||0)+(manaPool.C||0); +} + +function hexToRgba(hex, alpha) { + if (!hex || hex.charAt(0) !== '#') return 'rgba(102,102,102,' + alpha + ')'; + let r, g, b; + if (hex.length === 4) { + r = parseInt(hex[1] + hex[1], 16); + g = parseInt(hex[2] + hex[2], 16); + b = parseInt(hex[3] + hex[3], 16); + } else { + r = parseInt(hex.substring(1, 3), 16); + g = parseInt(hex.substring(3, 5), 16); + b = parseInt(hex.substring(5, 7), 16); + } + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; +} + +function createInitialState(playerCount, playerNames, startingLife) { + const players = []; + for (let i = 0; i < playerCount; i++) { + const player = { + id: i, + name: playerNames[i] || `Player ${i + 1}`, + color: PLAYER_COLORS[i], + life: startingLife, + startingLife, + poison: 0, + energy: 0, + experience: 0, + rad: 0, + emblems: [], + eliminated: false, + commanderDamage: {}, // keyed by opponent player id + zones: { + graveyard: [], + exile: [], + command: [], + hand: [], + library: [], + }, + manaPool: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }, + commanderCastCount: 0, + commanderCardName: '', + commanderCastCount2: 0, + commanderCardName2: '', + }; + // Initialize commander damage counters + for (let j = 0; j < playerCount; j++) { + if (j !== i) { + player.commanderDamage[j] = 0; + } + } + players.push(player); + } + + return { + players, + currentPlayerIndex: 0, + currentPhaseIndex: 0, + inCombat: false, + combatPhaseIndex: 0, + turnNumber: 1, + stormCount: 0, + manaAutoClear: true, + battlefieldCards: [], + log: [], + startingLife, + triggerStack: [], + triggersPaused: false, + turnHistory: [], // [{turnNumber, playerIndex, playerName, playerColor}] + rulesEngine: { + autoUntap: true, + autoDraw: true, + sbaChecks: true, + enforceLandLimit: true, + enforceHandSize: true, + commanderTaxEnabled: true, + commanderZoneReplacement: true, + useStack: false, + enforceTurnStructure: false, + enforceColorIdentity: false, + turnTimerEnabled: false, + turnTimerMinutes: 30, + autoResolveTriggers: true, + autoEliminateOnSBA: false, + autoTapLands: true, + autoSpendMana: true, + }, + turnTimer: { + enabled: false, + timePerPlayer: {}, + activePlayerId: null, + intervalId: null, + }, + theStack: [], + monarchPlayerIndex: null, + priority: { + active: false, + playerIndex: 0, + passCount: 0, + reason: '', // 'phase', 'spell_cast', 'spell_resolve', 'trigger', 'combat_damage' + sourcePhase: '', // phase id that triggered the priority window + }, + landsPlayedThisTurn: 0, + replacementEffects: [], // Forge: replacement effects registry + maxLandsPerTurn: 1, + combat: { + attackers: [], // [{cardId, targetPlayerId}] + blockers: [], // [{cardId, blockingCardId}] + damageAssignments: [], // calculated during damage step + phase: null, // 'declaring-attackers' | 'declaring-blockers' | 'previewing-damage' | 'resolving-damage' | null + pendingBlockerFor: null, // cardId of attacker awaiting blocker click + damagePreview: null, // damage calculator preview data + }, + }; +} + +// ============================================================ +// FORGE-INSPIRED ENGINE SYSTEMS +// 1. Mana Pool System +// 2. Activated Abilities System +// 3. Replacement Effects Engine +// 4. Damage Marking & Cleanup Step +// ============================================================ + +// ============================================================ +// 1. MANA POOL SYSTEM +// ============================================================ + +/** + * Initialize mana pool for a player. + * Colors: W(white), U(blue), B(black), R(red), G(green), C(colorless) + */ +function initManaPool() { + return { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; +} + +/** + * Get a player's current mana pool (lazy-init if missing) + */ +function getManaPool(playerIdx) { + if (!gameState || !gameState.players[playerIdx]) return initManaPool(); + if (!gameState.players[playerIdx].manaPool) { + gameState.players[playerIdx].manaPool = initManaPool(); + } + return gameState.players[playerIdx].manaPool; +} + +/** + * Add mana to a player's pool. + * @param {number} playerIdx + * @param {string} color - 'W','U','B','R','G','C' + * @param {number} amount + */ +function addMana(playerIdx, color, amount) { + if (!gameState) return; + var pool = getManaPool(playerIdx); + var c = color.toUpperCase(); + if (pool[c] === undefined) c = 'C'; + pool[c] = (pool[c] || 0) + (amount || 1); + renderManaPool(playerIdx); + addLogEntry('+ ' + gameState.players[playerIdx].name + ' adds ' + (amount || 1) + ' ' + getManaColorName(c) + ' mana'); +} + +/** + * Spend mana from a player's pool. Returns true if successful. + * @param {number} playerIdx + * @param {object} cost - e.g. {W:1, R:2, C:3} meaning 1 white, 2 red, 3 generic + */ +function spendMana(playerIdx, cost) { + if (!gameState) return false; + var pool = getManaPool(playerIdx); + // Check colored costs + var colors = ['W', 'U', 'B', 'R', 'G']; + for (var i = 0; i < colors.length; i++) { + var c = colors[i]; + if (cost[c] && pool[c] < cost[c]) return false; + } + // Check generic/colorless cost + var genericNeeded = cost.C || cost.generic || 0; + var totalAvailable = 0; + for (var ci = 0; ci < colors.length; ci++) { + totalAvailable += pool[colors[ci]] - (cost[colors[ci]] || 0); + } + totalAvailable += pool.C; + if (totalAvailable < genericNeeded) return false; + + // Actually spend + for (var j = 0; j < colors.length; j++) { + if (cost[colors[j]]) pool[colors[j]] -= cost[colors[j]]; + } + // Spend generic from colorless first, then any color + var remaining = genericNeeded; + if (remaining > 0 && pool.C > 0) { + var fromC = Math.min(pool.C, remaining); + pool.C -= fromC; + remaining -= fromC; + } + for (var k = 0; k < colors.length && remaining > 0; k++) { + var avail = pool[colors[k]] - (cost[colors[k]] || 0); + // pool already reduced for color costs, so pool[colors[k]] is post-color-cost + if (pool[colors[k]] > 0 && remaining > 0) { + var take = Math.min(pool[colors[k]], remaining); + pool[colors[k]] -= take; + remaining -= take; + } + } + + renderManaPool(playerIdx); + return true; +} + +/** + * Clear a player's mana pool (end of phase/step) + */ +function clearManaPool(playerIdx) { + if (!gameState || !gameState.players[playerIdx]) return; + gameState.players[playerIdx].manaPool = initManaPool(); + renderManaPool(playerIdx); +} + +/** + * Clear all players' mana pools + */ +function clearAllManaPools() { + if (!gameState) return; + for (var p = 0; p < gameState.players.length; p++) { + clearManaPool(p); + } +} + +/** + * Total mana in pool + */ +function getTotalMana(playerIdx) { + var pool = getManaPool(playerIdx); + return pool.W + pool.U + pool.B + pool.R + pool.G + pool.C; +} + +function getManaColorName(c) { + var names = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' }; + return names[c] || c; +} + +var MANA_SYMBOLS = { W: '{W}', U: '{U}', B: '{B}', R: '{R}', G: '{G}', C: '{C}' }; +var MANA_COLORS_CSS = { + W: '#F9FAF4', U: '#0E68AB', B: '#150B00', + R: '#D3202A', G: '#00733E', C: '#CAC5C0' +}; +var MANA_TEXT_CSS = { + W: '#333', U: '#fff', B: '#fff', + R: '#fff', G: '#fff', C: '#333' +}; + +/** + * Parse mana cost string like "{2}{W}{U}" into cost object + * Returns { W:1, U:1, C:2 } (C = generic) + */ +function parseManaCost(costStr) { + if (!costStr) return null; + var cost = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; + var matches = costStr.match(/\{([^}]+)\}/g); + if (!matches) return null; + for (var i = 0; i < matches.length; i++) { + var val = matches[i].replace(/[{}]/g, '').toUpperCase(); + if (/^\d+$/.test(val)) { + cost.C += parseInt(val, 10); + } else if (cost[val] !== undefined) { + cost[val]++; + } else if (val === 'X') { + // X costs — skip for now + } + } + return cost; +} + +/** + * Tap a land to produce mana — auto-detect color from card data + */ +function tapLandForMana(cardId) { + if (!gameState) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + if (card.tapped) { + addLogEntry('!! ' + card.name + ' is already tapped'); + return; + } + + card.tapped = true; + var el = document.getElementById('bc-' + cardId); + if (el) el.classList.add('tapped'); + + // Determine what color(s) this land produces + var colors = detectLandManaColors(card); + if (colors.length === 1) { + addMana(card.ownerIndex, colors[0], 1); + } else if (colors.length > 1) { + // Multi-color land — show picker + showManaColorPicker(card.ownerIndex, cardId, colors); + } else { + // Unknown land — default to colorless + addMana(card.ownerIndex, 'C', 1); + } +} + +/** + * Detect what colors a land can produce from its oracle text + */ +function detectLandManaColors(card) { + var name = (card.name || '').toLowerCase(); + var oracle = (card.oracleText || '').toLowerCase(); + var colors = []; + + // Basic lands + if (/\bplains\b/.test(name) || /\bplains\b/.test(card.typeLine || '')) colors.push('W'); + if (/\bisland\b/.test(name) || /\bisland\b/.test(card.typeLine || '')) colors.push('U'); + if (/\bswamp\b/.test(name) || /\bswamp\b/.test(card.typeLine || '')) colors.push('B'); + if (/\bmountain\b/.test(name) || /\bmountain\b/.test(card.typeLine || '')) colors.push('R'); + if (/\bforest\b/.test(name) || /\bforest\b/.test(card.typeLine || '')) colors.push('G'); + + // Oracle text: "Add {W}" patterns + var addMatch = oracle.match(/add\s+(\{[WUBRGC]\}(?:\s*(?:,|or)\s*\{[WUBRGC]\})*)/gi); + if (addMatch) { + for (var i = 0; i < addMatch.length; i++) { + var syms = addMatch[i].match(/\{([WUBRGC])\}/gi); + if (syms) { + for (var j = 0; j < syms.length; j++) { + var c = syms[j].replace(/[{}]/g, '').toUpperCase(); + if (colors.indexOf(c) === -1) colors.push(c); + } + } + } + } + + // "Add one mana of any color" + if (/add one mana of any color/i.test(oracle) || /add \{.\} of any color/i.test(oracle)) { + colors = ['W', 'U', 'B', 'R', 'G']; + } + + // "any type" or "any combination" + if (/any (?:type|combination)/i.test(oracle)) { + colors = ['W', 'U', 'B', 'R', 'G']; + } + + return colors.length > 0 ? colors : ['C']; +} + +/** + * Show a mana color picker popup for multi-color lands + */ +function showManaColorPicker(playerIdx, cardId, colors) { + var existing = document.getElementById('mana-color-picker'); + if (existing) existing.remove(); + + var picker = document.createElement('div'); + picker.id = 'mana-color-picker'; + picker.className = 'mana-color-picker-overlay'; + var html = '
'; + html += '
Choose mana color
'; + html += '
'; + for (var i = 0; i < colors.length; i++) { + var c = colors[i]; + html += ''; + } + html += '
'; + picker.innerHTML = html; + document.body.appendChild(picker); +} + +function closeManaColorPicker() { + var el = document.getElementById('mana-color-picker'); + if (el) el.remove(); +} + +/** + * Render mana pool display in the player panel + */ +function renderManaPool(playerIdx) { + var el = document.getElementById('mana-pool-' + playerIdx); + if (!el) return; + var pool = getManaPool(playerIdx); + var total = getTotalMana(playerIdx); + var html = ''; + var colors = ['W', 'U', 'B', 'R', 'G', 'C']; + for (var i = 0; i < colors.length; i++) { + var c = colors[i]; + if (pool[c] > 0) { + html += ''; + html += pool[c] + ''; + } + } + el.innerHTML = html || 'Empty'; + // Update total mana indicator + var totalEl = document.getElementById('mana-total-' + playerIdx); + if (totalEl) totalEl.textContent = total; +} + + +// ============================================================ +// 2. ACTIVATED ABILITIES SYSTEM +// ============================================================ + +/** + * Parse activated abilities from oracle text. + * Looks for patterns like: "{cost}: {effect}" or "{T}: {effect}" + * Returns array of ability objects. + */ +function parseActivatedAbilities(cardData) { + if (!cardData || !cardData.oracleText) return []; + var text = cardData.oracleText; + var abilities = []; + + // Split on newlines / sentence boundaries to get individual abilities + var sentences = text.split(/\n|(?<=\.)(?=\s*[A-Z{])/); + + for (var i = 0; i < sentences.length; i++) { + var s = sentences[i].trim(); + if (!s) continue; + + // Match: {cost}: {effect} pattern + // Cost can be: {T}, {mana}, "Pay N life", "Sacrifice", combination + var abilityMatch = s.match(/^((?:\{[^}]+\}[\s,]*)+(?:,\s*(?:Pay \d+ life|Sacrifice [^:]+|Discard [^:]+|Remove [^:]+|Tap [^:]+))?)\s*:\s*(.+)$/i); + if (!abilityMatch) { + // Also match text costs like "Pay 2 life, {T}: effect" + abilityMatch = s.match(/^((?:Pay \d+ life|Sacrifice [^:]+|Discard [^:]+)(?:\s*,\s*(?:\{[^}]+\}|Pay \d+ life|Sacrifice [^:]+))*)\s*:\s*(.+)$/i); + } + + if (abilityMatch) { + var costText = abilityMatch[1].trim(); + var effectText = abilityMatch[2].trim(); + + var ability = { + costText: costText, + effectText: effectText, + fullText: s, + requiresTap: /\{T\}/.test(costText), + requiresUntap: /\{Q\}/.test(costText), + manaCost: null, + lifeCost: 0, + requiresSacrifice: false, + sacrificeTarget: '', + requiresDiscard: false, + discardCount: 0, + }; + + // Parse mana cost from activation + var manaParts = costText.match(/\{([WUBRGC0-9X]+)\}/g); + if (manaParts) { + var manaStr = manaParts.filter(function(p) { return !/\{[TQ]\}/.test(p); }).join(''); + if (manaStr) ability.manaCost = parseManaCost(manaStr); + } + + // Parse life cost + var lifeMatch = costText.match(/Pay (\d+) life/i); + if (lifeMatch) ability.lifeCost = parseInt(lifeMatch[1], 10); + + // Parse sacrifice requirement + if (/Sacrifice/i.test(costText)) { + ability.requiresSacrifice = true; + var sacMatch = costText.match(/Sacrifice\s+(.+?)(?:,|$)/i); + if (sacMatch) ability.sacrificeTarget = sacMatch[1].trim(); + } + + // Parse discard requirement + if (/Discard/i.test(costText)) { + ability.requiresDiscard = true; + var discMatch = costText.match(/Discard\s+(\d+|a)\s+cards?/i); + ability.discardCount = discMatch ? (discMatch[1] === 'a' ? 1 : parseInt(discMatch[1], 10)) : 1; + } + + // Parse the effect (what happens on resolution) + ability.parsedEffect = parseTriggerEffect(effectText); + + abilities.push(ability); + } + } + + return abilities; +} + +/** + * Check if a player can pay an activated ability's cost + */ +function canPayAbilityCost(playerIdx, cardData, ability) { + if (!gameState || !ability) return false; + var player = gameState.players[playerIdx]; + if (!player) return false; + + // Tap cost — card must be untapped + if (ability.requiresTap && cardData.tapped) return false; + + // Mana cost + if (ability.manaCost) { + var pool = getManaPool(playerIdx); + var colors = ['W', 'U', 'B', 'R', 'G']; + for (var i = 0; i < colors.length; i++) { + if ((ability.manaCost[colors[i]] || 0) > (pool[colors[i]] || 0)) return false; + } + var generic = ability.manaCost.C || 0; + var totalAvail = 0; + for (var j = 0; j < colors.length; j++) { + totalAvail += Math.max(0, (pool[colors[j]] || 0) - (ability.manaCost[colors[j]] || 0)); + } + totalAvail += pool.C || 0; + if (totalAvail < generic) return false; + } + + // Life cost + if (ability.lifeCost > 0 && player.life <= ability.lifeCost) return false; + + // Sacrifice — must have a valid target + if (ability.requiresSacrifice) { + // Check if they control a matching permanent (simplified: "another creature" check) + if (/another creature/i.test(ability.sacrificeTarget)) { + var otherCreatures = gameState.battlefieldCards.filter(function(c) { + return c.ownerIndex === playerIdx && c.id !== cardData.id && + c.typeLine && /creature/i.test(c.typeLine); + }); + if (otherCreatures.length === 0) return false; + } + } + + // Discard — must have cards in hand + if (ability.requiresDiscard && player.zones.hand.length < ability.discardCount) return false; + + return true; +} + +/** + * Execute an activated ability: pay costs + resolve effect + */ +function activateAbility(cardId, abilityIndex) { + if (!gameState) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + + var abilities = parseActivatedAbilities(card); + if (!abilities[abilityIndex]) return; + var ability = abilities[abilityIndex]; + var playerIdx = card.ownerIndex; + + if (!canPayAbilityCost(playerIdx, card, ability)) { + addLogEntry('!! Cannot activate ability on ' + card.name + ' -- insufficient resources'); + return; + } + + pushUndoState('Activate ' + card.name); + + // Pay costs + if (ability.requiresTap) { + card.tapped = true; + var el = document.getElementById('bc-' + cardId); + if (el) el.classList.add('tapped'); + } + + if (ability.manaCost) { + spendMana(playerIdx, ability.manaCost); + } + + if (ability.lifeCost > 0) { + adjustLife(playerIdx, -ability.lifeCost); + } + + if (ability.requiresSacrifice) { + if (/^(this|it|~|itself)/i.test(ability.sacrificeTarget) || ability.sacrificeTarget === '') { + // Sacrifice self + moveCardToZone(cardId, 'graveyard'); + addLogEntry('>> Sacrificed ' + card.name + ''); + } + // For "Sacrifice another creature" — would need target selection UI + } + + if (ability.requiresDiscard) { + var hand = gameState.players[playerIdx].zones.hand; + for (var d = 0; d < ability.discardCount && hand.length > 0; d++) { + var discarded = hand.pop(); + gameState.players[playerIdx].zones.graveyard = gameState.players[playerIdx].zones.graveyard || []; + gameState.players[playerIdx].zones.graveyard.push(discarded); + addLogEntry('>> ' + gameState.players[playerIdx].name + ' discards ' + (discarded.name || 'a card')); + } + } + + // Resolve effect + addLogEntry('>> ' + card.name + ' ability activated: ' + ability.effectText.substring(0, 80)); + + // Auto-resolve if we can + if (ability.parsedEffect && ability.parsedEffect.autoResolvable) { + autoResolveTriggerEffect({ + autoResolvable: true, + effectType: ability.parsedEffect.effectType, + ownerIndex: playerIdx, + cardName: card.name, + oracleSnippet: ability.effectText + }); + } else { + // Push to trigger stack for manual resolution + pushTrigger('activated', card.name, ability.effectText, playerIdx, cardId); + } + + renderPlayerPanels(); +} + +/** + * Show activated abilities panel for a card + */ +function showActivatedAbilitiesPanel(cardId) { + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + + var abilities = parseActivatedAbilities(card); + if (abilities.length === 0) { + addLogEntry('!! ' + card.name + ' has no activated abilities'); + return; + } + + var existing = document.getElementById('activated-ability-panel'); + if (existing) existing.remove(); + + var panel = document.createElement('div'); + panel.id = 'activated-ability-panel'; + panel.className = 'activated-ability-overlay'; + + var html = '
'; + html += '
Activated Abilities: ' + escapeHtml(card.name) + ''; + html += '
'; + html += '
'; + + for (var i = 0; i < abilities.length; i++) { + var ab = abilities[i]; + var canPay = canPayAbilityCost(card.ownerIndex, card, ab); + html += '
'; + html += '
' + escapeHtml(ab.costText) + '
'; + html += '
' + escapeHtml(ab.effectText) + '
'; + html += ''; + html += '
'; + } + + html += '
'; + panel.innerHTML = html; + document.body.appendChild(panel); +} + +function closeActivatedAbilityPanel() { + var el = document.getElementById('activated-ability-panel'); + if (el) el.remove(); +} + + +// ============================================================ +// 3. REPLACEMENT EFFECTS ENGINE +// ============================================================ + +/** + * Replacement effects registry. + * Each replacement effect is an object: + * { + * id: unique, + * sourceCardId: battlefield card id, + * ownerIndex: player index, + * event: 'draw' | 'damage' | 'die' | 'gain_life' | 'lose_life' | 'etb' | 'to_graveyard', + * condition: function(eventData) -> bool, + * replace: function(eventData) -> modifiedEventData, + * description: string + * } + */ +var replacementEffects = []; +var nextReplacementId = 1; + +/** + * Register a replacement effect from a card on the battlefield + */ +function registerReplacementEffect(effect) { + effect.id = nextReplacementId++; + replacementEffects.push(effect); + return effect.id; +} + +/** + * Remove all replacement effects from a specific card + */ +function removeReplacementEffects(sourceCardId) { + replacementEffects = replacementEffects.filter(function(r) { + return r.sourceCardId !== sourceCardId; + }); +} + +/** + * Apply replacement effects to an event. + * Returns the modified event data (or null if the event is fully replaced/prevented). + */ +function applyReplacements(eventType, eventData) { + var applicable = replacementEffects.filter(function(r) { + return r.event === eventType && (!r.condition || r.condition(eventData)); + }); + if (applicable.length === 0) return eventData; + + var result = eventData; + for (var i = 0; i < applicable.length; i++) { + if (!result) break; // Event was fully prevented + result = applicable[i].replace(result); + if (result && result._prevented) { + addLogEntry('>> Replacement: ' + applicable[i].description); + return null; + } + if (result && result._modified) { + addLogEntry('>> Replacement: ' + applicable[i].description); + } + } + return result; +} + +/** + * Scan battlefield cards and register known replacement effects. + * Call this whenever a card enters or leaves the battlefield. + */ +function scanReplacementEffects() { + replacementEffects = []; + if (!gameState) return; + + gameState.battlefieldCards.forEach(function(card) { + var oracle = (card.oracleText || '').toLowerCase(); + var oi = card.ownerIndex; + + // "If you would draw a card, instead..." (e.g., Notion Thief for opponents) + if (/if .* would draw a card.* instead/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'draw', + condition: function(data) { + // Check if applies to controller or opponents based on card text + if (/each opponent/i.test(oracle) || /another player/i.test(oracle)) { + return data.playerIdx !== oi; + } + return data.playerIdx === oi; + }, + replace: function(data) { + if (/that player.*instead/i.test(oracle) || /you draw/i.test(oracle)) { + // Redirect draw to controller + data.playerIdx = oi; + data._modified = true; + } + return data; + }, + description: card.name + ' replaces a draw' + }); + } + + // "If a creature would die, exile it instead" (e.g., Rest in Peace) + if (/would (?:die|be put into a graveyard).*exile.*instead/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'die', + condition: function() { return true; }, + replace: function(data) { + data.destination = 'exile'; + data._modified = true; + return data; + }, + description: card.name + ' exiles instead of graveyard' + }); + } + + // "If you would gain life, you lose that much life instead" (e.g., Tainted Remedy for opponents) + if (/would gain life.*lose that much life instead/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'gain_life', + condition: function(data) { + // Usually applies to opponents + if (/opponent/i.test(oracle)) return data.playerIdx !== oi; + return true; + }, + replace: function(data) { + data.amount = -Math.abs(data.amount); + data._modified = true; + return data; + }, + description: card.name + ' reverses life gain to life loss' + }); + } + + // "Damage can't be prevented" (e.g., Leyline of Punishment) — flag + if (/damage.*can't be prevented/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'prevent_damage', + condition: function() { return true; }, + replace: function(data) { + data._prevented = false; // Override any prevention + data._modified = true; + return data; + }, + description: card.name + ' prevents damage prevention' + }); + } + + // "If a nontoken creature you control would die, instead [effect]" + if (/nontoken creature you control would die.*instead/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'die', + condition: function(data) { + return data.ownerIndex === oi && !data.isToken; + }, + replace: function(data) { + // Common: return to hand, exile, etc. + if (/return.*to.*hand/i.test(oracle)) { + data.destination = 'hand'; + } else if (/exile/i.test(oracle)) { + data.destination = 'exile'; + } + data._modified = true; + return data; + }, + description: card.name + ' replaces creature death' + }); + } + + // "If a permanent entering the battlefield causes a triggered ability..." + // "Enters the battlefield tapped" is handled by applyStaticETBEffects already + + // "Double that amount" / "If you would create a token, create an additional" + if (/create (?:one|an?) additional/i.test(oracle) || /twice that many.*instead/i.test(oracle)) { + if (/token/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'create_token', + condition: function(data) { return data.ownerIndex === oi; }, + replace: function(data) { + data.count = (data.count || 1) * 2; + data._modified = true; + return data; + }, + description: card.name + ' doubles token creation' + }); + } + } + + // "If you would gain life, gain twice that much instead" (e.g., Boon Reflection) + if (/would gain life.*gain twice that much/i.test(oracle)) { + registerReplacementEffect({ + sourceCardId: card.id, + ownerIndex: oi, + event: 'gain_life', + condition: function(data) { return data.playerIdx === oi; }, + replace: function(data) { + data.amount = data.amount * 2; + data._modified = true; + return data; + }, + description: card.name + ' doubles life gain' + }); + } + }); +} + + +// ============================================================ +// 4. DAMAGE MARKING & CLEANUP STEP +// ============================================================ + +/** + * Mark damage on a creature (instead of instant kill). + * Damage is tracked on cardData.damageMarked. + * Creatures die from SBA check when damageMarked >= toughness. + */ +function markDamage(cardId, amount, sourceData) { + if (!gameState || amount <= 0) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + + // Apply replacement effects to damage + var eventData = applyReplacements('damage', { + targetCardId: cardId, + amount: amount, + source: sourceData || null, + ownerIndex: card.ownerIndex + }); + if (!eventData) return; // Damage was prevented + + card.damageMarked = (card.damageMarked || 0) + (eventData.amount || amount); + + // Update visual + var el = document.getElementById('bc-' + cardId); + if (el) { + var badge = el.querySelector('.damage-badge'); + if (!badge) { + badge = document.createElement('div'); + badge.className = 'damage-badge'; + el.appendChild(badge); + } + badge.textContent = card.damageMarked; + badge.style.display = card.damageMarked > 0 ? 'flex' : 'none'; + } + + return card.damageMarked; +} + +/** + * Clear all damage marked on all creatures (cleanup step) + */ +function clearAllDamageMarks() { + if (!gameState) return; + gameState.battlefieldCards.forEach(function(card) { + if (card.damageMarked && card.damageMarked > 0) { + card.damageMarked = 0; + var el = document.getElementById('bc-' + card.id); + if (el) { + var badge = el.querySelector('.damage-badge'); + if (badge) badge.style.display = 'none'; + } + } + }); +} + +/** + * Remove "until end of turn" effects from all cards + */ +function clearEndOfTurnEffects() { + if (!gameState) return; + gameState.battlefieldCards.forEach(function(card) { + // Clear temp keywords + if (card.tempKeywords && card.tempKeywords.length > 0) { + addLogEntry('>> Cleanup: Removed temporary keywords from ' + card.name + ''); + card.tempKeywords = []; + } + // Clear power/toughness mods from "until end of turn" effects + if (card.powerMod && card.powerMod !== 0) { + card.powerMod = 0; + } + if (card.toughnessMod && card.toughnessMod !== 0) { + card.toughnessMod = 0; + } + }); +} + +/** + * Full cleanup step — called at end of each turn. + * 1. Remove "until end of turn" effects + * 2. Clear damage marked on creatures + * 3. Discard to hand size (already handled) + * 4. Clear mana pools (if auto-clear enabled) + */ +function performCleanupStep() { + if (!gameState) return; + + addLogEntry('Phase: Cleanup Step'); + + // 1. Remove temporary effects + clearEndOfTurnEffects(); + + // 2. Clear damage + clearAllDamageMarks(); + + // 3. Hand size enforcement (already exists elsewhere) + + // 4. Clear mana pools + if (gameState.manaAutoClear !== false) { + clearAllManaPools(); + } + + // 5. Check SBA after cleanup + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) { + checkStateBasedActions(); + } + + renderPlayerPanels(); +} + +/** + * Enhanced SBA check: creatures with damage >= toughness die + */ +function checkDamageBasedSBA() { + if (!gameState) return []; + var alerts = []; + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/creature/i.test(card.typeLine)) return; + if (!card.damageMarked || card.damageMarked <= 0) return; + var toughness = getCreatureToughness(card); + if (card.damageMarked >= toughness) { + if (hasKeyword(card, 'indestructible')) return; + alerts.push({ + type: 'lethal_damage_marked', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' has ' + card.damageMarked + ' damage marked (toughness ' + toughness + ') -- lethal', + }); + } + }); + return alerts; +} + +// ============================================================ +// UNDO / REDO SYSTEM +// ============================================================ + +const UNDO_MAX = 100; +let undoStack = []; +let redoStack = []; +let undoDisabled = false; // temporarily suppress snapshots during restore + +function deepCloneState() { + if (!gameState) return null; + const snapshot = { + gameState: JSON.parse(JSON.stringify(gameState)), + nextStackGroupId: nextStackGroupId, + }; + return snapshot; +} + +function pushUndoState(description) { + if (undoDisabled || !gameState) return; + const snap = deepCloneState(); + if (!snap) return; + if (description) snap._description = description; + undoStack.push(snap); + if (undoStack.length > UNDO_MAX) undoStack.shift(); + redoStack = []; // any new action clears redo + updateUndoRedoButtons(); +} + +function undo() { + if (undoStack.length === 0 || !gameState) return; + // Push current state onto redo + const current = deepCloneState(); + if (current) redoStack.push(current); + // Pop previous state + const prev = undoStack.pop(); + restoreFromSnapshot(prev); + addLogEntry('\u21A9\uFE0F Undo'); + updateUndoRedoButtons(); +} + +function redo() { + if (redoStack.length === 0 || !gameState) return; + // Push current state onto undo + const current = deepCloneState(); + if (current) undoStack.push(current); + // Pop redo state + const next = redoStack.pop(); + restoreFromSnapshot(next); + addLogEntry('\u21AA\uFE0F Redo'); + updateUndoRedoButtons(); +} + +function restoreFromSnapshot(snapshot) { + if (!snapshot) return; + undoDisabled = true; + + // Restore game state + gameState = snapshot.gameState; + nextStackGroupId = snapshot.nextStackGroupId; + + // Clear existing battlefield card DOM elements (from section areas and battlefield fallback) + document.querySelectorAll('.bf-section-area .battlefield-card, #battlefield > .battlefield-card').forEach(function(c) { c.remove(); }); + + // Rebuild section divs for the restored game state (player count may differ) + buildBattlefieldSections(); + + // Re-render all battlefield cards + for (const cardData of gameState.battlefieldCards) { + renderBattlefieldCard(cardData); + } + refreshStackVisuals(); + + // Re-render all UI panels + renderPlayerPanels(); + renderHandArea(); + renderPhaseTracker(); + updateTurnDisplay(); + updateStormCounter(); + renderManaPool(); + updatePlayerZones(); + + // Sync mana auto-clear checkbox + const acCb = document.getElementById('mana-autoclear-toggle'); + if (acCb) acCb.checked = gameState.manaAutoClear !== false; + + // Hide battlefield label if cards exist + var bfEl = document.getElementById('battlefield'); + if (gameState.battlefieldCards.length > 0) { + var label2 = bfEl ? bfEl.querySelector('.battlefield-label') : null; + if (label2) label2.style.opacity = '0'; + } else { + var label3 = bfEl ? bfEl.querySelector('.battlefield-label') : null; + if (label3) label3.style.opacity = ''; + } + + undoDisabled = false; +} + +function updateUndoRedoButtons() { + // Refresh undo history panel if visible + if (undoHistoryVisible) renderUndoHistory(); + const undoBtn = document.getElementById('btn-undo'); + const redoBtn = document.getElementById('btn-redo'); + if (undoBtn) { + undoBtn.disabled = undoStack.length === 0; + var lastSnap = undoStack.length > 0 ? undoStack[undoStack.length - 1] : null; + var undoDesc = lastSnap && lastSnap._description ? lastSnap._description : 'last action'; + undoBtn.title = undoStack.length > 0 ? `Undo: ${undoDesc} (${undoStack.length} steps)` : 'Nothing to undo'; + // Badge showing count + var undoBadge = undoBtn.querySelector('.undo-count-badge'); + if (!undoBadge) { + undoBadge = document.createElement('span'); + undoBadge.className = 'undo-count-badge'; + undoBtn.appendChild(undoBadge); + } + undoBadge.textContent = undoStack.length > 0 ? undoStack.length : ''; + undoBadge.style.display = undoStack.length > 0 ? 'inline' : 'none'; + } + if (redoBtn) { + redoBtn.disabled = redoStack.length === 0; + redoBtn.title = redoStack.length > 0 ? `Redo (${redoStack.length} steps)` : 'Nothing to redo'; + } +} + +// ============================================================ +// TRIGGER SYSTEM — PARSING ENGINE +// ============================================================ + +let nextTriggerId = 1; + +const TRIGGER_TYPE_ICONS = { + etb: '✦', + ltb: '↩', + dies: '☠', + cast: '✦', + attack: '⚔', + block: '🛡', + upkeep: '◈', + combat_begin: '⚔', + end_step: '◇', + damage: '💥', + life_gain: '♥', + life_loss: '♦', + draw: '📥', + discard: '🗑', + mill: '⏬', + token_create: '✨', + sacrifice: '💀', + landfall: '🏔', + // New trigger types + enchantment_etb: '✧', + artifact_etb: '⚙', + counter_placed: '⊕', + equipped: '🗡', + target: '🎯', + tap: '↷', + transform: '🌙', + exile_from_grave: '⬡', + mana_spent: '◆', + commander_cast: '👑', + player_eliminated: '💀', + combat_damage_player: '⚔', +}; + +const TRIGGER_TYPE_LABELS = { + etb: 'Enters the Battlefield', + ltb: 'Leaves the Battlefield', + dies: 'Dies', + cast: 'Cast', + attack: 'Attacks', + block: 'Blocks', + upkeep: 'Beginning of Upkeep', + combat_begin: 'Beginning of Combat', + end_step: 'End Step', + damage: 'Deals Damage', + life_gain: 'Life Gained', + life_loss: 'Life Lost', + draw: 'Card Drawn', + discard: 'Card Discarded', + mill: 'Milled', + token_create: 'Token Created', + sacrifice: 'Sacrificed', + landfall: 'Landfall', + // New trigger types + enchantment_etb: 'Enchantment Enters Battlefield', + artifact_etb: 'Artifact Enters Battlefield', + counter_placed: 'Counter Placed', + equipped: 'Equipped/Attached', + target: 'Becomes Target', + tap: 'Creature Tapped', + transform: 'Transforms', + exile_from_grave: 'Exiled from Graveyard', + mana_spent: 'Mana Spent', + commander_cast: 'Commander Cast', + player_eliminated: 'Player Eliminated', + combat_damage_player: 'Combat Damage to Player', +}; + +/** + * Parse oracle text to identify trigger patterns. + * Returns an array of trigger descriptors. + */ +function parseTriggers(cardName, oracleText) { + if (!oracleText) return []; + const triggers = []; + const text = oracleText; + const safeName = cardName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + function extractSnippet(matchStr) { + const idx = text.indexOf(matchStr); + if (idx === -1) return matchStr.slice(0, 80); + const snippet = text.slice(idx, Math.min(text.length, idx + 90)); + return snippet + (idx + 90 < text.length ? '\u2026' : ''); + } + + // ETB + const etbPatterns = [ + new RegExp('when ' + safeName + ' enters the battlefield', 'i'), + new RegExp('when ' + safeName + ' enters', 'i'), + /whenever a creature enters the battlefield/i, + /whenever a creature enters/i, + /when a creature enters the battlefield/i, + /whenever another creature enters/i, + /whenever a permanent enters the battlefield/i, + /whenever a nontoken creature enters/i, + /whenever one or more creatures enter/i, + /whenever a land enters the battlefield/i, + /whenever an artifact enters the battlefield/i, + /whenever a creature enters the battlefield under your control/i, + ]; + for (const pat of etbPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'etb', text: extractSnippet(m[0]), cardName }); break; } + } + + // Nontoken ETB (separate trigger type for cards that specifically care) + { + const m = text.match(/whenever a nontoken creature enters/i); + if (m) triggers.push({ type: 'nontoken_etb', text: extractSnippet(m[0]), cardName }); + } + + // LTB + const ltbPatterns = [ + new RegExp('when ' + safeName + ' leaves the battlefield', 'i'), + /whenever a creature leaves the battlefield/i, + /when a permanent leaves the battlefield/i, + /whenever .+ leaves the battlefield/i, + ]; + for (const pat of ltbPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'ltb', text: extractSnippet(m[0]), cardName }); break; } + } + + // Dies + const diesPatterns = [ + new RegExp('when ' + safeName + ' dies', 'i'), + /whenever a creature you control dies/i, + /whenever a creature dies/i, + /whenever another creature dies/i, + /whenever a nontoken creature dies/i, + /whenever a creature an opponent controls dies/i, + /whenever .+ dies/i, + ]; + for (const pat of diesPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'dies', text: extractSnippet(m[0]), cardName }); break; } + } + + // Cast + const castPatterns = [ + new RegExp('when you cast ' + safeName, 'i'), + /whenever you cast a spell/i, + /whenever a player casts a spell/i, + /whenever you cast an instant or sorcery spell/i, + /whenever you cast a noncreature spell/i, + /whenever you cast a creature spell/i, + /whenever you cast a historic spell/i, + /whenever you cast .+ spell/i, + ]; + for (const pat of castPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'cast', text: extractSnippet(m[0]), cardName }); break; } + } + + // Attack + const attackPatterns = [ + new RegExp('whenever ' + safeName + ' attacks', 'i'), + /whenever a creature attacks/i, + /whenever one or more creatures attack/i, + /whenever you attack/i, + /whenever .+ attacks/i, + ]; + for (const pat of attackPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'attack', text: extractSnippet(m[0]), cardName }); break; } + } + + // Block + const blockPatterns = [ + new RegExp('whenever ' + safeName + ' blocks', 'i'), + new RegExp('when ' + safeName + ' becomes blocked', 'i'), + /whenever a creature blocks/i, + /whenever .+ blocks/i, + ]; + for (const pat of blockPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'block', text: extractSnippet(m[0]), cardName }); break; } + } + + // Upkeep + const upkeepPatterns = [ + /at the beginning of your upkeep/i, + /at the beginning of each player's upkeep/i, + /at the beginning of each upkeep/i, + /at the beginning of .+ upkeep/i, + ]; + for (const pat of upkeepPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'upkeep', text: extractSnippet(m[0]), cardName }); break; } + } + + // Combat begin + const combatBeginPatterns = [ + /at the beginning of combat on your turn/i, + /at the beginning of combat/i, + /at the beginning of each combat/i, + ]; + for (const pat of combatBeginPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'combat_begin', text: extractSnippet(m[0]), cardName }); break; } + } + + // End step + const endStepPatterns = [ + /at the beginning of your end step/i, + /at the beginning of each end step/i, + /at the beginning of the end step/i, + /at the beginning of .+ end step/i, + ]; + for (const pat of endStepPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'end_step', text: extractSnippet(m[0]), cardName }); break; } + } + + // Damage + const damagePatterns = [ + new RegExp('whenever ' + safeName + ' deals combat damage to a player', 'i'), + new RegExp('whenever ' + safeName + ' deals damage', 'i'), + /whenever .+ deals combat damage to a player/i, + /whenever .+ deals damage/i, + ]; + for (const pat of damagePatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'damage', text: extractSnippet(m[0]), cardName }); break; } + } + + // Life gain + { + const m = text.match(/whenever you gain life/i); + if (m) triggers.push({ type: 'life_gain', text: extractSnippet(m[0]), cardName }); + } + + // Life loss + { + const m = text.match(/whenever you lose life/i); + if (m) triggers.push({ type: 'life_loss', text: extractSnippet(m[0]), cardName }); + } + + // Draw + const drawPatterns = [ + /whenever you draw a card/i, + /whenever a player draws a card/i, + /whenever you draw one or more cards/i, + /whenever an opponent draws a card/i, + /whenever a card is drawn/i, + ]; + for (const pat of drawPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'draw', text: extractSnippet(m[0]), cardName }); break; } + } + + // Discard + const discardPatterns = [ + /whenever you discard a card/i, + /whenever a player discards a card/i, + /whenever you discard one or more cards/i, + /whenever a card is discarded/i, + /whenever an opponent discards a card/i, + ]; + for (const pat of discardPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'discard', text: extractSnippet(m[0]), cardName }); break; } + } + + // Mill + const millPatterns = [ + /whenever one or more cards are put into your graveyard from your library/i, + /whenever a card is put into a graveyard from a library/i, + /whenever you mill/i, + /whenever a player mills/i, + /whenever one or more cards are milled/i, + ]; + for (const pat of millPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'mill', text: extractSnippet(m[0]), cardName }); break; } + } + + // Token creation + const tokenPatterns = [ + /whenever you create a token/i, + /whenever one or more tokens are created/i, + /whenever a creature token enters/i, + /whenever you create one or more tokens/i, + /whenever a token enters the battlefield/i, + /whenever a token is created/i, + ]; + for (const pat of tokenPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'token_create', text: extractSnippet(m[0]), cardName }); break; } + } + + // Sacrifice + const sacrificePatterns = [ + /whenever you sacrifice a/i, + /whenever a player sacrifices a/i, + /whenever a creature is sacrificed/i, + /whenever a permanent is sacrificed/i, + /whenever you sacrifice another/i, + ]; + for (const pat of sacrificePatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'sacrifice', text: extractSnippet(m[0]), cardName }); break; } + } + + // Landfall (dedicated — not just generic ETB land) + const landfallPatterns = [ + /landfall/i, + /whenever a land enters the battlefield under your control/i, + ]; + for (const pat of landfallPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'landfall', text: extractSnippet(m[0]), cardName }); break; } + } + + // Enchantment ETB (Constellation) + const enchantmentEtbPatterns = [ + /constellation/i, + /whenever an enchantment enters the battlefield/i, + /whenever an enchantment you control enters/i, + /whenever an enchantment enters the battlefield under your control/i, + ]; + for (const pat of enchantmentEtbPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'enchantment_etb', text: extractSnippet(m[0]), cardName }); break; } + } + + // Artifact ETB + const artifactEtbPatterns = [ + /whenever an artifact enters the battlefield under your control/i, + /whenever an artifact enters the battlefield/i, + /whenever an artifact you control enters/i, + ]; + for (const pat of artifactEtbPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'artifact_etb', text: extractSnippet(m[0]), cardName }); break; } + } + + // Counter placed + const counterPlacedPatterns = [ + /whenever a counter is placed/i, + /whenever you put a counter/i, + /whenever one or more counters are put/i, + /whenever a \+1\/\+1 counter is placed/i, + /whenever a counter is put on/i, + ]; + for (const pat of counterPlacedPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'counter_placed', text: extractSnippet(m[0]), cardName }); break; } + } + + // Equipped / attached + const equippedPatterns = [ + /whenever equipped creature/i, + /whenever this becomes attached/i, + /whenever .+ becomes equipped/i, + /whenever .+ becomes attached/i, + ]; + for (const pat of equippedPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'equipped', text: extractSnippet(m[0]), cardName }); break; } + } + + // Target + const targetTrigPatterns = [ + /whenever you become the target/i, + /whenever a creature becomes the target/i, + /whenever .+ becomes the target of/i, + /whenever .+ is the target of/i, + ]; + for (const pat of targetTrigPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'target', text: extractSnippet(m[0]), cardName }); break; } + } + + // Tap + const tapTriggerPatterns = [ + /whenever you tap/i, + /whenever a creature becomes tapped/i, + /whenever .+ becomes tapped/i, + /whenever .+ is tapped/i, + ]; + for (const pat of tapTriggerPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'tap', text: extractSnippet(m[0]), cardName }); break; } + } + + // Transform / daybound / nightbound + const transformPatterns = [ + /when this (creature )?transforms/i, + /daybound/i, + /nightbound/i, + /whenever this (permanent|creature|card) transforms/i, + ]; + for (const pat of transformPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'transform', text: extractSnippet(m[0]), cardName }); break; } + } + + // Exile from graveyard + const exileFromGravePatterns = [ + /whenever a card is exiled from a graveyard/i, + /whenever a card is exiled from your graveyard/i, + /whenever a card leaves your graveyard/i, + /whenever a card leaves a graveyard/i, + /whenever .+ is exiled from a graveyard/i, + ]; + for (const pat of exileFromGravePatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'exile_from_grave', text: extractSnippet(m[0]), cardName }); break; } + } + + // Mana spent + const manaSpentPatterns = [ + /whenever you spend mana/i, + /whenever you cast a spell, you may pay/i, + /whenever .+ mana is spent/i, + /whenever mana of any type is spent/i, + ]; + for (const pat of manaSpentPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'mana_spent', text: extractSnippet(m[0]), cardName }); break; } + } + + // Commander cast + { + const m = text.match(/whenever you cast your commander/i); + if (m) triggers.push({ type: 'commander_cast', text: extractSnippet(m[0]), cardName }); + } + + // Player eliminated + const playerEliminatedPatterns = [ + /whenever a player loses the game/i, + /whenever an opponent loses the game/i, + /whenever a player leaves the game/i, + ]; + for (const pat of playerEliminatedPatterns) { + const m = text.match(pat); + if (m) { triggers.push({ type: 'player_eliminated', text: extractSnippet(m[0]), cardName }); break; } + } + + // Combat damage to a player (dedicated — separate from generic damage) + { + const m = text.match(new RegExp('whenever ' + safeName + ' deals combat damage to a player', 'i')); + if (m) triggers.push({ type: 'combat_damage_player', text: extractSnippet(m[0]), cardName }); + } + + return triggers; +} + +// ============================================================ +// TRIGGER EFFECT HINTS +// ============================================================ + +/** + * Parse the effect portion of a trigger from Oracle text. + * Returns { hint: string, autoResolvable: bool, effectType: string|null } + */ +const EFFECT_PATTERNS = [ + // ---- Compound effects (must come BEFORE single-effect patterns) ---- + { pattern: /draw a card and you lose (\d+) life/i, hint: 'Draw + lose life', effectType: 'draw_lose', auto: true }, + { pattern: /draw a card.{0,15}lose (\d+) life/i, hint: 'Draw + lose life', effectType: 'draw_lose', auto: true }, + { pattern: /gain (\d+) life.{0,20}draw a card/i, hint: 'Gain life + draw', effectType: 'gain_draw', auto: true }, + { pattern: /deals? (\d+) damage.{0,30}gains? that much life/i, hint: 'Damage + lifegain', effectType: 'damage_lifegain', auto: false }, + // ---- Draw ---- + { pattern: /draw a card/i, hint: 'Draw a card', effectType: 'draw', auto: true }, + { pattern: /draw (two|three|four|five|six|seven|\d+) cards?/i, hint: 'Draw cards', effectType: 'draw_n', auto: true }, + // ---- Life ---- + { pattern: /gain (\d+) life/i, hint: 'Gain life', effectType: 'gain_life', auto: true }, + { pattern: /lose (\d+) life/i, hint: 'Lose life', effectType: 'lose_life', auto: true }, + { pattern: /each opponent loses (\d+) life/i, hint: 'Opponents lose life', effectType: 'opp_lose', auto: true }, + { pattern: /each opponent loses (\d+) life and you gain .* life/i, hint: 'Drain', effectType: 'drain', auto: true }, + { pattern: /you gain life equal to/i, hint: 'Gain life (variable)', effectType: 'gain_life_var', auto: false }, + // ---- Tokens ---- + { pattern: /create a? ?\d*\/\d* .* token/i, hint: 'Create token', effectType: 'token', auto: true }, + { pattern: /create a? ?[A-Z].* token/i, hint: 'Create token', effectType: 'token', auto: true }, + { pattern: /create two? .*token/i, hint: 'Create tokens', effectType: 'tokens', auto: true }, + { pattern: /create a? ?treasure/i, hint: 'Create Treasure', effectType: 'treasure', auto: true }, + { pattern: /create a? ?food/i, hint: 'Create Food', effectType: 'food', auto: true }, + { pattern: /investigate|create a? ?clue/i, hint: 'Investigate', effectType: 'investigate',auto: true }, + // ---- Counters ---- + { pattern: /put a \+1\/\+1 counter on (.+?)(?:\.|$)/i, hint: '+1/+1 counter', effectType: 'counter_plus', auto: true }, + { pattern: /put (two|three|four|\d+) \+1\/\+1 counters? on/i, hint: '+1/+1 counters', effectType: 'counter_plus_n',auto: true }, + { pattern: /put a \-1\/\-1 counter on (.+?)(?:\.|$)/i, hint: '-1/-1 counter', effectType: 'counter_minus', auto: true }, + { pattern: /put a \+1\/\+1 counter on each creature you control/i, hint: '+1/+1 all', effectType: 'counter_all', auto: true }, + { pattern: /proliferate/i, hint: 'Proliferate', effectType: 'proliferate',auto: false }, + // ---- Damage ---- + { pattern: /deals? (\d+) damage to (?:each|any) opponent/i, hint: 'Damage opponents', effectType: 'damage_opp', auto: true }, + { pattern: /deals? (\d+) damage to each player/i, hint: 'Damage all players', effectType: 'damage_all', auto: true }, + { pattern: /deals? (\d+) damage/i, hint: 'Deal damage', effectType: 'damage', auto: false }, + // ---- Destroy / Exile / Bounce ---- + { pattern: /destroy target/i, hint: 'Destroy', effectType: 'destroy', auto: false }, + { pattern: /exile target/i, hint: 'Exile', effectType: 'exile', auto: false }, + { pattern: /return .* to (your|its owner's) hand/i, hint: 'Bounce', effectType: 'bounce', auto: false }, + // ---- Mill ---- + { pattern: /mill (\d+)/i, hint: 'Mill', effectType: 'mill_ef', auto: true }, + { pattern: /put the top (\w+|\d+) cards? of your library into your graveyard/i, hint: 'Self mill', effectType: 'self_mill_n', auto: true }, + { pattern: /put the top card of your library into your graveyard/i, hint: 'Self mill 1', effectType: 'self_mill', auto: true }, + { pattern: /each opponent mills (\d+)/i, hint: 'Opponents mill', effectType: 'opp_mill', auto: true }, + // ---- Scry ---- + { pattern: /scry (\d+)/i, hint: 'Scry', effectType: 'scry', auto: true }, + // ---- Library ---- + { pattern: /search your library/i, hint: 'Tutor', effectType: 'tutor', auto: false }, + { pattern: /return .* from .* graveyard/i, hint: 'Return from graveyard',effectType: 'reanimate', auto: false }, + // ---- Sacrifice / Discard ---- + { pattern: /sacrifice a/i, hint: 'Sacrifice', effectType: 'sacrifice', auto: false }, + { pattern: /discard a card/i, hint: 'Discard', effectType: 'discard_ef', auto: false }, + { pattern: /each opponent discards a card/i, hint: 'Opponents discard', effectType: 'opp_discard',auto: true }, + { pattern: /each opponent sacrifices a/i, hint: 'Opponents sacrifice', effectType: 'opp_sacrifice', auto: false }, + // ---- Mana / Pump / Keywords ---- + { pattern: /add (\{[WUBRGC]\}|one mana)/i, hint: 'Add mana', effectType: 'mana', auto: false }, + { pattern: /\+\d+\/\+\d+ until end of turn/i, hint: 'Pump', effectType: 'pump', auto: false }, + { pattern: /gains? (flying|haste|trample|vigilance|lifelink|deathtouch|first strike|double strike|indestructible|hexproof|menace|reach) until end of turn/i, hint: 'Grant keyword', effectType: 'keyword_temp', auto: true }, + { pattern: /gains? (flying|haste|trample|vigilance|lifelink|deathtouch|first strike|double strike|indestructible|hexproof|menace|reach)/i, hint: 'Grant keyword', effectType: 'keyword', auto: false }, + // ---- Other ---- + { pattern: /untap all .* you control/i, hint: 'Untap all', effectType: 'untap_all', auto: true }, + { pattern: /untap target/i, hint: 'Untap', effectType: 'untap', auto: false }, + { pattern: /tap target/i, hint: 'Tap', effectType: 'tap', auto: false }, + { pattern: /each player draws a card/i, hint: 'All players draw', effectType: 'all_draw', auto: true }, + { pattern: /target player draws a card/i, hint: 'Target draws', effectType: 'target_draw',auto: false }, + { pattern: /counter target spell/i, hint: 'Counter spell', effectType: 'counter_spell', auto: false }, + { pattern: /you may/i, hint: 'Optional', effectType: 'optional', auto: false }, +]; + +function parseTriggerEffect(oracleText) { + if (!oracleText) return { hint: '', autoResolvable: false, effectType: null }; + // Get the part after the trigger condition (after the comma typically) + var effectText = oracleText; + var commaIdx = oracleText.indexOf(','); + if (commaIdx > 10 && commaIdx < oracleText.length - 5) { + effectText = oracleText.slice(commaIdx + 1); + } + for (var i = 0; i < EFFECT_PATTERNS.length; i++) { + var ep = EFFECT_PATTERNS[i]; + var m = effectText.match(ep.pattern); + if (m) { + var hint = ep.hint; + // Add specifics from capture groups + if (ep.effectType === 'gain_life' && m[1]) hint = 'Gain ' + m[1] + ' life'; + if (ep.effectType === 'lose_life' && m[1]) hint = 'Lose ' + m[1] + ' life'; + if (ep.effectType === 'opp_lose' && m[1]) hint = 'Each opponent loses ' + m[1] + ' life'; + if (ep.effectType === 'damage' && m[1]) hint = 'Deal ' + m[1] + ' damage'; + return { hint: hint, autoResolvable: ep.auto, effectType: ep.effectType }; + } + } + return { hint: '', autoResolvable: false, effectType: null }; +} + +/** + * Push a trigger onto the game state trigger stack. + */ +function pushTrigger(type, cardName, oracleSnippet, ownerIndex, sourceCardId) { + if (!gameState) return; + var effect = parseTriggerEffect(oracleSnippet); + const trigger = { + id: nextTriggerId++, + type, + cardName, + oracleSnippet: oracleSnippet || '', + ownerIndex: ownerIndex != null ? ownerIndex : (gameState.currentPlayerIndex), + sourceCardId: sourceCardId || null, + resolved: false, + effectHint: effect.hint, + autoResolvable: effect.autoResolvable, + effectType: effect.effectType, + }; + gameState.triggerStack.push(trigger); + updateTriggerBadge(); + renderTriggerStack(); + updateUnifiedStack(); + addLogEntry('⚡ Trigger: ' + cardName + ' — ' + (TRIGGER_TYPE_LABELS[type] || type) + (effect.hint ? ' (' + effect.hint + ')' : '')); + // Auto-resolve immediately if eligible + if (trigger.autoResolvable && gameState.rulesEngine && gameState.rulesEngine.autoResolveTriggers) { + setTimeout(function() { autoResolveTrigger(trigger); }, 150); + } +} + +/** + * Fire all battlefield permanents that have triggers of triggerType. + * filterFn(cardData) => bool optional filter. + */ +function fireBattlefieldTriggers(triggerType, filterFn) { + if (!gameState) return; + let firedAny = false; + var currentPlayerIdx = gameState.currentPlayerIndex; + gameState.battlefieldCards.forEach(cardData => { + if (!cardData.triggers || !cardData.triggers.length) return; + if (filterFn && !filterFn(cardData)) return; + const matching = cardData.triggers.filter(t => t.type === triggerType); + matching.forEach(t => { + // For upkeep/end_step triggers: check if they should fire this turn + if (triggerType === 'upkeep' || triggerType === 'end_step') { + var trigText = (t.text || '').toLowerCase(); + var isYourOnly = /\byour\b/.test(trigText) && !/\beach\b/.test(trigText); + // "your upkeep" only fires on the card owner's turn + if (isYourOnly && cardData.ownerIndex !== currentPlayerIdx) return; + } + pushTrigger(t.type, t.cardName, t.text, cardData.ownerIndex, cardData.id); + firedAny = true; + }); + }); + // Flash the active phase step when triggers fire + if (firedAny) flashActivePhaseStep(); +} + +/** + * Fire triggers for a single card (by type array). + */ +function fireCardTriggers(cardData, triggerTypes) { + if (!cardData || !cardData.triggers) return; + cardData.triggers + .filter(t => triggerTypes.includes(t.type)) + .forEach(t => pushTrigger(t.type, t.cardName, t.text, cardData.ownerIndex, cardData.id)); +} + +// ============================================================ +// TRIGGER STACK UI +// ============================================================ + +function updateTriggerBadge() { + if (!gameState) return; + const count = gameState.triggerStack.filter(t => !t.resolved).length; + const badgeEl = document.getElementById('trigger-count'); + const inlineEl = document.getElementById('trigger-count-inline'); + const btnEl = document.getElementById('btn-trigger-stack'); + if (badgeEl) badgeEl.textContent = count; + if (inlineEl) inlineEl.textContent = count; + if (btnEl) btnEl.classList.toggle('has-triggers', count > 0); +} + +function openTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (panel) panel.classList.add('visible'); + renderTriggerStack(); +} + +function closeTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (panel) panel.classList.remove('visible'); +} + +function toggleTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (!panel) return; + panel.classList.contains('visible') ? closeTriggerStack() : openTriggerStack(); +} + +function escapeHtml(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderTriggerStack() { + const body = document.getElementById('trigger-stack-body'); + if (!body || !gameState) return; + + const pending = gameState.triggerStack.filter(t => !t.resolved); + + if (!pending.length) { + body.innerHTML = '
No pending triggers
'; + return; + } + + body.innerHTML = ''; + pending.forEach((trigger, idx) => { + const ownerColor = (gameState.players[trigger.ownerIndex] || {}).color || '#888'; + const typeIcon = TRIGGER_TYPE_ICONS[trigger.type] || '?'; + const typeLabel = TRIGGER_TYPE_LABELS[trigger.type] || trigger.type; + + const item = document.createElement('div'); + item.className = 'trigger-item'; + item.dataset.triggerId = trigger.id; + + var effectBadge = ''; + if (trigger.effectHint) { + effectBadge = '' + escapeHtml(trigger.effectHint) + ''; + } + + item.innerHTML = + '
≡
' + + '
' + typeIcon + '
' + + '
' + + '
' + escapeHtml(trigger.cardName) + effectBadge + '
' + + '
' + escapeHtml(typeLabel) + '
' + + '
' + escapeHtml(trigger.oracleSnippet) + '
' + + '
' + + '
' + + ''; + + // Dismiss button — use inline onclick for Comet browser compatibility + var dismissBtn = item.querySelector('.trigger-item-dismiss'); + if (dismissBtn) dismissBtn.setAttribute('onclick', 'event.stopPropagation();dismissTrigger(' + trigger.id + ')'); + + setupTriggerItemDrag(item, trigger.id); + body.appendChild(item); + }); + + // Auto-open when triggers arrive + const panel = document.getElementById('trigger-stack-panel'); + if (panel && !panel.classList.contains('visible')) { + openTriggerStack(); + } + updateUnifiedStack(); +} + + +/** + * Automatically apply simple trigger effects when resolved. + * Only handles unambiguous, single-player effects. + */ +function autoResolveTriggerEffect(trigger) { + if (!trigger || !trigger.autoResolvable || !trigger.effectType) return; + var owner = gameState.players[trigger.ownerIndex]; + if (!owner) return; + var snippet = trigger.oracleSnippet || ''; + var cname = trigger.cardName || 'Unknown'; + var oi = trigger.ownerIndex; + + // Helper: get opponent indices + function getOpponents(ownerIdx) { + var opps = []; + for (var p = 0; p < gameState.players.length; p++) { + if (p !== ownerIdx) opps.push(p); + } + return opps; + } + + // Helper: find the card on battlefield for counter placement + function findCardOnBattlefield(cardName, ownerIdx) { + var bf = (gameState.players[ownerIdx] || {}).battlefield || []; + for (var i = 0; i < bf.length; i++) { + if (bf[i].name === cardName) return bf[i]; + } + return null; + } + + switch (trigger.effectType) { + // ---- Compound effects ---- + case 'draw_lose': { + drawCard(oi); + var dlMatch = snippet.match(/lose (\d+) life/i); + var dlAmt = dlMatch ? parseInt(dlMatch[1], 10) : 1; + adjustLife(oi, -dlAmt); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw a card and lose ' + dlAmt + ' life'); + break; + } + + case 'gain_draw': { + var gdMatch = snippet.match(/gain (\d+) life/i); + var gdAmt = gdMatch ? parseInt(gdMatch[1], 10) : 1; + adjustLife(oi, gdAmt); + drawCard(oi); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Gain ' + gdAmt + ' life and draw a card'); + break; + } + + case 'draw': + drawCard(oi); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw a card'); + break; + + case 'draw_n': { + var dnMatch = snippet.match(/draws?\s+(\w+|\d+)\s+cards?/i); + var dnCount = 1; + if (dnMatch) { + var wordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9,ten:10}; + dnCount = wordMap[dnMatch[1].toLowerCase()] || parseInt(dnMatch[1], 10) || 1; + } + for (var dn = 0; dn < dnCount; dn++) drawCard(oi); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw ' + dnCount + ' cards'); + break; + } + + case 'all_draw': { + var adMatch = snippet.match(/each player draws?\s+(\w+|\d+)?/i); + var adCount = 1; + if (adMatch && adMatch[1]) { + var adMap = {a:1,one:1,two:2,three:3,four:4,five:5}; + adCount = adMap[adMatch[1].toLowerCase()] || parseInt(adMatch[1], 10) || 1; + } + for (var ap = 0; ap < gameState.players.length; ap++) { + for (var adc = 0; adc < adCount; adc++) drawCard(ap); + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each player draws ' + adCount); + break; + } + + case 'gain_life': { + var gainMatch = snippet.match(/gain\s+(\d+)\s+life/i); + var gainAmt = gainMatch ? parseInt(gainMatch[1], 10) : 1; + adjustLife(oi, gainAmt); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Gain ' + gainAmt + ' life'); + break; + } + + case 'lose_life': { + var loseMatch = snippet.match(/lose\s+(\d+)\s+life/i); + var loseAmt = loseMatch ? parseInt(loseMatch[1], 10) : 1; + adjustLife(oi, -loseAmt); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Lose ' + loseAmt + ' life'); + break; + } + + case 'opp_lose': { + var olMatch = snippet.match(/(?:each opponent|opponents?)\s+loses?\s+(\d+)\s+life/i); + var olAmt = olMatch ? parseInt(olMatch[1], 10) : 1; + var opps1 = getOpponents(oi); + for (var ol = 0; ol < opps1.length; ol++) adjustLife(opps1[ol], -olAmt); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent loses ' + olAmt + ' life'); + break; + } + + case 'drain': { + var drainLose = snippet.match(/(?:each opponent|opponents?)\s+loses?\s+(\d+)\s+life/i); + var drainGain = snippet.match(/you gain\s+(?:that much life|(\d+)\s+life)/i); + var drLose = drainLose ? parseInt(drainLose[1], 10) : 1; + var drGain = drainGain && drainGain[1] ? parseInt(drainGain[1], 10) : drLose; + var opps2 = getOpponents(oi); + for (var dr = 0; dr < opps2.length; dr++) adjustLife(opps2[dr], -drLose); + adjustLife(oi, drGain * opps2.length); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Drain ' + drLose + ' from each opponent, gain ' + (drGain * opps2.length)); + break; + } + + case 'damage_opp': { + var doMatch = snippet.match(/deals?\s+(\d+)\s+damage\s+to\s+each\s+opponent/i); + var doDmg = doMatch ? parseInt(doMatch[1], 10) : 1; + var opps3 = getOpponents(oi); + for (var dop = 0; dop < opps3.length; dop++) adjustLife(opps3[dop], -doDmg); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Deal ' + doDmg + ' damage to each opponent'); + break; + } + + case 'damage_all': { + var daMatch = snippet.match(/deals?\s+(\d+)\s+damage\s+to\s+each\s+(?:player|creature and each player)/i); + var daDmg = daMatch ? parseInt(daMatch[1], 10) : 1; + for (var dap = 0; dap < gameState.players.length; dap++) adjustLife(dap, -daDmg); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Deal ' + daDmg + ' damage to each player'); + break; + } + + case 'counter_plus': { + var cpCard = findCardOnBattlefield(cname, oi); + if (cpCard) { + cpCard.counters = cpCard.counters || {}; + cpCard.counters['+1/+1'] = (cpCard.counters['+1/+1'] || 0) + 1; + if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cpCard); + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Put a +1/+1 counter on it'); + break; + } + + case 'counter_plus_n': { + var cpnMatch = snippet.match(/(\w+|\d+)\s+\+1\/\+1\s+counters?/i); + var cpnCount = 1; + if (cpnMatch) { + var cpnMap = {a:1,one:1,two:2,three:3,four:4,five:5,six:6}; + cpnCount = cpnMap[cpnMatch[1].toLowerCase()] || parseInt(cpnMatch[1], 10) || 1; + } + var cpnCard = findCardOnBattlefield(cname, oi); + if (cpnCard) { + cpnCard.counters = cpnCard.counters || {}; + cpnCard.counters['+1/+1'] = (cpnCard.counters['+1/+1'] || 0) + cpnCount; + if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cpnCard); + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Put ' + cpnCount + ' +1/+1 counters'); + break; + } + + case 'counter_minus': { + // -1/-1 counter on target — place on first opponent creature as default + var opps4 = getOpponents(oi); + var placed = false; + for (var cmi = 0; cmi < opps4.length && !placed; 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 || {}; + cmBf[cmj].counters['-1/-1'] = (cmBf[cmj].counters['-1/-1'] || 0) + 1; + if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cmBf[cmj]); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Put -1/-1 counter on ' + cmBf[cmj].name); + placed = true; + } + } + } + if (!placed) addLogEntry('>> Auto-resolved: ' + cname + ' -- -1/-1 counter (no valid target)'); + break; + } + + case 'counter_all': { + // +1/+1 counter on each creature you control + 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) { + caBf[cai].counters = caBf[cai].counters || {}; + caBf[cai].counters['+1/+1'] = (caBf[cai].counters['+1/+1'] || 0) + 1; + if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(caBf[cai]); + caCount++; + } + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- +1/+1 counter on ' + caCount + ' creatures'); + break; + } + + case 'self_mill': + millCards(oi, 1); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Mill 1'); + break; + + case 'self_mill_n': { + var smnMatch = snippet.match(/mills?\s+(\w+|\d+)/i); + var smnCount = 1; + if (smnMatch) { + var smnMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,ten:10}; + smnCount = smnMap[smnMatch[1].toLowerCase()] || parseInt(smnMatch[1], 10) || 1; + } + millCards(oi, smnCount); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Mill ' + smnCount); + break; + } + + case 'mill_ef': { + var meMatch = snippet.match(/(?:target|each)\s+(?:player|opponent)\s+(?:mills?|puts?).*?(\w+|\d+)/i); + var meCount = 1; + if (meMatch) { + var meMap = {one:1,two:2,three:3,four:4,five:5,ten:10}; + meCount = meMap[meMatch[1].toLowerCase()] || parseInt(meMatch[1], 10) || 1; + } + var isOppOnly = /each opponent|target opponent/i.test(snippet); + if (isOppOnly) { + var opps5 = getOpponents(oi); + for (var mei = 0; mei < opps5.length; mei++) millCards(opps5[mei], meCount); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent mills ' + meCount); + } else { + for (var mep = 0; mep < gameState.players.length; mep++) millCards(mep, meCount); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each player mills ' + meCount); + } + break; + } + + case 'opp_mill': { + var omMatch = snippet.match(/opponent\s+mills?\s+(\w+|\d+)/i); + var omCount = 1; + if (omMatch) { + var omMap = {one:1,two:2,three:3,four:4,five:5,ten:10}; + omCount = omMap[omMatch[1].toLowerCase()] || parseInt(omMatch[1], 10) || 1; + } + var opps6 = getOpponents(oi); + for (var omi = 0; omi < opps6.length; omi++) millCards(opps6[omi], omCount); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent mills ' + omCount); + break; + } + + case 'scry': { + var scryMatch = snippet.match(/scry\s+(\d+)/i); + var scryN = scryMatch ? parseInt(scryMatch[1], 10) : 1; + if (typeof openScryModal === 'function') { + openScryModal(oi, scryN); + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Scry ' + scryN); + break; + } + + case 'opp_discard': { + var odMatch = snippet.match(/(?:each opponent|opponents?)\s+discards?\s+(?:a|one|\d+)\s+cards?/i); + var odCount = 1; + if (odMatch) { + var odNumMatch = odMatch[0].match(/(\d+)/); + odCount = odNumMatch ? parseInt(odNumMatch[1], 10) : 1; + } + var opps7 = getOpponents(oi); + for (var odi = 0; odi < opps7.length; odi++) { + var odHand = (gameState.players[opps7[odi]] || {}).hand || []; + for (var odj = 0; odj < odCount && odHand.length > 0; odj++) { + var randIdx = Math.floor(Math.random() * odHand.length); + var discarded = odHand.splice(randIdx, 1)[0]; + gameState.players[opps7[odi]].graveyard = gameState.players[opps7[odi]].graveyard || []; + gameState.players[opps7[odi]].graveyard.push(discarded); + addLogEntry('>> ' + gameState.players[opps7[odi]].name + ' discards ' + (discarded.name || 'a card')); + } + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent discards ' + odCount); + break; + } + + case 'keyword_temp': { + // Grants a temporary keyword (haste, lifelink, etc.) — tag the card + var kwMatch = snippet.match(/gains?\s+(haste|lifelink|deathtouch|flying|trample|vigilance|menace|first strike|double strike|hexproof|indestructible|reach)/i); + if (kwMatch) { + var kw = kwMatch[1].toLowerCase(); + var kwCard = findCardOnBattlefield(cname, oi); + if (kwCard) { + kwCard.tempKeywords = kwCard.tempKeywords || []; + if (kwCard.tempKeywords.indexOf(kw) === -1) kwCard.tempKeywords.push(kw); + if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(kwCard); + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Gains ' + kw); + } + break; + } + + case 'untap_all': { + var uaBf = (gameState.players[oi] || {}).battlefield || []; + var uaCount = 0; + for (var uai = 0; uai < uaBf.length; uai++) { + if (uaBf[uai].tapped) { + uaBf[uai].tapped = false; + uaCount++; + } + } + addLogEntry('>> Auto-resolved: ' + cname + ' -- Untapped ' + uaCount + ' permanents'); + break; + } + + case 'token': + case 'tokens': { + var tokenSpecs = parseTokenCreationAbilities(snippet); + if (tokenSpecs.length > 0) { + for (var ti = 0; ti < tokenSpecs.length; ti++) { + var ts = tokenSpecs[ti]; + createQuickToken(oi, ts.name, ts.power, ts.toughness, ts.typeLine, ts.color, ts.abilities, ts.count); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Created ' + ts.displayLabel); + } + } else { + addLogEntry('!! Could not auto-parse token from: ' + cname + ' -- resolve manually'); + } + break; + } + + case 'treasure': { + createQuickToken(oi, 'Treasure', '', '', 'Artifact -- Treasure', 'C', 'Sacrifice: Add one mana of any color', 1); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Created 1x Treasure'); + break; + } + + case 'food': { + createQuickToken(oi, 'Food', '', '', 'Artifact -- Food', 'C', '{2}, Sacrifice: Gain 3 life', 1); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Created 1x Food'); + break; + } + + case 'investigate': { + createQuickToken(oi, 'Clue', '', '', 'Artifact -- Clue', 'C', '{2}, Sacrifice: Draw a card', 1); + addLogEntry('>> Auto-resolved: ' + cname + ' -- Investigated (Created 1x Clue)'); + break; + } + + default: + addLogEntry('!! Unhandled auto-trigger type "' + trigger.effectType + '" for ' + cname + ' -- resolve manually'); + break; + } + + // Refresh UI after any auto-resolution + if (typeof renderPlayerPanels === 'function') renderPlayerPanels(); +} + +/** + * autoResolveTrigger(trigger) — gated by rulesEngine.autoResolveTriggers setting. + * Auto-resolves simple effects, removes from stack, and chains to next auto trigger. + * @returns {boolean} true if auto-resolved, false if manual needed + */ +function autoResolveTrigger(trigger) { + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.autoResolveTriggers) return false; + if (!trigger || !trigger.autoResolvable || !trigger.effectType) return false; + // Verify trigger is still on stack + var stillPending = gameState.triggerStack.find(function(t) { return t.id === trigger.id && !t.resolved; }); + if (!stillPending) return false; + + autoResolveTriggerEffect(trigger); + + var idx = gameState.triggerStack.findIndex(function(t) { return t.id === trigger.id; }); + if (idx !== -1) gameState.triggerStack.splice(idx, 1); + updateTriggerBadge(); + + if (gameState.rulesEngine.sbaChecks) checkStateBasedActions(); + + var nextAuto = gameState.triggerStack.filter(function(t) { return !t.resolved && t.autoResolvable; }); + if (nextAuto.length > 0) { + setTimeout(function() { + autoResolveTrigger(nextAuto[0]); + renderTriggerStack(); + updateUnifiedStack(); + }, 300); + } else { + renderTriggerStack(); + updateUnifiedStack(); + if (!gameState.triggerStack.filter(function(t) { return !t.resolved; }).length) { + setTimeout(closeTriggerStack, 500); + } + } + return true; +} + + +function resolveNextTrigger() { + if (!gameState) return; + const pending = gameState.triggerStack.filter(t => !t.resolved); + if (!pending.length) return; + + const trigger = pending[0]; + + // Try auto-resolve first (gated by setting) + if (autoResolveTrigger(trigger)) { + updateUnifiedStack(); + return; + } + + const body = document.getElementById('trigger-stack-body'); + const item = body ? body.querySelector('[data-trigger-id="' + trigger.id + '"]') : null; + + // Manual resolution — apply effect for non-auto-resolve case + autoResolveTriggerEffect(trigger); + + addLogEntry('✓ Resolved: ' + trigger.cardName + ' — ' + (TRIGGER_TYPE_LABELS[trigger.type] || trigger.type) + (trigger.effectHint ? ' (' + trigger.effectHint + ')' : '')); + + if (item) { + item.classList.add('resolving'); + setTimeout(function() { + const idx = gameState.triggerStack.findIndex(t => t.id === trigger.id); + if (idx !== -1) gameState.triggerStack.splice(idx, 1); + updateTriggerBadge(); + renderTriggerStack(); + updateUnifiedStack(); + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions(); + if (!gameState.triggerStack.filter(t => !t.resolved).length) { + setTimeout(closeTriggerStack, 500); + } + }, 420); + } else { + const idx = gameState.triggerStack.findIndex(t => t.id === trigger.id); + if (idx !== -1) gameState.triggerStack.splice(idx, 1); + updateTriggerBadge(); + renderTriggerStack(); + updateUnifiedStack(); + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions(); + if (!gameState.triggerStack.filter(t => !t.resolved).length) { + setTimeout(closeTriggerStack, 500); + } + } +} + +function resolveAllTriggers() { + if (!gameState) return; + var pending = gameState.triggerStack.filter(t => !t.resolved); + if (!pending.length) return; + pending.forEach(function(t) { autoResolveTriggerEffect(t); }); + addLogEntry('✓ Resolved ' + pending.length + ' trigger' + (pending.length !== 1 ? 's' : '')); + gameState.triggerStack = []; + updateTriggerBadge(); + renderTriggerStack(); + updateUnifiedStack(); + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions(); + setTimeout(closeTriggerStack, 500); +} + +function dismissTrigger(triggerId) { + if (!gameState) return; + const idx = gameState.triggerStack.findIndex(t => t.id === triggerId); + if (idx === -1) return; + const trigger = gameState.triggerStack[idx]; + gameState.triggerStack.splice(idx, 1); + updateTriggerBadge(); + renderTriggerStack(); + updateUnifiedStack(); + addLogEntry('✕ Dismissed: ' + trigger.cardName + ''); + if (!gameState.triggerStack.filter(t => !t.resolved).length) { + setTimeout(closeTriggerStack, 500); + } +} + +// Drag-to-reorder trigger items +let dragTriggerSourceId = null; + +function setupTriggerItemDrag(item, triggerId) { + item.setAttribute('draggable', 'true'); + + item.addEventListener('dragstart', function(e) { + dragTriggerSourceId = triggerId; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(triggerId)); + setTimeout(function() { item.style.opacity = '0.45'; }, 0); + }); + + item.addEventListener('dragend', function() { + item.style.opacity = ''; + dragTriggerSourceId = null; + document.querySelectorAll('.trigger-item').forEach(function(el) { + el.style.outline = ''; + }); + }); + + item.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + document.querySelectorAll('.trigger-item').forEach(function(el) { el.style.outline = ''; }); + if (triggerId !== dragTriggerSourceId) { + item.style.outline = '2px solid var(--mtg-gold)'; + } + }); + + item.addEventListener('dragleave', function() { + item.style.outline = ''; + }); + + item.addEventListener('drop', function(e) { + e.preventDefault(); + item.style.outline = ''; + if (!gameState || dragTriggerSourceId == null || dragTriggerSourceId === triggerId) return; + + const pending = gameState.triggerStack.filter(t => !t.resolved); + const srcIdx = pending.findIndex(t => t.id === dragTriggerSourceId); + const dstIdx = pending.findIndex(t => t.id === triggerId); + if (srcIdx === -1 || dstIdx === -1) return; + + // Move srcIdx to dstIdx position + const moved = pending.splice(srcIdx, 1)[0]; + pending.splice(dstIdx, 0, moved); + gameState.triggerStack = pending; + + addLogEntry('⇅ Reordered trigger stack'); + renderTriggerStack(); + }); +} + +// ============================================================ +// SCRYFALL API +// ============================================================ + +// searchDebounceTimer declared in ui.js +let lastSearchTime = 0; +const SEARCH_DEBOUNCE_MS = 300; +const RATE_LIMIT_MS = 100; + +async function searchScryfall(query) { + if (!query || query.trim().length < 2) { + renderSearchResults([]); + setSearchStatus('Type to search cards...'); + return; + } + + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(async () => { + const now = Date.now(); + const waitMs = Math.max(0, lastSearchTime + RATE_LIMIT_MS - now); + + setTimeout(async () => { + lastSearchTime = Date.now(); + setSearchStatus(''); + showSearchSpinner(true); + + try { + const url = `${SCRYFALL_BASE}/cards/search?q=${encodeURIComponent(query)}&order=name&unique=cards`; + const resp = await fetch(url); + + if (!resp.ok) { + if (resp.status === 404) { + renderSearchResults([]); + setSearchStatus('No cards found.'); + } else { + setSearchStatus('Search error. Try again.'); + } + return; + } + + const data = await resp.json(); + renderSearchResults(data.data || []); + setSearchStatus(data.total_cards + ? `${data.total_cards} result${data.total_cards === 1 ? '' : 's'}` + (data.total_cards > data.data.length ? ` (showing ${data.data.length})` : '') + : 'No results.'); + } catch (err) { + setSearchStatus('Network error. Check connection.'); + } finally { + showSearchSpinner(false); + } + }, waitMs); + }, SEARCH_DEBOUNCE_MS); +} + +function getCardImage(card) { + if (card.image_uris && card.image_uris.normal) { + return card.image_uris.normal; + } + if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) { + return card.card_faces[0].image_uris.normal; + } + return null; +} + +function setSearchStatus(text) { + const el = document.getElementById('search-status'); + if (el) el.textContent = text; +} + +function showSearchSpinner(show) { + const el = document.getElementById('search-status'); + if (!el) return; + if (show) { + el.innerHTML = '
'; + } +} + +function renderSearchResults(cards) { + const container = document.getElementById('card-results'); + if (!container) return; + + if (!cards.length) { + container.innerHTML = '
No cards found.
'; + return; + } + + container.innerHTML = ''; + cards.slice(0, 40).forEach(card => { + const imgSrc = getCardImage(card); + if (!imgSrc) return; + + const item = document.createElement('div'); + item.className = 'search-card-item'; + item.draggable = true; + + item.innerHTML = ` + ${card.name} +
+
${card.name}
+
${card.type_line || ''}
+
+
+
+
+
📚
+
✋
+
+ `; + + // Drag start + item.addEventListener('dragstart', e => { + e.dataTransfer.setData('application/json', JSON.stringify({ + type: 'search-card', + card: { + id: card.id || card.name, + name: card.name, + imageUrl: imgSrc, + typeLine: card.type_line || '', + manaCost: card.mana_cost || '', + oracleText: card.oracle_text || '', + } + })); + e.dataTransfer.effectAllowed = 'copy'; + }); + + // Click to preview + item.addEventListener('click', e => { + if (e.target.classList.contains('search-card-add-btn')) { + addCardToBattlefield(card.name, imgSrc, card.type_line || '', gameState.currentPlayerIndex, undefined, undefined, card.oracle_text || '', { manaCost: card.mana_cost || '', power: card.power || '', toughness: card.toughness || '', pt: (card.power && card.toughness) ? card.power + '/' + card.toughness : '', cmc: card.cmc || 0, loyalty: card.loyalty || '' }); + } else if (e.target.classList.contains('search-card-lib-btn')) { + addCardToPlayerZone(card, imgSrc, 'library', 'top'); + } else if (e.target.classList.contains('search-card-hand-btn')) { + addCardToPlayerZone(card, imgSrc, 'hand'); + } else { + showCardPreviewModal(card.name, imgSrc, card.type_line, card.oracle_text, card.mana_cost); + } + }); + + // Hover to preview + item.addEventListener('mouseenter', e => { + showFloatingCardPreview(imgSrc, e, { + name: card.name, + typeLine: card.type_line || '', + oracleText: card.oracle_text || '', + manaCost: card.mana_cost || '', + power: card.power, + toughness: card.toughness, + loyalty: card.loyalty + }); + }); + item.addEventListener('mousemove', e => { + positionFloatingPreview(e); + }); + item.addEventListener('mouseleave', () => { + hideFloatingCardPreview(); + }); + + container.appendChild(item); + }); +} + +// ============================================================ +// FLOATING CARD PREVIEW +// ============================================================ + +// Calculate effective Power/Toughness for a battlefield creature +// Includes: base P/T + counters + equipment buffs + anthem effects +function getEffectivePT(bfCard) { + if (!bfCard) return null; + var basePower = 0; + var baseTough = 0; + + // Try power/toughness fields first, but only if they contain valid numbers + var hasPower = bfCard.power !== undefined && bfCard.power !== '' && bfCard.power !== null; + var hasTough = bfCard.toughness !== undefined && bfCard.toughness !== '' && bfCard.toughness !== null; + + if (hasPower) basePower = parseInt(bfCard.power, 10) || 0; + if (hasTough) baseTough = parseInt(bfCard.toughness, 10) || 0; + + // If power/toughness not set (or 0/0 which could be parse failure), try pt string + if ((!hasPower || !hasTough) && bfCard.pt) { + var ptParts = bfCard.pt.split('/'); + if (ptParts.length === 2) { + if (!hasPower) basePower = parseInt(ptParts[0], 10) || 0; + if (!hasTough) baseTough = parseInt(ptParts[1], 10) || 0; + } + } + + var bonusPower = 0; + var bonusTough = 0; + + // +1/+1 and -1/-1 counters + if (bfCard.counters) { + var plus = bfCard.counters['+1/+1'] || 0; + var minus = bfCard.counters['-1/-1'] || 0; + bonusPower += plus - minus; + bonusTough += plus - minus; + } + + // Equipment buffs: scan attached equipment oracle text for buff patterns + if (bfCard.attachments && bfCard.attachments.length > 0 && gameState) { + for (var i = 0; i < bfCard.attachments.length; i++) { + var eq = gameState.battlefieldCards.find(function(c) { return c.id === bfCard.attachments[i]; }); + if (!eq || !eq.oracleText) continue; + var buffMatches = eq.oracleText.match(/(?:gets?|has)\s+([+-]\d+)\/([+-]\d+)/gi); + if (buffMatches) { + for (var m = 0; m < buffMatches.length; m++) { + var nums = buffMatches[m].match(/([+-]\d+)\/([+-]\d+)/); + if (nums) { + bonusPower += parseInt(nums[1]) || 0; + bonusTough += parseInt(nums[2]) || 0; + } + } + } + } + } + + // Anthem effects: scan all OTHER battlefield permanents for "creatures you control get +X/+X" + if (gameState && gameState.battlefieldCards) { + for (var j = 0; j < gameState.battlefieldCards.length; j++) { + var other = gameState.battlefieldCards[j]; + if (other.id === bfCard.id) continue; + if (other.ownerIndex !== bfCard.ownerIndex) continue; + if (!other.oracleText) continue; + // Match patterns like "creatures you control get +1/+1" or "other creatures you control get +2/+2" + var anthemMatch = other.oracleText.match(/(?:other\s+)?creatures\s+you\s+control\s+get\s+([+-]\d+)\/([+-]\d+)/i); + if (anthemMatch) { + // "Other creatures" shouldn't buff itself — check if the anthem source IS the creature + var isOther = /other\s+creatures/i.test(other.oracleText); + if (!isOther || other.id !== bfCard.id) { + bonusPower += parseInt(anthemMatch[1]) || 0; + bonusTough += parseInt(anthemMatch[2]) || 0; + } + } + } + } + + return { + power: basePower + bonusPower, + toughness: baseTough + bonusTough, + bonusPower: bonusPower, + bonusTough: bonusTough + }; +} + +function showFloatingCardPreview(imgSrc, e, cardInfo) { + var preview = document.getElementById('card-preview'); + if (!preview) return; + preview.style.display = 'block'; + preview.querySelector('img').src = imgSrc; + + // Effective P/T overlay for battlefield creatures + var ptOverlay = preview.querySelector('.card-preview-pt-overlay'); + if (ptOverlay) { + var bfCard = cardInfo && cardInfo._bfCard; + if (bfCard && aiIsCreature(bfCard)) { + var ept = getEffectivePT(bfCard); + if (ept) { + var basePower = parseInt(bfCard.power) || 0; + var baseTough = parseInt(bfCard.toughness) || 0; + var boosted = ept.power !== basePower || ept.toughness !== baseTough; + ptOverlay.textContent = ept.power + ' / ' + ept.toughness; + ptOverlay.style.display = 'flex'; + ptOverlay.className = 'card-preview-pt-overlay' + (boosted ? ' boosted' : '') + + (ept.power > basePower ? ' power-up' : '') + + (ept.toughness > baseTough ? ' tough-up' : '') + + (ept.power < basePower ? ' power-down' : '') + + (ept.toughness < baseTough ? ' tough-down' : ''); + // Position at bottom-left of the card image + var imgEl = preview.querySelector('img'); + if (imgEl) { + requestAnimationFrame(function() { + ptOverlay.style.top = (imgEl.offsetHeight - ptOverlay.offsetHeight - 8) + 'px'; + }); + } + } else { + ptOverlay.style.display = 'none'; + } + } else { + ptOverlay.style.display = 'none'; + } + } + + // Populate info panel if card data provided + if (cardInfo && (cardInfo.name || cardInfo.typeLine || cardInfo.oracleText)) { + preview.classList.add('has-info'); + var nameEl = preview.querySelector('.card-preview-name'); + var costEl = preview.querySelector('.card-preview-cost'); + var typeEl = preview.querySelector('.card-preview-type'); + var oracleEl = preview.querySelector('.card-preview-oracle'); + var ptEl = preview.querySelector('.card-preview-pt'); + + if (nameEl) nameEl.textContent = cardInfo.name || ''; + if (costEl) costEl.innerHTML = cardInfo.manaCost ? renderManaCostHTML(cardInfo.manaCost) : ''; + if (typeEl) typeEl.textContent = cardInfo.typeLine || ''; + if (oracleEl) oracleEl.textContent = cardInfo.oracleText || ''; + if (ptEl) { + // Show P/T for creatures, loyalty for planeswalkers + if (cardInfo.power !== undefined && cardInfo.toughness !== undefined) { + ptEl.textContent = cardInfo.power + '/' + cardInfo.toughness; + } else if (cardInfo.pt) { + ptEl.textContent = cardInfo.pt; + } else if (cardInfo.loyalty) { + ptEl.textContent = 'Loyalty: ' + cardInfo.loyalty; + } else { + ptEl.textContent = ''; + } + } + } else { + preview.classList.remove('has-info'); + } + + positionFloatingPreview(e); +} + +function positionFloatingPreview(e) { + var preview = document.getElementById('card-preview'); + if (!preview || preview.style.display === 'none') return; + var offset = 16; + var pw = 290; // preview width + buffer + var ph = preview.offsetHeight || 420; + var x = e.clientX + offset; + var y = e.clientY + offset; + if (x + pw > window.innerWidth) x = e.clientX - pw - offset; + if (y + ph > window.innerHeight) y = Math.max(8, window.innerHeight - ph - 8); + if (x < 4) x = 4; + if (y < 4) y = 4; + preview.style.left = x + 'px'; + preview.style.top = y + 'px'; +} + +function hideFloatingCardPreview() { + var preview = document.getElementById('card-preview'); + if (preview) { + preview.style.display = 'none'; + preview.classList.remove('has-info'); + } +} + +// Render mana cost string like "{2}{W}{U}" into colored circle HTML +function renderManaCostHTML(costStr) { + if (!costStr) return ''; + var parts = costStr.match(/\{[^}]+\}/g); + if (!parts) return costStr; + return parts.map(function(p) { + var sym = p.replace(/[{}]/g, ''); + var cls = 'mana-symbol'; + if (sym === 'W') cls += ' mana-W'; + else if (sym === 'U') cls += ' mana-U'; + else if (sym === 'B') cls += ' mana-B'; + else if (sym === 'R') cls += ' mana-R'; + else if (sym === 'G') cls += ' mana-G'; + else if (sym === 'C') cls += ' mana-C'; + else if (sym === 'X') cls += ' mana-X'; + else if (/^\d+$/.test(sym)) cls += ' mana-generic'; + else if (sym.indexOf('/') !== -1) { + // Hybrid like W/U — use first color + var first = sym.split('/')[0]; + if ('WUBRGC'.indexOf(first) !== -1) cls += ' mana-' + first; + else cls += ' mana-generic'; + } + else cls += ' mana-generic'; + return '' + sym + ''; + }).join(''); +} + +// ============================================================ +// BATTLEFIELD +// ============================================================ + +let nextCardId = 1; +let draggedCardId = null; +let dragOffset = { x: 0, y: 0 }; +let isDraggingBattlefieldCard = false; + +// Determine battlefield zone for a card based on its type line +function getCardTypeZone(typeLine) { + var t = (typeLine || '').toLowerCase(); + if (/instant|sorcery/.test(t)) return 'spell'; + if (/creature/.test(t)) return 'creature'; + if (/artifact|enchantment/.test(t)) return 'artifact'; + if (/land/.test(t)) return 'land'; + if (/planeswalker/.test(t)) return 'creature'; // planeswalkers with creatures top center + return 'other'; +} + +// ============================================================ +// EQUIPMENT ATTACH/DETACH SYSTEM +// ============================================================ +var equipPickMode = false; +var equipPickSourceId = null; + +function isEquipment(card) { + if (!card || !card.typeLine) return false; + return /equipment/i.test(card.typeLine); +} + +function isAura(card) { + if (!card || !card.typeLine) return false; + return /aura/i.test(card.typeLine) && /enchantment/i.test(card.typeLine); +} + +function isAttachable(card) { + return isEquipment(card) || isAura(card); +} + +function enterEquipPickMode(equipmentId) { + equipPickMode = true; + equipPickSourceId = equipmentId; + document.body.classList.add('equip-pick-mode'); + var eqCard = gameState.battlefieldCards.find(function(c){return c.id===equipmentId}); + var eqIcon = eqCard && isAura(eqCard) ? '\u2728' : '\u2694'; + addLogEntry(eqIcon + ' Click a creature to attach ' + (eqCard || {}).name + ' to it.'); +} + +function cancelEquipPickMode() { + equipPickMode = false; + equipPickSourceId = null; + document.body.classList.remove('equip-pick-mode'); +} + +function handleEquipPickClick(creatureId) { + if (!equipPickMode || !equipPickSourceId) return; + var equipment = gameState.battlefieldCards.find(function(c) { return c.id === equipPickSourceId; }); + var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; }); + if (!equipment || !creature) { cancelEquipPickMode(); return; } + if (!aiIsCreature(creature)) { + addLogEntry('\u26A0 ' + creature.name + ' is not a creature \u2014 cannot attach.'); + cancelEquipPickMode(); + return; + } + // If equipment is already on another creature, detach first + if (equipment.attachedTo) { + detachEquipment(equipment.id, true); + } + pushUndoState(); + // Attach + equipment.attachedTo = creatureId; + if (!creature.attachments) creature.attachments = []; + if (creature.attachments.indexOf(equipment.id) === -1) { + creature.attachments.push(equipment.id); + } + var attachIcon = isAura(equipment) ? '\u2728' : '\u2694'; + addLogEntry(attachIcon + ' ' + equipment.name + ' attached to ' + creature.name + ''); + cancelEquipPickMode(); + repositionAttachments(creatureId); +} + +function detachEquipment(equipmentId, silent) { + var equipment = gameState.battlefieldCards.find(function(c) { return c.id === equipmentId; }); + if (!equipment || !equipment.attachedTo) return; + var creatureId = equipment.attachedTo; + var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; }); + if (creature && creature.attachments) { + var idx = creature.attachments.indexOf(equipmentId); + if (idx !== -1) creature.attachments.splice(idx, 1); + } + equipment.attachedTo = null; + + // Auras go to graveyard when detached (they can't exist unattached) + if (isAura(equipment)) { + var el = document.getElementById('bc-' + equipmentId); + if (el) { + el.classList.remove('attached-equipment', 'attached-aura'); + el.style.clipPath = ''; + el.style.zIndex = ''; + } + if (!silent) { + pushUndoState(); + addLogEntry('\u2728 ' + equipment.name + ' (Aura) lost its target and was moved to graveyard'); + } + moveCardToZone(equipmentId, 'graveyard'); + // Reposition remaining attachments on the creature + if (creature && creature.attachments && creature.attachments.length > 0) { + repositionAttachments(creatureId); + } + return; + } + + // Equipment: return to its type zone position + var sectionArea = document.getElementById('bf-section-area-' + equipment.ownerIndex); + var battlefield = document.getElementById('battlefield'); + var rect = sectionArea ? sectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect(); + var zone = getCardTypeZone(equipment.typeLine || ''); + var zones = { + 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.artifact; + equipment.x = z.x1 * rect.width + Math.random() * ((z.x2 - z.x1) * rect.width - 110) + 4; + equipment.y = z.y1 * rect.height + Math.random() * ((z.y2 - z.y1) * rect.height - 150) + 4; + var el = document.getElementById('bc-' + equipmentId); + if (el) { + el.style.left = equipment.x + 'px'; + el.style.top = equipment.y + 'px'; + el.classList.remove('attached-equipment', 'attached-equip'); + el.style.clipPath = ''; + el.style.zIndex = ''; + } + if (!silent) { + pushUndoState(); + addLogEntry('\u2694 ' + equipment.name + ' unequipped'); + } + // Reposition remaining attachments on the creature + if (creature && creature.attachments && creature.attachments.length > 0) { + repositionAttachments(creatureId); + } +} + +function repositionAttachments(creatureId) { + var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; }); + if (!creature || !creature.attachments) return; + var creatureEl = document.getElementById('bc-' + creatureId); + if (!creatureEl) return; + var cardH = creatureEl.offsetHeight || 140; + var cardW = creatureEl.offsetWidth || 100; + var attachOffset = 40; // how much of each attached card peeks out below + + for (var i = 0; i < creature.attachments.length; i++) { + var eqId = creature.attachments[i]; + var eq = gameState.battlefieldCards.find(function(c) { return c.id === eqId; }); + if (!eq) continue; + // Position attached card below the creature, offset for each attachment + eq.x = creature.x; + eq.y = creature.y + cardH + (i * attachOffset); + var el = document.getElementById('bc-' + eqId); + if (el) { + el.style.left = eq.x + 'px'; + el.style.top = eq.y + 'px'; + el.classList.add('attached-equipment'); + el.classList.remove('attached-aura', 'attached-equip'); + // Distinguish aura vs equipment visually + if (isAura(eq)) { + el.classList.add('attached-aura'); + } else { + el.classList.add('attached-equip'); + } + // Show only the top portion (card name area) by clipping the bottom + el.style.clipPath = 'inset(50% 0 0 0)'; + el.style.zIndex = '1'; + } + } +} + +// When a creature moves (drag), move its attachments too +function moveAttachmentsWithCreature(creatureId) { + var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; }); + if (!creature || !creature.attachments || creature.attachments.length === 0) return; + repositionAttachments(creatureId); +} + +// When a creature leaves the battlefield, detach all equipment +function detachAllFromCreature(creatureId) { + var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; }); + if (!creature || !creature.attachments) return; + var attachmentsCopy = creature.attachments.slice(); + for (var i = 0; i < attachmentsCopy.length; i++) { + detachEquipment(attachmentsCopy[i], true); + } +} + +/** + * Apply static ETB effects to a card that just entered the battlefield. + * Handles: enters tapped, enters with counters, defender, other static abilities. + */ +function applyStaticETBEffects(cardData) { + if (!cardData || !gameState) return; + var oracle = (cardData.oracleText || '').toLowerCase(); + var typeLine = (cardData.typeLine || '').toLowerCase(); + var ownerIdx = cardData.ownerIndex !== undefined ? cardData.ownerIndex : cardData.ownerId; + var player = gameState.players[ownerIdx]; + var playerName = player ? player.name : 'Unknown'; + + // ---- Enters the battlefield tapped ---- + if (/enters the battlefield tapped/i.test(oracle) || + /enters tapped/i.test(oracle) || + /this (?:land|creature|permanent|artifact) enters tapped/i.test(oracle)) { + cardData.tapped = true; + addLogEntry('\u27a1 ' + cardData.name + ' enters the battlefield tapped'); + } + + // ---- Enters with +1/+1 counters ---- + var plusMatch = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) \+1\/\+1 counter/i); + if (plusMatch) { + var numStr = plusMatch[1]; + var num = parseInt(numStr, 10); + if (isNaN(num)) { + var wordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9,ten:10,eleven:11,twelve:12,thirteen:13}; + num = wordMap[numStr.toLowerCase()] || 0; + } + if (num > 0) { + if (!cardData.counters) cardData.counters = {}; + cardData.counters['+1/+1'] = (cardData.counters['+1/+1'] || 0) + num; + addLogEntry('\u2728 ' + cardData.name + ' enters with ' + num + ' +1/+1 counter' + (num > 1 ? 's' : '')); + } + } + + // ---- Enters with -1/-1 counters ---- + var minusMatch = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) \-1\/\-1 counter/i); + if (minusMatch) { + var mNum = parseInt(minusMatch[1], 10); + if (isNaN(mNum)) { + var mWordMap = {one:1,two:2,three:3,four:4,five:5}; + mNum = mWordMap[minusMatch[1].toLowerCase()] || 0; + } + if (mNum > 0) { + if (!cardData.counters) cardData.counters = {}; + cardData.counters['-1/-1'] = (cardData.counters['-1/-1'] || 0) + mNum; + addLogEntry('\u2728 ' + cardData.name + ' enters with ' + mNum + ' -1/-1 counter' + (mNum > 1 ? 's' : '')); + } + } + + // ---- Enters with charge/lore/other named counters ---- + var namedCtrs = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) (\w+) counter/i); + if (namedCtrs && !/\+1|\-1/.test(namedCtrs[0])) { + var nCnt = parseInt(namedCtrs[1], 10); + if (isNaN(nCnt)) { + var nWordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8}; + nCnt = nWordMap[namedCtrs[1].toLowerCase()] || 0; + } + var ctrName = namedCtrs[2].toLowerCase(); + if (nCnt > 0 && ctrName !== 'loyalty') { + if (!cardData.counters) cardData.counters = {}; + cardData.counters[ctrName] = (cardData.counters[ctrName] || 0) + nCnt; + addLogEntry('\u2728 ' + cardData.name + ' enters with ' + nCnt + ' ' + ctrName + ' counter' + (nCnt > 1 ? 's' : '')); + } + } + + // ---- ETB draw a card (non-trigger static like cantrip creatures) ---- + // "When [this] enters, draw a card" is handled by triggers; this catches "enters...draw" + + // ---- ETB gain life ---- + var etbGainLife = oracle.match(/when .* enters.*gain (\d+) life/i); + // Handled by trigger system, no need to duplicate here + + // ---- Creatures with Haste should not have summoning sickness regardless ---- + // (Already handled by hasKeyword check in hasSummoningSickness) + + // ---- Creatures that enter with specific abilities ---- + // "other creatures you control have haste" is an anthem, handled by hasKeyword + + // ---- Mana-producing lands auto-detect (tap abilities parsed elsewhere) ---- +} + +function addCardToBattlefield(name, imageUrl, typeLine, ownerIndex, x, y, oracleText, extraData) { + pushUndoState("Play " + name); + // New 4-quadrant layout: each player has battle-zone-N + var pNum = (ownerIndex || 0) + 1; + const battlefield = document.getElementById('battle-zone-' + pNum) || document.getElementById('battlefield'); + if (!battlefield) return; + + var sectionArea = document.getElementById('bf-section-area-' + ownerIndex); + var rect = sectionArea ? sectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect(); + + var cardX, cardY; + if (x !== undefined && y !== undefined) { + cardX = x; + cardY = y; + } else { + // Place cards by type in designated battlefield zones + var zone = getCardTypeZone(typeLine || ''); + var cardW = 110, cardH = 150; + // Zone regions (percentage of section area) + // 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.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; + var areaH = rect.height; + // Count existing cards of same type to offset placement and avoid stacking + var sameTypeCount = 0; + if (gameState && gameState.battlefieldCards) { + sameTypeCount = gameState.battlefieldCards.filter(function(c) { + return c.ownerIndex === ownerIndex && getCardTypeZone(c.typeLine || '') === zone; + }).length; + } + var zoneW = (z.x2 - z.x1) * areaW; + var zoneH = (z.y2 - z.y1) * areaH; + var cols = Math.max(1, Math.floor(zoneW / (cardW + 8))); + var row = Math.floor(sameTypeCount / cols); + var col = sameTypeCount % cols; + cardX = z.x1 * areaW + col * (cardW + 8) + 4; + cardY = z.y1 * areaH + row * (cardH + 8) + 4; + // Wrap within zone bounds + if (cardX + cardW > z.x2 * areaW) cardX = z.x1 * areaW + 4; + if (cardY + cardH > z.y2 * areaH) cardY = z.y1 * areaH + 4; + } + + const parsedTriggers = parseTriggers(name, oracleText || ''); + + const cardData = { + id: nextCardId++, + name, + imageUrl, + typeLine, + ownerIndex, + ownerId: ownerIndex, + x: cardX, + y: cardY, + tapped: false, + faceDown: false, + oracleText: oracleText || '', + triggers: parsedTriggers, + turnPlayed: gameState ? `${gameState.turnNumber}_${gameState.currentPlayerIndex}` : '0_0', + damageMarked: 0, + damageThisTurn: 0, + ...(extraData || {}), + }; + + gameState.battlefieldCards.push(cardData); + + // Apply static ETB effects (enters tapped, enters with counters, etc.) + applyStaticETBEffects(cardData); + + // Rescan replacement effects since a new permanent entered + scanReplacementEffects(); + + renderBattlefieldCard(cardData); + animateCardEnter(cardData.id); + addLogEntry(`${gameState.players[ownerIndex].name} played ${name}`); + // Feature 8: color identity check + var _ciViolation = checkColorIdentityViolation(cardData, ownerIndex); + if (_ciViolation) { + showColorIdentityWarning(name, _ciViolation); + addLogEntry('⚠ ' + name + ' may violate color identity (' + _ciViolation.join(',') + ')'); + } + gameState.stormCount++; + updateStormCounter(); + + // Fire ETB triggers: first check all existing battlefield permanents for + // "whenever a creature/permanent enters" triggers, then the new card's own ETB. + fireBattlefieldTriggers('etb', function(bfCard) { + return bfCard.id !== cardData.id; // other permanents watching for ETBs + }); + // Also fire the new card's own ETB trigger ("When [this card] enters..."). + fireCardTriggers(cardData, ['etb']); + + // Fire nontoken_etb if the entering card is NOT a token and IS a creature + if (!cardData.isToken && cardData.typeLine && /creature/i.test(cardData.typeLine)) { + fireBattlefieldTriggers('nontoken_etb', function(bfCard) { + return bfCard.id !== cardData.id; + }); + } + + if (cardData.typeLine && /land/i.test(cardData.typeLine)) { + if (gameState.rulesEngine && gameState.rulesEngine.enforceLandLimit && gameState.landsPlayedThisTurn >= gameState.maxLandsPerTurn) { + addLogEntry('⚠ ' + cardData.name + ' — land drop limit reached this turn! (Playing anyway)'); + } + if (gameState.landsPlayedThisTurn !== undefined) gameState.landsPlayedThisTurn++; + renderPhaseTracker(); + } + + // Fire landfall triggers if this is a land + if (cardData.typeLine && /land/i.test(cardData.typeLine)) { + fireBattlefieldTriggers('landfall'); + } + + // Fire cast triggers for all permanents watching spells being cast + fireBattlefieldTriggers('cast'); + + // Planeswalker ETB: auto-set loyalty counters from starting loyalty + if (cardData.typeLine && /planeswalker/i.test(cardData.typeLine)) { + var startingLoyalty = 0; + if (cardData.loyalty) { + startingLoyalty = parseInt(cardData.loyalty, 10) || 0; + } + if (startingLoyalty > 0) { + if (!cardData.counters) cardData.counters = {}; + cardData.counters['loyalty'] = startingLoyalty; + addLogEntry('\u25c6 ' + name + ' enters with ' + startingLoyalty + ' loyalty'); + // Re-render to show loyalty counter badge + var bcEl = document.getElementById('bc-' + cardData.id); + if (bcEl) bcEl.remove(); + renderBattlefieldCard(cardData); + } + // Track loyalty ability usage per turn + cardData.loyaltyUsedThisTurn = false; + } + + // Auto-attach: Auras prompt for target creature on ETB + if (isAura(cardData)) { + // Check if there are any creatures to attach to + var ownerCreatures = gameState.battlefieldCards.filter(function(c) { + return c.id !== cardData.id && aiIsCreature(c); + }); + if (ownerCreatures.length > 0) { + enterEquipPickMode(cardData.id); + } else { + addLogEntry('\u26A0 No creatures on the battlefield to enchant with ' + cardData.name + ''); + } + } +} + +function getBattlefieldCardProtectionBadge(cardData) { + var oracle = (cardData.oracleText || '').toLowerCase(); + if (/shroud/.test(oracle)) return '
🌫
'; + if (/hexproof/.test(oracle)) return '
🛡
'; + if (/protection from/.test(oracle)) return '
🔒
'; + return ''; +} + +function renderBattlefieldCard(cardData) { + const battlefield = document.getElementById('battlefield'); + if (!battlefield) return; + + // Target the player's section area; fall back to #battlefield if sections not built yet + var sectionArea = document.getElementById('bf-section-area-' + cardData.ownerIndex); + var cardContainer = sectionArea || battlefield; + + // Remove label if cards exist + const label = battlefield.querySelector('.battlefield-label'); + if (label) label.style.opacity = '0'; + + const card = document.createElement('div'); + card.className = 'battlefield-card'; + card.id = `bc-${cardData.id}`; + card.style.left = cardData.x + 'px'; + card.style.top = cardData.y + 'px'; + card.style.borderColor = gameState.players[cardData.ownerIndex]?.color || 'transparent'; + + if (cardData.tapped) card.classList.add('tapped'); + if (cardData.faceDown) card.classList.add('face-down'); + if (hasSummoningSickness(cardData)) card.classList.add('summoning-sick'); + if (cardData.isToken) card.classList.add('token-glow'); + + // Build keyword badges + const kwBadges = getKeywordBadges(cardData); + let badgeHtml = ''; + if (kwBadges.length > 0) { + badgeHtml = '
'; + for (const b of kwBadges) { + badgeHtml += `${b.icon}`; + } + badgeHtml += '
'; + } + + // Check for activated/triggered abilities indicators + const abilities = parseCardAbilities(cardData.oracleText || '', cardData.typeLine || '', cardData.name || ''); + let abilityIndicators = ''; + if (abilities.activated.length > 0) { + abilityIndicators += `⚡${abilities.activated.length > 1 ? abilities.activated.length : ''}`; + } + if (abilities.triggered.length > 0) { + abilityIndicators += `⚙${abilities.triggered.length > 1 ? abilities.triggered.length : ''}`; + } + if (abilities.mana.length > 0) { + abilityIndicators += `◆`; + } + + // Feature 6: Protection/hexproof/shroud badge + const protectionBadge = getBattlefieldCardProtectionBadge(cardData); + + card.innerHTML = ` + ${protectionBadge} + ${cardData.name} +
+ + ${badgeHtml} + ${abilityIndicators ? '
' + abilityIndicators + '
' : ''} + `; + + // Render counter badges (generic system) + if (cardData.counters || cardData.plusCounters || cardData.minusCounters) { + // Ensure counters map exists and migrate legacy + if (!cardData.counters) cardData.counters = {}; + if (cardData.plusCounters > 0 && !cardData.counters['+1/+1']) cardData.counters['+1/+1'] = cardData.plusCounters; + if (cardData.minusCounters > 0 && !cardData.counters['-1/-1']) cardData.counters['-1/-1'] = cardData.minusCounters; + var cTypes = Object.keys(cardData.counters).filter(function(k) { return cardData.counters[k] > 0; }); + if (cTypes.length > 0) { + var strip = document.createElement('div'); + strip.className = 'card-counters-strip'; + for (var ci = 0; ci < cTypes.length; ci++) { + var cType = cTypes[ci]; + var cCount = cardData.counters[cType]; + var ct = COUNTER_TYPES[cType] || { icon: '●', color: '#9ca3af', label: cType, shortLabel: cType.substring(0, 3) }; + var badge = document.createElement('span'); + badge.className = 'card-counter-badge'; + badge.style.background = ct.color; + badge.style.color = '#fff'; + badge.title = cCount + ' ' + ct.label + ' counter' + (cCount > 1 ? 's' : ''); + if (cType === '+1/+1') badge.textContent = '+' + cCount + '/+' + cCount; + else if (cType === '-1/-1') badge.textContent = '-' + cCount + '/-' + cCount; + else badge.textContent = ct.icon + cCount; + strip.appendChild(badge); + } + card.appendChild(strip); + } + } + + // Damage badge + if (cardData.damageMarked && cardData.damageMarked > 0) { + var dmgBadge = document.createElement('div'); + dmgBadge.className = 'damage-badge'; + dmgBadge.textContent = cardData.damageMarked; + dmgBadge.title = cardData.damageMarked + ' damage marked'; + card.appendChild(dmgBadge); + } + + // Drag to move + card.addEventListener('mousedown', e => { + if (e.button !== 0) return; + // Don't allow dragging attached equipment independently + if (cardData.attachedTo) return; + e.preventDefault(); + isDraggingBattlefieldCard = true; + draggedCardId = cardData.id; + const rect = card.getBoundingClientRect(); + dragOffset.x = e.clientX - rect.left; + dragOffset.y = e.clientY - rect.top; + card.style.zIndex = '100'; + card.style.transition = 'none'; + }); + + // Right-click context menu + card.addEventListener('contextmenu', e => { + e.preventDefault(); + showContextMenu(e.clientX, e.clientY, cardData.id); + }); + + // Touch: long press for context menu + let longPressTimer = null; + card.addEventListener('touchstart', e => { + longPressTimer = setTimeout(() => { + const touch = e.touches[0]; + showContextMenu(touch.clientX, touch.clientY, cardData.id); + }, 600); + }); + card.addEventListener('touchend', () => clearTimeout(longPressTimer)); + card.addEventListener('touchmove', () => clearTimeout(longPressTimer)); + + // Double-click to tap/untap + card.addEventListener('dblclick', e => { + e.preventDefault(); + toggleTap(cardData.id); + }); + + // Single-click for combat attacker/blocker toggle + card.addEventListener('click', e => { + if (!gameState) return; + // Stack pick mode: clicking a card stacks it with the source + if (stackPickMode) { + handleStackPickClick(cardData.id); + e.stopPropagation(); + return; + } + // Equip pick mode: clicking a creature attaches the equipment + if (equipPickMode) { + handleEquipPickClick(cardData.id); + e.stopPropagation(); + return; + } + const combat = gameState.combat; + if (combat.phase === 'declaring-attackers') { + // Only the active player's untapped non-sick creatures + if (cardData.ownerId === gameState.currentPlayerIndex && !cardData.tapped && aiIsCreature(cardData)) { + toggleAttacker(cardData.id); + e.stopPropagation(); + } + } else if (combat.phase === 'declaring-blockers') { + // Defending players assign their untapped creatures as blockers + const isDefender = combat.attackers.some(a => a.targetPlayerId === cardData.ownerId); + if (isDefender && !cardData.tapped && aiIsCreature(cardData)) { + // If a pending attacker is set, assign this blocker to it + if (combat.pendingBlockerFor !== null) { + toggleBlocker(cardData.id, combat.pendingBlockerFor); + e.stopPropagation(); + } else { + // Click on an attacker card first to select it + const asAttacker = combat.attackers.find(a => a.cardId === cardData.id); + if (!asAttacker) { + // Show pulse that user needs to pick an attacker first + addLogEntry('Click an attacking creature first to assign a blocker to it.'); + } + } + } + // Clicking an attacker card to select it as target for blocking + const asAttacker = combat.attackers.find(a => a.cardId === cardData.id); + if (asAttacker) { + combat.pendingBlockerFor = cardData.id; + refreshCombatOverlay(); + e.stopPropagation(); + // Highlight selected attacker + document.querySelectorAll('.battlefield-card.attacker-selected').forEach(el => el.classList.remove('attacker-selected')); + card.classList.add('attacker-selected'); + } + } + }); + + // Hover preview + abilities panel + card.addEventListener('mouseenter', e => { + if (!isDraggingBattlefieldCard) { + // Fan out stack if this is a stack leader + if (cardData.stackGroupId && isStackLeader(cardData)) { + fanOutStack(cardData.stackGroupId); + } + showFloatingCardPreview(cardData.imageUrl, e, { + name: cardData.name, + typeLine: cardData.typeLine, + oracleText: cardData.oracleText, + manaCost: cardData.manaCost || '', + power: cardData.power, + toughness: cardData.toughness, + pt: cardData.pt, + loyalty: cardData.loyalty, + _bfCard: cardData + }); + } + }); + card.addEventListener('mousemove', e => { + if (!isDraggingBattlefieldCard) positionFloatingPreview(e); + }); + card.addEventListener('mouseleave', () => { + hideFloatingCardPreview(); + // Schedule fan collapse — delay to avoid flicker when moving between stacked cards + if (cardData.stackGroupId) { + if (stackFanCollapseTimer) clearTimeout(stackFanCollapseTimer); + stackFanCollapseTimer = setTimeout(() => { + // Check if mouse is over any card in this stack + const groupId = cardData.stackGroupId; + if (!groupId) return; + const cards = getStackCards(groupId); + const anyHovered = cards.some(c => { + const el = document.getElementById(`bc-${c.id}`); + return el && el.matches(':hover'); + }); + if (!anyHovered) collapseStackFan(false); + }, 150); + } + }); + + cardContainer.appendChild(card); + updateBattlefieldSectionCounts(); +} + +// Mouse drag for battlefield cards +document.addEventListener('mousemove', e => { + if (!isDraggingBattlefieldCard || draggedCardId === null) return; + hideFloatingCardPreview(); + + const cardData = gameState?.battlefieldCards.find(c => c.id === draggedCardId); + if (!cardData) return; + + // Use section area bounds if available, otherwise fall back to battlefield + var dragSectionArea = document.getElementById('bf-section-area-' + cardData.ownerIndex); + var dragBounds = dragSectionArea ? dragSectionArea : document.getElementById('battlefield'); + if (!dragBounds) return; + + const rect = dragBounds.getBoundingClientRect(); + let newX = e.clientX - rect.left - dragOffset.x; + let newY = e.clientY - rect.top - dragOffset.y; + + // Clamp within section area + newX = Math.max(0, Math.min(rect.width - 75, newX)); + newY = Math.max(0, Math.min(rect.height - 100, newY)); + + cardData.x = newX; + cardData.y = newY; + + const cardEl = document.getElementById(`bc-${draggedCardId}`); + if (cardEl) { + cardEl.style.left = newX + 'px'; + cardEl.style.top = newY + 'px'; + } + + // Move attached equipment/auras with the creature + if (cardData.attachments && cardData.attachments.length > 0) { + moveAttachmentsWithCreature(cardData.id); + } +}); + +document.addEventListener('mouseup', e => { + if (!isDraggingBattlefieldCard) return; + isDraggingBattlefieldCard = false; + const droppedId = draggedCardId; + const cardEl = document.getElementById(`bc-${droppedId}`); + if (cardEl) { + cardEl.style.zIndex = ''; + cardEl.style.transition = ''; + } + draggedCardId = null; + + // Shift+drop: check proximity to another card and stack them + if (e.shiftKey && droppedId && gameState) { + const droppedCard = gameState.battlefieldCards.find(c => c.id === droppedId); + if (droppedCard) { + const threshold = 40; + let closestCard = null; + let closestDist = Infinity; + gameState.battlefieldCards.forEach(other => { + if (other.id === droppedId) return; + // Skip cards that are stacked-hidden (not leaders), unless it's a stacked card in same group + if (other.stackGroupId && !isStackLeader(other)) return; + const dx = (other.x || 0) - (droppedCard.x || 0); + const dy = (other.y || 0) - (droppedCard.y || 0); + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < threshold && dist < closestDist) { + closestDist = dist; + closestCard = other; + } + }); + if (closestCard) { + createStack(closestCard.id, droppedId); + } + } + } +}); + +// ============================================================ +// BATTLEFIELD FILTERS +// ============================================================ + +let bfTypeFilter = 'all'; +let bfOwnerFilter = 'all'; // 'all' or player index + +// ============================================================ +// CARD STACKING SYSTEM +// ============================================================ + +let nextStackGroupId = 1; +let stackPickMode = false; // true when "Stack With Next Click" is active +let stackPickSourceId = null; // the card that initiated stack pick mode +let stackFanGroupId = null; // which group is currently fanned out +let stackFanCollapseTimer = null; + +function getStackCards(groupId) { + if (!gameState || !groupId) return []; + return gameState.battlefieldCards + .filter(c => c.stackGroupId === groupId) + .sort((a, b) => (a.stackOrder || 0) - (b.stackOrder || 0)); +} + +function getStackLeader(groupId) { + const cards = getStackCards(groupId); + return cards.length ? cards[cards.length - 1] : null; +} + +function isStackLeader(cardData) { + if (!cardData.stackGroupId) return false; + const leader = getStackLeader(cardData.stackGroupId); + return leader && leader.id === cardData.id; +} + +function createStack(cardId1, cardId2) { + if (!gameState) return; + pushUndoState(); + const card1 = gameState.battlefieldCards.find(c => c.id === cardId1); + const card2 = gameState.battlefieldCards.find(c => c.id === cardId2); + if (!card1 || !card2) return; + if (cardId1 === cardId2) return; + + // If either card is already in a stack, merge into that stack + if (card1.stackGroupId && card2.stackGroupId) { + // Merge card2's group into card1's group + const gid = card1.stackGroupId; + const otherCards = getStackCards(card2.stackGroupId); + const existingMax = Math.max(...getStackCards(gid).map(c => c.stackOrder || 0)); + otherCards.forEach((c, i) => { + c.stackGroupId = gid; + c.stackOrder = existingMax + 1 + i; + }); + } else if (card1.stackGroupId) { + addToStack(cardId2, card1.stackGroupId); + return; + } else if (card2.stackGroupId) { + addToStack(cardId1, card2.stackGroupId); + return; + } else { + // Both ungrouped — create new stack + const gid = nextStackGroupId++; + card1.stackGroupId = gid; + card1.stackOrder = 0; + card2.stackGroupId = gid; + card2.stackOrder = 1; + // Move card2 to card1's position + card2.x = card1.x; + card2.y = card1.y; + const el2 = document.getElementById(`bc-${cardId2}`); + if (el2) { el2.style.left = card1.x + 'px'; el2.style.top = card1.y + 'px'; } + } + + refreshStackVisuals(); + addLogEntry(`${card1.name} and ${card2.name} stacked together`); +} + +function addToStack(cardId, groupId) { + if (!gameState) return; + pushUndoState(); + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card) return; + + // If card is already in another stack, remove it first + if (card.stackGroupId && card.stackGroupId !== groupId) { + removeFromStack(cardId); + } + + const existing = getStackCards(groupId); + const leaderCard = existing.length ? existing[existing.length - 1] : null; + + card.stackGroupId = groupId; + card.stackOrder = existing.length ? (Math.max(...existing.map(c => c.stackOrder || 0)) + 1) : 0; + + // Move to leader's position + if (leaderCard) { + card.x = leaderCard.x; + card.y = leaderCard.y; + const el = document.getElementById(`bc-${cardId}`); + if (el) { el.style.left = card.x + 'px'; el.style.top = card.y + 'px'; } + } + + refreshStackVisuals(); +} + +function removeFromStack(cardId) { + if (!gameState) return; + pushUndoState(); + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card || !card.stackGroupId) return; + + const groupId = card.stackGroupId; + card.stackGroupId = null; + card.stackOrder = 0; + + // Offset the card slightly so it's visible + card.x = (card.x || 0) + 20; + card.y = (card.y || 0) + 20; + const el = document.getElementById(`bc-${cardId}`); + if (el) { + el.style.left = card.x + 'px'; + el.style.top = card.y + 'px'; + } + + // Re-number remaining stack cards + const remaining = getStackCards(groupId); + if (remaining.length === 1) { + // Only one card left — dissolve the stack + remaining[0].stackGroupId = null; + remaining[0].stackOrder = 0; + } else if (remaining.length === 0) { + // Nothing left + } + + refreshStackVisuals(); +} + +function unstackAll(groupId) { + if (!gameState) return; + pushUndoState(); + const cards = getStackCards(groupId); + const baseCard = cards[0]; + const baseX = baseCard ? (baseCard.x || 0) : 0; + const baseY = baseCard ? (baseCard.y || 0) : 0; + + cards.forEach((c, i) => { + c.stackGroupId = null; + c.stackOrder = 0; + c.x = baseX + i * 20; + c.y = baseY + i * 20; + const el = document.getElementById(`bc-${c.id}`); + if (el) { + el.style.left = c.x + 'px'; + el.style.top = c.y + 'px'; + el.classList.remove('stacked-hidden', 'stack-leader', 'stack-fanned'); + } + }); + + refreshStackVisuals(); + addLogEntry(`Stack of ${cards.length} cards dissolved`); +} + +function refreshStackVisuals() { + if (!gameState) return; + + // Collapse any active fan first + collapseStackFan(true); + + // Group all stacked cards by groupId + const groups = {}; + gameState.battlefieldCards.forEach(c => { + if (c.stackGroupId) { + if (!groups[c.stackGroupId]) groups[c.stackGroupId] = []; + groups[c.stackGroupId].push(c); + } + }); + + // First: un-mark all cards + gameState.battlefieldCards.forEach(c => { + const el = document.getElementById(`bc-${c.id}`); + if (!el) return; + el.classList.remove('stacked-hidden', 'stack-leader'); + // Remove any existing stack badge + const badge = el.querySelector('.stack-badge'); + if (badge) badge.remove(); + }); + + // Then apply stack visuals per group + Object.entries(groups).forEach(([gid, cards]) => { + if (cards.length < 2) { + // Degenerate single-card stack — clean up + cards.forEach(c => { c.stackGroupId = null; c.stackOrder = 0; }); + return; + } + const sorted = cards.sort((a, b) => (a.stackOrder || 0) - (b.stackOrder || 0)); + const leader = sorted[sorted.length - 1]; + + sorted.forEach(c => { + const el = document.getElementById(`bc-${c.id}`); + if (!el) return; + if (c.id === leader.id) { + el.classList.add('stack-leader'); + // Add count badge + const badge = document.createElement('span'); + badge.className = 'stack-badge'; + badge.textContent = `\u00d7${sorted.length}`; + el.appendChild(badge); + } else { + el.classList.add('stacked-hidden'); + } + }); + }); +} + +function collapseStackFan(immediate) { + if (stackFanCollapseTimer) clearTimeout(stackFanCollapseTimer); + stackFanCollapseTimer = null; + + if (!stackFanGroupId) return; + const groupId = stackFanGroupId; + stackFanGroupId = null; + + const cards = getStackCards(groupId); + const leader = cards.length ? cards[cards.length - 1] : null; + if (!leader) return; + + cards.forEach(c => { + const el = document.getElementById(`bc-${c.id}`); + if (!el) return; + el.classList.remove('stack-fanned'); + if (c.id !== leader.id) { + if (!immediate) { + // Animate back then hide + el.style.left = leader.x + 'px'; + el.style.top = leader.y + 'px'; + setTimeout(() => { + if (!el.classList.contains('stack-fanned')) { + el.classList.add('stacked-hidden'); + } + }, 220); + } else { + el.classList.add('stacked-hidden'); + el.style.left = leader.x + 'px'; + el.style.top = leader.y + 'px'; + } + } + }); +} + +function fanOutStack(groupId) { + if (!gameState) return; + if (stackFanGroupId === groupId) return; // already fanned + + // Collapse previous fan if any + if (stackFanGroupId && stackFanGroupId !== groupId) { + collapseStackFan(true); + } + + stackFanGroupId = groupId; + const cards = getStackCards(groupId); + const leader = cards.length ? cards[cards.length - 1] : null; + if (!leader) return; + + const baseX = leader.x || 0; + const baseY = leader.y || 0; + + cards.forEach((c, i) => { + const el = document.getElementById(`bc-${c.id}`); + if (!el) return; + el.classList.remove('stacked-hidden'); + el.classList.add('stack-fanned'); + const fanX = baseX + i * 18; + const fanY = baseY + i * 5; + el.style.left = fanX + 'px'; + el.style.top = fanY + 'px'; + // Update z-index so first cards are under later ones + el.style.zIndex = String(50 + i); + }); +} + +// Stack pick mode: activated via context menu +function enterStackPickMode(sourceCardId) { + stackPickMode = true; + stackPickSourceId = sourceCardId; + document.body.classList.add('stack-pick-mode'); + addLogEntry('Click any other card to stack with it.'); +} + +function cancelStackPickMode() { + stackPickMode = false; + stackPickSourceId = null; + document.body.classList.remove('stack-pick-mode'); +} + +// Handle stacking when a card is clicked in pick mode +function handleStackPickClick(targetCardId) { + if (!stackPickMode) return false; + if (targetCardId === stackPickSourceId) { + cancelStackPickMode(); + return true; + } + const sourceId = stackPickSourceId; + cancelStackPickMode(); + createStack(sourceId, targetCardId); + return true; +} + +function setBattlefieldTypeFilter(filter) { + bfTypeFilter = filter; + document.querySelectorAll('.bf-type-filters .bf-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.bfFilter === filter); + }); + applyBattlefieldFilter(); +} + +function setBattlefieldOwnerFilter(ownerIdx) { + bfOwnerFilter = ownerIdx; + document.querySelectorAll('.bf-owner-filters .bf-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.bfOwner === String(ownerIdx)); + }); + applyBattlefieldFilter(); +} + +function buildOwnerFilterButtons() { + const container = document.getElementById('bf-owner-filters'); + if (!container || !gameState) return; + + let html = ''; + gameState.players.forEach((p, idx) => { + const short = p.name.length > 6 ? p.name.substring(0, 6) : p.name; + html += ``; + }); + container.innerHTML = html; +} + +function buildBattlefieldSections() { + var container = document.getElementById('bf-sections'); + if (!container || !gameState) return; + container.innerHTML = ''; + // Order: other players top-to-bottom (furthest first), then Player 1 at very bottom + // For 4 players: [3, 2, 1, 0] — P4 top, P3 next, P2 next, P1 bottom + var order = []; + for (var i = gameState.players.length - 1; i >= 1; i--) { order.push(i); } + order.push(0); // Player 1 always last (bottom) + order.forEach(function(idx) { + var p = gameState.players[idx]; + var section = document.createElement('div'); + section.className = 'bf-player-section'; + section.id = 'bf-section-' + idx; + section.style.borderColor = p.color; + + var header = document.createElement('div'); + header.className = 'bf-section-header'; + header.innerHTML = '' + + '' + p.name + '' + + '0'; + section.appendChild(header); + + var area = document.createElement('div'); + area.className = 'bf-section-area'; + area.id = 'bf-section-area-' + idx; + section.appendChild(area); + + container.appendChild(section); + }); + // Re-render zone labels if active + if (bfZonesVisible) { + setTimeout(function() { renderBattlefieldZoneLabels(); }, 50); + } +} + +function updateBattlefieldSectionCounts() { + if (!gameState) return; + gameState.players.forEach(function(p, idx) { + var countEl = document.getElementById('bf-section-count-' + idx); + if (!countEl) return; + var count = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === idx; }).length; + countEl.textContent = count; + }); +} + +function cardMatchesTypeFilter(cardData, filter) { + if (filter === 'all') return true; + const tl = (cardData.typeLine || '').toLowerCase(); + + switch (filter) { + case 'creature': return tl.includes('creature'); + case 'artifact': return tl.includes('artifact') && !tl.includes('creature'); + case 'enchantment': return tl.includes('enchantment') && !tl.includes('creature'); + case 'land': return tl.includes('land'); + case 'planeswalker': return tl.includes('planeswalker'); + case 'instant_sorcery': return tl.includes('instant') || tl.includes('sorcery'); + case 'token': return !!cardData.isToken; + default: return true; + } +} + +function applyBattlefieldFilter() { + if (!gameState) return; + let shown = 0; + let total = gameState.battlefieldCards.length; + + gameState.battlefieldCards.forEach(cardData => { + const el = document.getElementById(`bc-${cardData.id}`); + if (!el) return; + + const matchesType = cardMatchesTypeFilter(cardData, bfTypeFilter); + const matchesOwner = bfOwnerFilter === 'all' || cardData.ownerIndex === bfOwnerFilter; + const visible = matchesType && matchesOwner; + + // Don't toggle bf-dimmed on stacked-hidden cards (they stay display:none) + if (!el.classList.contains('stacked-hidden')) { + el.classList.toggle('bf-dimmed', !visible); + } + if (visible) shown++; + }); + + // Update count + const countEl = document.getElementById('bf-filter-count'); + if (countEl) { + if (bfTypeFilter === 'all' && bfOwnerFilter === 'all') { + countEl.textContent = total > 0 ? `${total}` : ''; + } else { + countEl.textContent = `${shown}/${total}`; + } + } +} + +// ============================================================ +// AUTO-ARRANGE BATTLEFIELD +// ============================================================ + +function autoArrangeBattlefield() { + if (!gameState || gameState.battlefieldCards.length === 0) return; + pushUndoState('Auto-arrange battlefield'); + + const CARD_W = 100; + const CARD_H = 140; + const GAP_X = 6; + const GAP_Y = 6; + const PAD_LEFT = 10; + const PAD_TOP = 4; // offset from section area top (header already accounts for 22px) + const PAD_BOTTOM = 10; + + // Group cards by owner + var playerCount = gameState.players.length; + for (var pi = 0; pi < playerCount; pi++) { + var sectionArea = document.getElementById('bf-section-area-' + pi); + if (!sectionArea) continue; + + var rect = sectionArea.getBoundingClientRect(); + var bfW = rect.width; + var bfH = rect.height; + + // Categorize this player's cards by type + var categories = [ + { key: 'land', cards: [] }, + { key: 'creature', cards: [] }, + { key: 'artifact', cards: [] }, + { key: 'enchantment', cards: [] }, + { key: 'planeswalker',cards: [] }, + { key: 'other', cards: [] }, + ]; + + var catMap = {}; + categories.forEach(function(c) { catMap[c.key] = c; }); + + gameState.battlefieldCards.forEach(function(card) { + if (card.ownerIndex !== pi) return; + // Skip stacked non-leaders (they follow their leader) + if (card.stackGroupId && !isStackLeader(card)) return; + + var tl = (card.typeLine || '').toLowerCase(); + if (tl.includes('land')) catMap['land'].cards.push(card); + else if (tl.includes('creature')) catMap['creature'].cards.push(card); + else if (tl.includes('planeswalker')) catMap['planeswalker'].cards.push(card); + else if (tl.includes('artifact')) catMap['artifact'].cards.push(card); + else if (tl.includes('enchantment')) catMap['enchantment'].cards.push(card); + else catMap['other'].cards.push(card); + }); + + // Sort within each category by name + categories.forEach(function(cat) { + cat.cards.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); + }); + + // Filter out empty categories + var activeCats = categories.filter(function(c) { return c.cards.length > 0; }); + if (activeCats.length === 0) continue; + + // Reverse so lands end up at the bottom + activeCats.reverse(); + + var availH = bfH - PAD_TOP - PAD_BOTTOM; + var rowH = CARD_H + GAP_Y; + var availW = bfW - PAD_LEFT * 2; + var cardsPerRow = Math.max(1, Math.floor((availW + GAP_X) / (CARD_W + GAP_X))); + + var totalRows = 0; + activeCats.forEach(function(cat) { + totalRows += Math.ceil(cat.cards.length / cardsPerRow); + }); + + var effectiveRowH = rowH; + var neededH = totalRows * rowH; + if (neededH > availH && totalRows > 0) { + effectiveRowH = Math.max(30, availH / totalRows); + } + + // Place cards bottom-up within this section + var cursorY = bfH - PAD_BOTTOM - CARD_H; + + activeCats.forEach(function(cat) { + var rows = Math.ceil(cat.cards.length / cardsPerRow); + for (var r = rows - 1; r >= 0; r--) { + var rowStart = r * cardsPerRow; + var rowEnd = Math.min(rowStart + cardsPerRow, cat.cards.length); + var rowCount = rowEnd - rowStart; + + for (var i = 0; i < rowCount; i++) { + var card = cat.cards[rowStart + i]; + var newX = PAD_LEFT + i * (CARD_W + GAP_X); + var newY = Math.max(PAD_TOP, cursorY); + + card.x = newX; + card.y = newY; + + var el = document.getElementById('bc-' + card.id); + if (el) { + el.style.transition = 'left 0.4s cubic-bezier(0.16,1,0.3,1), top 0.4s cubic-bezier(0.16,1,0.3,1)'; + el.style.left = newX + 'px'; + el.style.top = newY + 'px'; + // Clear transition after animation + setTimeout(function(e) { return function() { e.style.transition = ''; }; }(el), 450); + } + + // Move stacked non-leaders to the same position + if (card.stackGroupId) { + var stackCards = getStackCards(card.stackGroupId); + stackCards.forEach(function(sc) { + if (sc.id !== card.id) { + sc.x = newX; + sc.y = newY; + } + }); + } + } + cursorY -= effectiveRowH; + } + // Small gap between categories + cursorY -= 4; + }); + } + + addLogEntry('🔧 Battlefield auto-arranged'); +} + +// Battlefield drag-and-drop from search sidebar +function setupBattlefieldDrop() { + const battlefield = document.getElementById('battlefield'); + if (!battlefield) return; + + battlefield.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + battlefield.classList.add('drag-over'); + }); + + battlefield.addEventListener('dragleave', e => { + if (!battlefield.contains(e.relatedTarget)) { + battlefield.classList.remove('drag-over'); + } + }); + + battlefield.addEventListener('drop', e => { + e.preventDefault(); + battlefield.classList.remove('drag-over'); + + try { + const raw = e.dataTransfer.getData('application/json'); + if (!raw) return; + const data = JSON.parse(raw); + + if (data.type === 'search-card') { + var dropOwnerIdx = gameState.currentPlayerIndex; + var dropSectionArea = document.getElementById('bf-section-area-' + dropOwnerIdx); + var dropRect = dropSectionArea ? dropSectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect(); + const x = e.clientX - dropRect.left - 35; + const y = e.clientY - dropRect.top - 49; + addCardToBattlefield( + data.card.name, + data.card.imageUrl, + data.card.typeLine, + gameState.currentPlayerIndex, + Math.max(0, x), + Math.max(0, y), + data.card.oracleText || '', + { manaCost: data.card.manaCost || '' } + ); + } + } catch(err) { /* ignore */ } + }); +} + +// ============================================================ +// CONTEXT MENU +// ============================================================ + +// contextMenuTargetId declared in ui.js + +function showContextMenu(x, y, cardId) { + const menu = document.getElementById('context-menu'); + if (!menu) return; + contextMenuTargetId = cardId; + + // Dynamically inject stack section based on card state + const stackSection = menu.querySelector('.ctx-stack-section'); + if (stackSection && gameState) { + const card = gameState.battlefieldCards.find(c => c.id === cardId); + const isStacked = card && card.stackGroupId; + stackSection.innerHTML = ` +
+ ${!isStacked + ? `` + : `` + } + ${isStacked + ? `` + : '' + } + ${isStacked + ? `` + : '' + } + `; + } + + // Dynamically inject equip section based on card type + var equipSection = menu.querySelector('.ctx-equip-section'); + if (equipSection && gameState) { + var eCard = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + var equipHtml = ''; + if (eCard && isAttachable(eCard)) { + var isAuraCard = isAura(eCard); + var actionWord = isAuraCard ? 'Enchant' : 'Equip'; + var detachWord = isAuraCard ? 'Remove Aura' : 'Unequip'; + var icon = isAuraCard ? '\u2728' : '\u2694'; + equipHtml += '
'; + if (eCard.attachedTo) { + var attachedCreature = gameState.battlefieldCards.find(function(c) { return c.id === eCard.attachedTo; }); + equipHtml += ''; + equipHtml += ''; + } else { + equipHtml += ''; + } + } + // If card is a creature with attachments, show list + if (eCard && eCard.attachments && eCard.attachments.length > 0) { + equipHtml += '
'; + for (var ai = 0; ai < eCard.attachments.length; ai++) { + var att = gameState.battlefieldCards.find(function(c) { return c.id === eCard.attachments[ai]; }); + if (att) { + var attIcon = isAura(att) ? '\u2728' : '\u2694'; + var attLabel = isAura(att) ? 'Remove Aura ' : 'Unequip '; + equipHtml += ''; + } + } + } + equipSection.innerHTML = equipHtml; + } + + // For tokens: replace zone buttons with "Destroy (cease to exist)" since tokens + // cannot go to graveyard, hand, exile, or library per MTG rules + if (gameState) { + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (card && card.isToken) { + const gyBtn = menu.querySelector('[data-action="graveyard"]'); + const exBtn = menu.querySelector('[data-action="exile"]'); + const handBtn = menu.querySelector('[data-action="hand"]'); + const libTopBtn = menu.querySelector('[data-action="library-top"]'); + const libBotBtn = menu.querySelector('[data-action="library-bottom"]'); + if (gyBtn) gyBtn.innerHTML = '☠ Destroy (dies)'; + if (exBtn) exBtn.innerHTML = '⬡ Exile (cease to exist)'; + if (handBtn) handBtn.innerHTML = '✋ Bounce (cease to exist)'; + if (libTopBtn) libTopBtn.style.display = 'none'; + if (libBotBtn) libBotBtn.style.display = 'none'; + + // Token batch operations section + var existingTokenOps = menu.querySelector('.ctx-token-ops'); + if (existingTokenOps) existingTokenOps.remove(); + // Also remove non-token sections if present + var existingCopySect = menu.querySelector('.ctx-token-copy-section'); + if (existingCopySect) existingCopySect.remove(); + var existingSmartSect = menu.querySelector('.ctx-smart-token-section'); + if (existingSmartSect) existingSmartSect.remove(); + var existingPwSect = menu.querySelector('.ctx-pw-section'); + if (existingPwSect) existingPwSect.remove(); + var tokenOpsDiv = document.createElement('div'); + tokenOpsDiv.className = 'ctx-token-ops context-menu-section'; + var tokenDispName = card.name.replace(/ Token$/, ''); + var ownerIdxForToken = card.ownerIndex; + tokenOpsDiv.innerHTML = + '
' + + '
Token Actions
' + + '' + + '' + + ''; + menu.appendChild(tokenOpsDiv); + } else { + // Remove token ops if present + var existingTokenOps2 = menu.querySelector('.ctx-token-ops'); + if (existingTokenOps2) existingTokenOps2.remove(); + // Restore defaults for non-tokens (since menu is reused) + const gyBtn = menu.querySelector('[data-action="graveyard"]'); + const exBtn = menu.querySelector('[data-action="exile"]'); + const handBtn = menu.querySelector('[data-action="hand"]'); + const libTopBtn = menu.querySelector('[data-action="library-top"]'); + const libBotBtn = menu.querySelector('[data-action="library-bottom"]'); + if (gyBtn) gyBtn.innerHTML = '☠ Send to Graveyard'; + if (exBtn) exBtn.innerHTML = '⬡ Send to Exile'; + if (handBtn) handBtn.innerHTML = '✋ Return to Hand'; + if (libTopBtn) libTopBtn.style.display = ''; + if (libBotBtn) libBotBtn.style.display = ''; + + // Non-token creature: add "Create Token Copy" for creatures + var existingCopySection = menu.querySelector('.ctx-token-copy-section'); + if (existingCopySection) existingCopySection.remove(); + var existingSmartToken = menu.querySelector('.ctx-smart-token-section'); + if (existingSmartToken) existingSmartToken.remove(); + + var ctCard = gameState && gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (ctCard) { + // Planeswalker: Activate Loyalty Ability button + if (ctCard.typeLine && /planeswalker/i.test(ctCard.typeLine)) { + var existingPwSection = menu.querySelector('.ctx-pw-section'); + if (existingPwSection) existingPwSection.remove(); + var pwSection = document.createElement('div'); + pwSection.className = 'ctx-pw-section context-menu-section'; + var pwLoyalty = (ctCard.counters && ctCard.counters['loyalty']) || 0; + var usedLabel = ctCard.loyaltyUsedThisTurn ? ' (used this turn)' : ''; + var usedClass = ctCard.loyaltyUsedThisTurn ? ' context-menu-item-disabled' : ''; + pwSection.innerHTML = + '
' + + '
Planeswalker (\u25c6' + pwLoyalty + ')' + usedLabel + '
' + + ''; + menu.appendChild(pwSection); + } else { + var existingPwSection2 = menu.querySelector('.ctx-pw-section'); + if (existingPwSection2) existingPwSection2.remove(); + } + + // Token copy button for creatures + if (ctCard.typeLine && /creature/i.test(ctCard.typeLine)) { + var copySection = document.createElement('div'); + copySection.className = 'ctx-token-copy-section context-menu-section'; + copySection.innerHTML = + '
' + + ''; + menu.appendChild(copySection); + } + + // Smart token suggestions from oracle text + var smartTokens = parseTokenCreationAbilities(ctCard.oracleText || ''); + if (smartTokens.length > 0) { + var smartSection = document.createElement('div'); + smartSection.className = 'ctx-smart-token-section context-menu-section'; + var smartHtml = '
Create Tokens
'; + smartTokens.forEach(function(t) { + var ownerIdx2 = ctCard.ownerIndex; + var safeN = t.name.replace(/'/g, "\\'").replace(/"/g, '"'); + var safeTl = t.typeLine.replace(/'/g, "\\'").replace(/"/g, '"').replace(/\u2014/g, '-'); + var safeAb = (t.abilities || '').replace(/'/g, "\\'").replace(/"/g, '"'); + smartHtml += ''; + }); + smartSection.innerHTML = smartHtml; + menu.appendChild(smartSection); + } + } + } + } + + // Feature 6: Show targeting restriction badge in context menu header + if (gameState) { + var tCard = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + var existingTWarn = menu.querySelector('.ctx-targeting-warn'); + if (existingTWarn) existingTWarn.remove(); + if (tCard) { + var protKws = getCardProtectionKeywords(tCard); + if (protKws.length > 0) { + var activeOwnerId = gameState.currentPlayerIndex; + var tResult = canTarget(activeOwnerId, cardId); + var warnDiv = document.createElement('div'); + warnDiv.className = 'ctx-targeting-warn'; + if (!tResult.allowed) { + warnDiv.innerHTML = '🚫 Targeting restricted: ' + protKws.join(', '); + warnDiv.classList.add('ctx-targeting-warn-blocked'); + } else { + warnDiv.innerHTML = 'ℹ Keywords: ' + protKws.join(', '); + } + menu.insertBefore(warnDiv, menu.firstChild); + } + } + } + + // Dim the Tap/Untap button when creature has summoning sickness + if (gameState && gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure) { + var sickCard = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + var tapBtn = menu.querySelector('[data-action="tap"]'); + if (tapBtn && sickCard && !sickCard.tapped && hasSummoningSickness(sickCard)) { + tapBtn.innerHTML = '\u26a0 Tap (Summoning Sickness)'; + tapBtn.style.opacity = '0.45'; + tapBtn.style.color = '#b388ff'; + } else if (tapBtn) { + tapBtn.innerHTML = '\u27f3 Tap / Untap'; + tapBtn.style.opacity = ''; + tapBtn.style.color = ''; + } + } + + // ---- Activated Abilities & Tap for Mana section ---- + if (gameState) { + var abCard = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (abCard) { + var forgeSection = document.createElement('div'); + forgeSection.className = 'ctx-forge-section context-menu-section'; + var forgeHtml = '
'; + forgeHtml += '
Abilities & Mana
'; + + // Check if card is a land for "Tap for Mana" + var abTypeLine = (abCard.typeLine || '').toLowerCase(); + if (abTypeLine.indexOf('land') !== -1) { + var tapDisabled = abCard.tapped ? ' context-menu-item-disabled' : ''; + forgeHtml += ''; + } + + // Check if card has activated abilities + var parsedAbs = parseActivatedAbilities(abCard); + if (parsedAbs && parsedAbs.length > 0) { + forgeHtml += ''; + } + + // Add mana manually button (for any card that might add mana) + var oText = (abCard.oracleText || '').toLowerCase(); + if (oText.indexOf('add') !== -1 && /\{[wubrgc]\}/i.test(oText) && abTypeLine.indexOf('land') === -1) { + var mTapDisabled = abCard.tapped ? ' context-menu-item-disabled' : ''; + forgeHtml += ''; + } + + // Show damage marked on creature + if (abCard.damageMarked && abCard.damageMarked > 0) { + forgeHtml += '
\uD83D\uDCA5 Damage marked: ' + abCard.damageMarked + '
'; + } + + forgeHtml += ''; + forgeSection.innerHTML = forgeHtml; + menu.appendChild(forgeSection); + } + } + + menu.style.left = Math.min(x, window.innerWidth - 220) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - 350) + 'px'; + menu.classList.add('visible'); +} + +function hideContextMenu() { + const menu = document.getElementById('context-menu'); + if (menu) menu.classList.remove('visible'); + contextMenuTargetId = null; +} + +function toggleTap(cardId) { + pushUndoState(); + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + if (!cardData) return; + // Block tapping creatures with summoning sickness (allow untapping) + if (!cardData.tapped && hasSummoningSickness(cardData) && gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure) { + addLogEntry('\u26a0 ' + cardData.name + ' has summoning sickness and cannot be tapped.'); + return; + } + cardData.tapped = !cardData.tapped; + const cardEl = document.getElementById(`bc-${cardId}`); + if (cardEl) { + cardEl.classList.toggle('tapped', cardData.tapped); + } + addLogEntry(`${cardData.name} ${cardData.tapped ? 'tapped' : 'untapped'}`); + + // Auto-tally mana when tapping a mana-producing permanent + if (cardData.tapped) { + handleManaOnTap(cardData); + } +} + +function toggleFaceDown(cardId) { + pushUndoState(); + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + if (!cardData) return; + cardData.faceDown = !cardData.faceDown; + const cardEl = document.getElementById(`bc-${cardId}`); + if (cardEl) { + cardEl.classList.toggle('face-down', cardData.faceDown); + } + addLogEntry(`A card was turned face ${cardData.faceDown ? 'down' : 'up'}`); +} + +// ============================================================ +// +1/+1 AND -1/-1 COUNTERS +// ============================================================ + +// ---- Generic Counter System ---- + +/** Add a counter of any type to a battlefield card */ +function addCounter(cardId, counterType, amount) { + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + if (!cardData) return; + if (!cardData.counters) cardData.counters = {}; + if (!cardData.counters[counterType]) cardData.counters[counterType] = 0; + cardData.counters[counterType] += (amount || 1); + // Sync legacy fields + if (counterType === '+1/+1') cardData.plusCounters = cardData.counters['+1/+1']; + if (counterType === '-1/-1') cardData.minusCounters = cardData.counters['-1/-1']; + updateCardCounterBadge(cardId); + const ct = COUNTER_TYPES[counterType] || { icon: '●', color: '#9ca3af', label: counterType }; + addLogEntry('' + cardData.name + ' got a ' + ct.label + ' counter (' + cardData.counters[counterType] + ' total)'); +} + +/** Remove a counter of any type from a battlefield card */ +function removeCounter(cardId, counterType, amount) { + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + if (!cardData || !cardData.counters || !cardData.counters[counterType] || cardData.counters[counterType] <= 0) return; + cardData.counters[counterType] = Math.max(0, cardData.counters[counterType] - (amount || 1)); + if (cardData.counters[counterType] === 0) delete cardData.counters[counterType]; + // Sync legacy fields + if (counterType === '+1/+1') cardData.plusCounters = cardData.counters['+1/+1'] || 0; + if (counterType === '-1/-1') cardData.minusCounters = cardData.counters['-1/-1'] || 0; + updateCardCounterBadge(cardId); + const ct = COUNTER_TYPES[counterType] || { icon: '●', color: '#9ca3af', label: counterType }; + const remaining = (cardData.counters && cardData.counters[counterType]) || 0; + addLogEntry('' + cardData.name + ' lost a ' + ct.label + ' counter (' + remaining + ' remaining)'); +} + +/** Legacy wrappers for backward compatibility */ +function addPlusCounter(cardId) { addCounter(cardId, '+1/+1'); } +function removePlusCounter(cardId) { removeCounter(cardId, '+1/+1'); } +function addMinusCounter(cardId) { addCounter(cardId, '-1/-1'); } +function removeMinusCounter(cardId) { removeCounter(cardId, '-1/-1'); } + +/** Show the counter picker popover for a card */ +function showCounterPicker(cardId) { + hideContextMenu(); + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + const cardEl = document.getElementById('bc-' + cardId); + if (!cardData || !cardEl) return; + + // Remove any existing picker + var old = document.getElementById('counter-picker'); + if (old) old.remove(); + + var picker = document.createElement('div'); + picker.id = 'counter-picker'; + picker.className = 'counter-picker'; + + // Position near the card + var rect = cardEl.getBoundingClientRect(); + picker.style.left = Math.min(rect.right + 8, window.innerWidth - 260) + 'px'; + picker.style.top = Math.max(rect.top, 8) + 'px'; + + // Header + var header = '
'; + header += 'Counters: ' + cardData.name + ''; + header += ''; + header += '
'; + + // Current counters on this card + var currentHtml = ''; + if (cardData.counters && Object.keys(cardData.counters).length > 0) { + currentHtml = '
'; + for (var type in cardData.counters) { + if (cardData.counters[type] <= 0) continue; + var ct = COUNTER_TYPES[type] || { icon: '●', color: '#9ca3af', label: type, shortLabel: type }; + currentHtml += '
'; + currentHtml += '' + ct.icon + ' ' + ct.label + ''; + currentHtml += '
'; + currentHtml += ''; + currentHtml += '' + cardData.counters[type] + ''; + currentHtml += ''; + currentHtml += '
'; + currentHtml += '
'; + } + currentHtml += '
'; + } + + // Quick-add grid of common counters + var gridHtml = '
'; + var commonTypes = ['+1/+1', '-1/-1', 'loyalty', 'charge', 'shield', 'time', 'lore', 'ki', 'quest', 'energy', 'experience', 'bounty']; + for (var i = 0; i < commonTypes.length; i++) { + var t = commonTypes[i]; + var ct = COUNTER_TYPES[t]; + var has = (cardData.counters && cardData.counters[t]) ? ' has' : ''; + gridHtml += ''; + } + gridHtml += '
'; + + // Custom counter input + var customHtml = '
'; + customHtml += ''; + customHtml += ''; + customHtml += '
'; + + picker.innerHTML = header + currentHtml + '
Add Counter
' + gridHtml + customHtml; + + document.body.appendChild(picker); + + // Close on outside click (delay to avoid immediate close) + setTimeout(function() { + document.addEventListener('mousedown', counterPickerOutsideClick); + }, 50); +} + +function counterPickerOutsideClick(e) { + var picker = document.getElementById('counter-picker'); + if (picker && !picker.contains(e.target)) { + closeCounterPicker(); + } +} + +function closeCounterPicker() { + var picker = document.getElementById('counter-picker'); + if (picker) picker.remove(); + document.removeEventListener('mousedown', counterPickerOutsideClick); +} + +function refreshCounterPicker(cardId) { + closeCounterPicker(); + showCounterPicker(cardId); +} + +function updateCardCounterBadge(cardId) { + const cardData = gameState.battlefieldCards.find(c => c.id === cardId); + const cardEl = document.getElementById('bc-' + cardId); + if (!cardData || !cardEl) return; + + // Remove existing counter badge container + const existing = cardEl.querySelector('.card-counters-strip'); + if (existing) existing.remove(); + + // Migrate legacy plusCounters/minusCounters into counters map if needed + if (!cardData.counters) cardData.counters = {}; + if (cardData.plusCounters > 0 && !cardData.counters['+1/+1']) cardData.counters['+1/+1'] = cardData.plusCounters; + if (cardData.minusCounters > 0 && !cardData.counters['-1/-1']) cardData.counters['-1/-1'] = cardData.minusCounters; + + var types = Object.keys(cardData.counters).filter(function(k) { return cardData.counters[k] > 0; }); + if (types.length === 0) return; + + var strip = document.createElement('div'); + strip.className = 'card-counters-strip'; + + for (var i = 0; i < types.length; i++) { + var type = types[i]; + var count = cardData.counters[type]; + var ct = COUNTER_TYPES[type] || { icon: '●', color: '#9ca3af', label: type, shortLabel: type.substring(0, 3) }; + var badge = document.createElement('span'); + badge.className = 'card-counter-badge'; + badge.style.background = ct.color; + badge.style.color = '#fff'; + badge.title = count + ' ' + ct.label + ' counter' + (count > 1 ? 's' : ''); + if (type === '+1/+1') { + badge.textContent = '+' + count + '/+' + count; + } else if (type === '-1/-1') { + badge.textContent = '-' + count + '/-' + count; + } else { + badge.textContent = ct.icon + count; + } + strip.appendChild(badge); + } + cardEl.appendChild(strip); +} + +// Remove a card from its stack (if any) before it leaves the battlefield +function cleanupCardFromStack(cardId) { + if (!gameState) return; + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card || !card.stackGroupId) return; + const groupId = card.stackGroupId; + card.stackGroupId = null; + card.stackOrder = 0; + // Clean up remaining stack + const remaining = getStackCards(groupId); + if (remaining.length === 1) { + remaining[0].stackGroupId = null; + remaining[0].stackOrder = 0; + } + // Refresh visuals for remaining stack members + if (remaining.length >= 2) { + // Reassign orders cleanly + remaining.forEach((c, i) => { c.stackOrder = i; }); + refreshStackVisuals(); + } else { + // Clean up the last card's visuals + if (remaining.length === 1) { + const el = document.getElementById(`bc-${remaining[0].id}`); + if (el) { + el.classList.remove('stacked-hidden', 'stack-leader', 'stack-fanned'); + const badge = el.querySelector('.stack-badge'); + if (badge) badge.remove(); + } + } + } +} + +function moveCardToZone(cardId, zone) { + pushUndoState("Move card to " + zone); + const idx = gameState.battlefieldCards.findIndex(c => c.id === cardId); + if (idx === -1) return; + const cardData = gameState.battlefieldCards[idx]; + const owner = gameState.players[cardData.ownerIndex]; + if (!owner) return; + + // Apply replacement effects for death/graveyard + if (zone === 'graveyard') { + var dieEvent = applyReplacements('die', { + cardId: cardId, + cardName: cardData.name, + ownerIndex: cardData.ownerIndex, + isToken: !!cardData.isToken, + destination: 'graveyard' + }); + if (dieEvent && dieEvent.destination && dieEvent.destination !== 'graveyard') { + zone = dieEvent.destination; // Redirect (e.g., exile instead of graveyard) + } + } + + // Remove replacement effects sourced from this card + removeReplacementEffects(cardId); + + // Clean up stack membership before leaving battlefield + cleanupCardFromStack(cardId); + + // Detach all equipment/auras from this creature, or detach this equipment from its creature + if (cardData.attachments && cardData.attachments.length > 0) { + detachAllFromCreature(cardId); + } + if (cardData.attachedTo) { + detachEquipment(cardId, true); + } + + // Commander zone replacement rule + if (gameState.rulesEngine && gameState.rulesEngine.commanderZoneReplacement && isCommander(cardData) && (zone === 'graveyard' || zone === 'exile')) { + // AI always sends commander back to command zone + if (owner.isAI) { + var prev = gameState.rulesEngine.commanderZoneReplacement; + gameState.rulesEngine.commanderZoneReplacement = false; + moveCardToZone(cardId, 'command'); + gameState.rulesEngine.commanderZoneReplacement = prev; + addLogEntry('\u{1F916} \u{1F451} ' + owner.name + ' returns commander to command zone'); + return; + } + showCommanderZonePrompt(cardId, cardData, zone); + return; + } + + // Tokens cease to exist when they leave the battlefield (MTG rules) + if (cardData.isToken) { + // Still fire LTB/dies triggers + fireBattlefieldTriggers('ltb', function(bfCard) { + return bfCard.id !== cardData.id; + }); + fireCardTriggers(cardData, ['ltb']); + if (zone === 'graveyard') { + fireBattlefieldTriggers('dies', function(bfCard) { + return bfCard.id !== cardData.id; + }); + fireCardTriggers(cardData, ['dies']); + } + + gameState.battlefieldCards.splice(idx, 1); + const cardEl = document.getElementById(`bc-${cardId}`); + if (cardEl) { + var exitDur = animateCardExit(cardEl, zone); + setTimeout(function() { if (cardEl.parentNode) cardEl.remove(); }, exitDur || 350); + } + addLogEntry(`${cardData.name} ceased to exist (token)`); + if (!gameState.battlefieldCards.length) { + const label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + updateBattlefieldSectionCounts(); + updatePlayerZones(); + updateTokenSummaryBar(); + return; + } + + // Add to zone (preserve card data for tooltips and returning to battlefield) + owner.zones[zone].push({ + name: cardData.name, + imageUrl: cardData.imageUrl, + typeLine: cardData.typeLine, + oracleText: cardData.oracleText || '', + manaCost: cardData.manaCost || '', + power: cardData.power, + toughness: cardData.toughness, + loyalty: cardData.loyalty, + }); + + // Fire LTB triggers on ALL battlefield permanents watching for leaves + fireBattlefieldTriggers('ltb', function(bfCard) { + return bfCard.id !== cardData.id; + }); + // Fire the leaving card's own LTB trigger + fireCardTriggers(cardData, ['ltb']); + + // Fire dies triggers if going to graveyard + if (zone === 'graveyard') { + fireBattlefieldTriggers('dies', function(bfCard) { + return bfCard.id !== cardData.id; + }); + fireCardTriggers(cardData, ['dies']); + } + + // Remove from battlefield + gameState.battlefieldCards.splice(idx, 1); + const cardEl = document.getElementById(`bc-${cardId}`); + if (cardEl) { + var exitDur2 = animateCardExit(cardEl, zone); + setTimeout(function() { if (cardEl.parentNode) cardEl.remove(); }, exitDur2 || 350); + } + + const zoneLabels = { graveyard: 'Graveyard', exile: 'Exile', hand: 'Hand', library: 'Library' }; + addLogEntry(`${cardData.name} sent to ${owner.name}'s ${zoneLabels[zone] || zone}`); + + // Re-show label if battlefield is empty + if (!gameState.battlefieldCards.length) { + const label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + + updateBattlefieldSectionCounts(); + updatePlayerZones(); + + // Check SBAs after zone change (token cleanup, toughness, etc.) + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions(); +} + +function removeCardFromBattlefield(cardId) { + pushUndoState('Remove card from battlefield'); + const idx = gameState.battlefieldCards.findIndex(c => c.id === cardId); + if (idx === -1) return; + const cardData = gameState.battlefieldCards[idx]; + // Clean up stack membership before removing + cleanupCardFromStack(cardId); + // Detach equipment + if (cardData.attachments && cardData.attachments.length > 0) detachAllFromCreature(cardId); + if (cardData.attachedTo) detachEquipment(cardId, true); + gameState.battlefieldCards.splice(idx, 1); + const cardEl = document.getElementById(`bc-${cardId}`); + if (cardEl) cardEl.remove(); + addLogEntry(`${cardData.name} removed from battlefield`); + if (!gameState.battlefieldCards.length) { + const label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + updateBattlefieldSectionCounts(); +} + +// ============================================================ +// CONTEXT MENU SETUP +// ============================================================ + +function setupContextMenu() { + const menu = document.getElementById('context-menu'); + if (!menu) return; + + menu.innerHTML = ` + + +
+
+ + + +
+
+ + +
+ + + + + +
+
+
+ + + `; + + menu.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + const id = contextMenuTargetId; + hideContextMenu(); + if (!id) return; + switch(action) { + case 'tap': toggleTap(id); break; + case 'facedown': toggleFaceDown(id); break; + case 'declare-attacker': toggleAttacker(id); break; + case 'view-abilities': { + const cd = gameState.battlefieldCards.find(c => c.id === id); + const cardEl = document.getElementById(`bc-${id}`); + if (cd && cardEl) showAbilitiesPanel(cd, cardEl); + break; + } + case 'graveyard': moveCardToZone(id, 'graveyard'); break; + case 'exile': moveCardToZone(id, 'exile'); break; + case 'hand': moveCardToZone(id, 'hand'); break; + case 'library-top': moveCardToLibrary(id, 'top'); break; + case 'library-bottom': moveCardToLibrary(id, 'bottom'); break; + case 'remove': removeCardFromBattlefield(id); break; + } + }); + }); + + document.addEventListener('click', e => { + if (!menu.contains(e.target)) hideContextMenu(); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape') { hideContextMenu(); cancelStackPickMode(); cancelEquipPickMode(); } + }); +} + +// ============================================================ +// PLAYER PANELS +// ============================================================ + +function renderPlayerPanels() { + const { players, currentPlayerIndex } = gameState; + const n = players.length; + + // Determine layout positions — all non-P1 players go to top bar + let top = [], bottom = [], left = [], right = []; + + if (n === 2) { + top = [1]; bottom = [0]; + } else if (n === 3) { + top = [1, 2]; bottom = [0]; + } else if (n === 4) { + top = [1, 2, 3]; bottom = [0]; + } else if (n === 5) { + top = [1, 2, 3, 4]; bottom = [0]; + } else if (n === 6) { + top = [1, 2, 3, 4, 5]; bottom = [0]; + } + + const containers = { + top: document.getElementById('players-top'), + bottom: document.getElementById('players-bottom'), + left: document.getElementById('players-left'), + right: document.getElementById('players-right'), + }; + + // Clear existing + Object.values(containers).forEach(c => { if (c) c.innerHTML = ''; }); + + // Also clear the fixed Player 1 panel + const p1Fixed = document.getElementById('player1-fixed-panel'); + if (p1Fixed) { p1Fixed.innerHTML = ''; p1Fixed.classList.add('hidden'); } + + // Render each player in their position + const allPositions = { top, bottom, left, right }; + Object.entries(allPositions).forEach(([pos, playerIds]) => { + const container = containers[pos]; + if (!container) return; + playerIds.forEach(pid => { + // Player 1 (index 0) goes to fixed bottom-left panel + if (pid === 0 && p1Fixed) { + const panel = createPlayerPanel(players[pid], currentPlayerIndex === pid); + p1Fixed.appendChild(panel); + p1Fixed.classList.remove('hidden'); + } else { + const panel = createPlayerPanel(players[pid], currentPlayerIndex === pid); + container.appendChild(panel); + } + }); + }); +} + +function createPlayerPanel(player, isActive) { + const panel = document.createElement('div'); + panel.className = `player-panel${isActive ? ' active-player' : ''}${player.eliminated ? ' eliminated' : ''}`; + panel.id = `player-panel-${player.id}`; + panel.style.setProperty('--player-color', player.color); + if (isActive) { + panel.style.boxShadow = '0 0 16px ' + hexToRgba(player.color, 0.3); + } + + // Commander damage rows (vs other players) + const otherPlayers = gameState.players.filter(p => p.id !== player.id); + const cmdDamageHtml = otherPlayers.map(op => { + const dmg = player.commanderDamage[op.id] || 0; + const cls = dmg >= 18 ? 'danger' : dmg >= 10 ? 'warning' : ''; + return ` +
+
+ ${dmg} + − +
+ `; + }).join(''); + + // Zone counts + const gravCount = player.zones.graveyard.length; + const exileCount = player.zones.exile.length; + const cmdCount = player.zones.command.length; + const handCount = player.zones.hand.length; + const libCount = player.zones.library.length; + + panel.innerHTML = ` +
+
+ ${player.name} + ${player.isAI ? '🤖 AI' : ''} +
+ +
+ +
${player.life}
+ +
+ +
+
+ CMD Damage + 21 = elim. +
+
+ ${cmdDamageHtml || 'No opponents'} +
+
+ +
+
+ ☠ + ${player.poison} + poison +
+
+ +
+ + + + + +
+ + + + + + + + + +
+ + ${player.commanderCardName2 ? '' : ''} +
+ `; + + // Right-click on life total to subtract more + const lifeEl = panel.querySelector(`#life-${player.id}`); + if (lifeEl) { + lifeEl.addEventListener('contextmenu', e => { + e.preventDefault(); + showSetLifeDialog(player.id); + }); + } + + // Right-click on poison to remove + panel.querySelectorAll('.poison-counter').forEach(el => { + el.addEventListener('contextmenu', e => { + e.preventDefault(); + adjustPoison(player.id, -1); + }); + }); + + return panel; +} + +// ============================================================ +// LIFE TOTAL MANAGEMENT +// ============================================================ + +function adjustLife(playerId, amount) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + // Apply replacement effects to life gain/loss + if (amount > 0) { + var gainEvent = applyReplacements('gain_life', { playerIdx: playerId, amount: amount }); + if (!gainEvent) return; + amount = gainEvent.amount; + } + + setLife(playerId, player.life + amount); +} + +function setLife(playerId, newLife) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + + const oldLife = player.life; + player.life = newLife; + + // Animate the value + const lifeEl = document.getElementById(`life-${playerId}`); + if (lifeEl) { + lifeEl.textContent = newLife; + lifeEl.classList.remove('life-gain', 'life-loss'); + lifeEl.offsetWidth; // Force reflow + if (newLife > oldLife) lifeEl.classList.add('life-gain'); + else if (newLife < oldLife) lifeEl.classList.add('life-loss'); + setTimeout(() => lifeEl.classList.remove('life-gain', 'life-loss'), 500); + } + + // Panel flash + const panel = document.getElementById(`player-panel-${playerId}`); + if (panel) { + panel.classList.remove('life-gained', 'life-lost'); + panel.offsetWidth; + if (newLife > oldLife) panel.classList.add('life-gained'); + else if (newLife < oldLife) panel.classList.add('life-lost'); + setTimeout(() => panel.classList.remove('life-gained', 'life-lost'), 700); + } + + const diff = newLife - oldLife; + const sign = diff > 0 ? '+' : ''; + addLogEntry(`${player.name} life: ${oldLife} → ${newLife} (${sign}${diff})`); + + // Fire life gain/loss triggers + if (diff > 0) { + fireBattlefieldTriggers('life_gain'); + } else if (diff < 0) { + fireBattlefieldTriggers('life_loss'); + } + + // Check for 0 or negative life + if (newLife <= 0 && !player.eliminated) { + eliminatePlayer(playerId, 'life total reached 0'); + } + checkStateBasedActions(); + // Sync enhanced player panels + if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels(); +} + +// ============================================================ +// COMMANDER DAMAGE +// ============================================================ + +function adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[targetPlayerId]; + if (!player) return; + + const oldDmg = player.commanderDamage[sourcePlayerId] || 0; + const newDmg = Math.max(0, oldDmg + amount); + player.commanderDamage[sourcePlayerId] = newDmg; + + const srcPlayer = gameState.players[sourcePlayerId]; + const srcName = srcPlayer ? srcPlayer.name : 'Unknown'; + + if (amount > 0) { + addLogEntry(`${player.name} took ${amount} commander damage from ${srcName} (total: ${newDmg})`); + } + + if (newDmg >= 21 && !player.eliminated) { + eliminatePlayer(targetPlayerId, `21 commander damage from ${srcName}`); + } + + // Re-render the panel + const panelContainer = document.getElementById(`player-panel-${targetPlayerId}`); + if (panelContainer) { + const newPanel = createPlayerPanel(player, gameState.currentPlayerIndex === targetPlayerId); + panelContainer.replaceWith(newPanel); + } + checkStateBasedActions(); + // Sync enhanced player panels + if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels(); +} + +// ============================================================ +// POISON COUNTERS +// ============================================================ + +function adjustPoison(playerId, amount) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + + player.poison = Math.max(0, player.poison + amount); + + const poisonEl = document.getElementById(`poison-${playerId}`); + if (poisonEl) poisonEl.textContent = player.poison; + + if (amount !== 0) { + addLogEntry(`${player.name} has ${player.poison} poison counter${player.poison !== 1 ? 's' : ''}`); + } + + if (player.poison >= 10 && !player.eliminated) { + eliminatePlayer(playerId, '10 poison counters'); + } + + // Update the counter styling + const panelContainer = document.getElementById(`player-panel-${playerId}`); + if (panelContainer) { + const newPanel = createPlayerPanel(player, gameState.currentPlayerIndex === playerId); + panelContainer.replaceWith(newPanel); + } + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions(); + // Sync enhanced player panels + if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels(); +} + +// ============================================================ +// SET LIFE DIALOG (replaces prompt()) +// ============================================================ + +function showSetLifeDialog(playerId) { + const player = gameState?.players[playerId]; + if (!player) return; + + // Remove any existing + const existing = document.getElementById('set-life-dialog'); + if (existing) existing.remove(); + + const dialog = document.createElement('div'); + dialog.id = 'set-life-dialog'; + dialog.style.cssText = ` + position: fixed; inset: 0; z-index: 800; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); + `; + dialog.innerHTML = ` +
+
+ Set Life Total +
+
${player.name}
+ +
+ + +
+
+ `; + + document.body.appendChild(dialog); + const input = document.getElementById('set-life-input'); + input.select(); + input.focus(); + + const confirm = () => { + const val = parseInt(input.value); + if (!isNaN(val)) setLife(playerId, val); + dialog.remove(); + }; + + document.getElementById('set-life-confirm').addEventListener('click', confirm); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') confirm(); + if (e.key === 'Escape') dialog.remove(); + }); + dialog.addEventListener('click', e => { + if (e.target === dialog) dialog.remove(); + }); +} + + + +function eliminatePlayer(playerId, reason) { + pushUndoState(); + const player = gameState.players[playerId]; + if (!player || player.eliminated) return; + player.eliminated = true; + addLogEntry(`⚠️ ${player.name} has been eliminated! (${reason})`); + renderPlayerPanels(); + // Fire player_eliminated triggers + fireBattlefieldTriggers('player_eliminated'); + + // Check for winner + const activePlayers = gameState.players.filter(p => !p.eliminated); + if (activePlayers.length === 1) { + showWinner(activePlayers[0]); + } +} + +function showWinner(player) { + addLogEntry(`🏆 ${player.name} wins the game!`); + // Show a nice in-app winner notification + const banner = document.createElement('div'); + banner.style.cssText = ` + position: fixed; inset: 0; z-index: 2000; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.75); backdrop-filter: blur(8px); + animation: fadeIn 0.5s ease; + `; + banner.innerHTML = ` +
+
+ 🏆 +
+
+ ${player.name} +
+
+ Wins the Game! +
+ +
+ `; + document.body.appendChild(banner); +} + +// ============================================================ +// FEATURE 9: COMMANDER DAMAGE MATRIX +// ============================================================ + +function openCmdDmgMatrix() { + if (!gameState) return; + var existing = document.getElementById('cmd-dmg-matrix-modal'); + if (existing) { existing.classList.add('visible'); renderCmdDmgMatrix(); return; } + renderCmdDmgMatrix(); + var modal = document.getElementById('cmd-dmg-matrix-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeCmdDmgMatrix() { + var modal = document.getElementById('cmd-dmg-matrix-modal'); + if (modal) modal.classList.remove('visible'); +} + +function renderCmdDmgMatrix() { + var modal = document.getElementById('cmd-dmg-matrix-modal'); + if (!modal || !gameState) return; + var players = gameState.players; + var html = '

📊 Commander Damage Matrix

' + + '
'; + html += '

Row = damage from row commander. Column = player taking damage.
Click cell +1, right-click \u22121.

'; + html += '
'; + // Header row + html += ''; + players.forEach(function(p) { + html += ''; + }); + html += ''; + // Data rows + players.forEach(function(rowP) { + html += ''; + html += ''; + players.forEach(function(colP) { + if (rowP.id === colP.id) { + html += ''; + } else { + // damage that colP has taken FROM rowP's commander + var dmg = colP.commanderDamage[rowP.id] || 0; + var cls = 'cdm-cell'; + if (dmg >= 21) cls += ' cdm-red'; + else if (dmg >= 15) cls += ' cdm-orange'; + else if (dmg >= 8) cls += ' cdm-yellow'; + else cls += ' cdm-green'; + html += ''; + } + }); + html += ''; + }); + html += '
From \ To' + p.name + '
' + rowP.name + '—' + dmg + '
'; + modal.innerHTML = html; +} + +function cdmAdjust(targetPlayerId, sourcePlayerId, amount) { + if (!gameState) return; + adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount); + renderCmdDmgMatrix(); +} + +// ============================================================ +// RULES ENGINE +// ============================================================ + +function getCommanderTax(playerIndex) { + if (!gameState) return 0; + var playerData = gameState.players[playerIndex]; + if (!playerData) return 0; + return (playerData.commanderCastCount || 0) * 2; +} + +function getCommanderTax2(playerIndex) { + if (!gameState) return 0; + var playerData = gameState.players[playerIndex]; + if (!playerData) return 0; + return (playerData.commanderCastCount2 || 0) * 2; +} + +// Feature 10: Partner Commanders +function openSetPartnerModal(playerId) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + var existing = document.getElementById('set-partner-modal'); + if (existing) existing.remove(); + var modal = document.createElement('div'); + modal.id = 'set-partner-modal'; + modal.className = 'set-partner-modal'; + modal.innerHTML = + '
' + + '

Set Partner Commander

' + + '
' + + '
' + + '

Enter the name of ' + player.name + '’s partner commander:

' + + '' + + '
' + + '' + + '' + + '
'; + document.body.appendChild(modal); + var inp = document.getElementById('partner-name-input'); + if (inp) inp.focus(); +} + +function confirmSetPartner(playerId) { + var inp = document.getElementById('partner-name-input'); + if (!inp) return; + var name = inp.value.trim(); + if (!name) return; + pushUndoState('Set partner ' + name); + gameState.players[playerId].commanderCardName2 = name; + gameState.players[playerId].commanderCastCount2 = 0; + addLogEntry('👑 ' + gameState.players[playerId].name + ' set partner commander: ' + name + ''); + var modal = document.getElementById('set-partner-modal'); + if (modal) modal.remove(); + renderPlayerPanels(); +} + +function clearPartnerCommander(playerId) { + if (!gameState) return; + pushUndoState('Clear partner commander'); + gameState.players[playerId].commanderCardName2 = ''; + gameState.players[playerId].commanderCastCount2 = 0; + addLogEntry('👑 ' + gameState.players[playerId].name + ' cleared partner commander'); + renderPlayerPanels(); +} + +function checkStateBasedActions() { + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.sbaChecks) return; + var alerts = []; + var autoEliminate = gameState.rulesEngine && gameState.rulesEngine.autoEliminateOnSBA; + + gameState.players.forEach(function(p, idx) { + if (p.eliminated) return; + + // Life <= 0 + if (p.life <= 0) { + if (autoEliminate) { + eliminatePlayer(idx, 'life total reached ' + p.life); + } else { + alerts.push({ type: 'life', player: p.name, playerIdx: idx, msg: p.name + ' has ' + p.life + ' life — should be eliminated' }); + } + } + + // Poison >= 10 + if (p.poison >= 10) { + if (autoEliminate) { + eliminatePlayer(idx, '10 poison counters'); + } else { + alerts.push({ type: 'poison', player: p.name, playerIdx: idx, msg: p.name + ' has ' + p.poison + ' poison counters — should be eliminated' }); + } + } + + // Commander damage >= 21 from any single opponent + for (var oppId in p.commanderDamage) { + if (p.commanderDamage[oppId] >= 21) { + var oppName = (gameState.players[oppId] || {}).name || 'Unknown'; + if (autoEliminate) { + eliminatePlayer(idx, '21 commander damage from ' + oppName); + } else { + alerts.push({ type: 'cmdr_dmg', player: p.name, playerIdx: idx, msg: p.name + ' has taken 21+ commander damage from ' + oppName }); + } + } + } + }); + + // Legendary Rule SBA + gameState.players.forEach(function(p, idx) { + if (p.eliminated) return; + var legendaries = gameState.battlefieldCards.filter(function(c) { + return c.ownerIndex === idx && c.typeLine && /legendary/i.test(c.typeLine); + }); + var byName = {}; + legendaries.forEach(function(c) { + if (!byName[c.name]) byName[c.name] = []; + byName[c.name].push(c); + }); + Object.keys(byName).forEach(function(name) { + if (byName[name].length > 1) { + alerts.push({ + type: 'legendary', + player: p.name, + playerIdx: idx, + cardName: name, + cardIds: byName[name].map(function(c) { return c.id; }), + msg: p.name + ' controls ' + byName[name].length + ' legendary permanents named "' + name + '" — must keep only one', + }); + } + }); + }); + + // SBA: Planeswalker with 0 loyalty dies + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/planeswalker/i.test(card.typeLine)) return; + if (!card.counters || card.counters['loyalty'] === undefined) return; + if (card.counters['loyalty'] <= 0) { + alerts.push({ + type: 'pw_loyalty', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' has 0 loyalty \u2014 should be moved to graveyard', + }); + } + }); + + // NEW SBA: Toughness <= 0 (creatures that should die from -1/-1 counters or debuffs) + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/creature/i.test(card.typeLine)) return; + // Only check creatures that have actual P/T data (skip if toughness data is missing) + var hasPTData = (card.toughness && card.toughness !== '') || (card.pt && card.pt !== ''); + if (!hasPTData) return; + var toughness = getCreatureToughness(card); + if (toughness <= 0) { + // Double-check: only alert if the card actually had modifiers that reduced toughness + var ept = getEffectivePT(card); + if (ept && ept.bonusTough < 0) { + alerts.push({ + type: 'toughness', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' has toughness ' + toughness + ' \u2014 should be moved to graveyard', + }); + } + } + }); + + // NEW SBA: Token in non-battlefield zone — tokens outside battlefield cease to exist + gameState.players.forEach(function(p, idx) { + ['graveyard', 'exile', 'hand'].forEach(function(zone) { + if (!p.zones[zone]) return; + var tokensInZone = p.zones[zone].filter(function(c) { return c.isToken; }); + if (tokensInZone.length > 0) { + // Auto-remove tokens from non-battlefield zones + p.zones[zone] = p.zones[zone].filter(function(c) { return !c.isToken; }); + addLogEntry('\u26a0 SBA: ' + tokensInZone.length + ' token(s) removed from ' + p.name + '\'s ' + zone + ' (tokens cease to exist)'); + } + }); + }); + + // NEW SBA: +1/+1 and -1/-1 counter cancellation + gameState.battlefieldCards.forEach(function(card) { + if (!card.counters) return; + var plus = card.counters['+1/+1'] || 0; + var minus = card.counters['-1/-1'] || 0; + if (plus > 0 && minus > 0) { + var cancel = Math.min(plus, minus); + card.counters['+1/+1'] -= cancel; + card.counters['-1/-1'] -= cancel; + addLogEntry('⚠ SBA: ' + cancel + ' pair(s) of +1/+1 and -1/-1 counters cancelled on ' + card.name + ''); + } + }); + + // NEW SBA: Aura without attached permanent + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/enchantment.*aura/i.test(card.typeLine)) return; + if (!card.attachedTo) { + alerts.push({ + type: 'aura', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' (Aura) has no attached permanent — should be moved to graveyard', + }); + } + }); + + // SBA: Lethal damage — use damage marking system (Forge-style) + // (replaces old damageThisTurn checks with unified damageMarked system) + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/creature/i.test(card.typeLine)) return; + var totalDmg = (card.damageMarked || 0) + (card.damageThisTurn || 0); + if (totalDmg <= 0) return; + if (hasKeyword(card, 'indestructible')) return; + var toughness = getCreatureToughness(card); + if (toughness <= 0) return; // handled by toughness <= 0 SBA above + if (totalDmg >= toughness) { + alerts.push({ + type: 'lethal_damage', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' has ' + totalDmg + ' damage (toughness ' + toughness + ') \u2014 should be destroyed', + }); + } + }); + + if (alerts.length > 0) { + showSbaAlert(alerts); + } +} + +function showSbaAlert(alerts) { + var existing = document.getElementById('sba-alert-panel'); + if (existing) existing.remove(); + + var panel = document.createElement('div'); + panel.id = 'sba-alert-panel'; + panel.className = 'sba-alert-panel'; + + var html = '
⚠ State-Based Actions
'; + html += '
'; + alerts.forEach(function(a) { + html += '
'; + html += '' + a.msg + ''; + if (a.type === 'toughness' && a.cardId) { + html += ''; + html += ''; + } else if (a.type === 'pw_loyalty' && a.cardId) { + html += ''; + html += ''; + } else if (a.type === 'aura' && a.cardId) { + html += ''; + html += ''; + } else if (a.type === 'lethal_damage' && a.cardId) { + html += ''; + html += ''; + } else if (a.type === 'lethal_damage_marked' && a.cardId) { + html += ''; + html += ''; + } else if (a.type === 'legendary' && a.cardIds && a.cardIds.length >= 2) { + // Show keep/sacrifice buttons for each copy + var cIds = JSON.stringify(a.cardIds); + html += '
'; + for (var li = 0; li < a.cardIds.length; li++) { + var cid = a.cardIds[li]; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cid; }); + var cardLabel = card ? (card.name + ' #' + (li+1)) : 'Copy ' + (li+1); + html += ''; + } + html += '
'; + html += ''; + } else { + html += ''; + html += ''; + } + html += '
'; + }); + html += '
'; + panel.innerHTML = html; + document.body.appendChild(panel); +} + +function closeSbaAlert() { + var panel = document.getElementById('sba-alert-panel'); + if (panel) panel.remove(); +} + +// Feature 5: Keep one legendary, sacrifice the rest +function keepLegendaryCard(keepId, allIds) { + if (!gameState) return; + pushUndoState(); + for (var i = 0; i < allIds.length; i++) { + if (allIds[i] !== keepId) { + var card = gameState.battlefieldCards.find(function(c) { return c.id === allIds[i]; }); + if (card) { + addLogEntry('⚖ Legendary rule: ' + card.name + ' was sacrificed'); + moveCardToZone(allIds[i], 'graveyard'); + } + } + } +} + +// ============================================================ +// FEATURE 6: PROTECTION / HEXPROOF / SHROUD TARGETING +// ============================================================ + +function canTarget(sourceOwnerId, targetCardId) { + if (!gameState) return { allowed: true, reason: '' }; + var card = gameState.battlefieldCards.find(function(c) { return c.id === targetCardId; }); + if (!card) return { allowed: true, reason: '' }; + + var oracle = (card.oracleText || '').toLowerCase(); + var typeLine = (card.typeLine || '').toLowerCase(); + + // Shroud — can't be targeted by anything + if (/shroud/.test(oracle)) { + return { allowed: false, reason: card.name + ' has shroud and cannot be targeted by anything' }; + } + + // Hexproof — can't be targeted by opponents + if (/hexproof/.test(oracle)) { + if (sourceOwnerId !== card.ownerIndex && sourceOwnerId !== card.ownerId) { + return { allowed: false, reason: card.name + ' has hexproof and cannot be targeted by your spells or abilities' }; + } + } + + // Protection from [color/quality] — prevents targeting + var protQualities = parseProtectionQualities(card); + if (protQualities.length > 0 && sourceOwnerId !== card.ownerIndex && sourceOwnerId !== card.ownerId) { + // For targeting, protection blocks spells/abilities from matching colors + // We can't know the source's color here, so we flag it as protected and let the caller decide + return { allowed: true, reason: '', protections: protQualities }; + } + + return { allowed: true, reason: '' }; +} + +/** + * Parse all protection qualities from a card's oracle text. + * Returns an array of protection qualities: ['white','blue','red', 'everything', etc.] + */ +function parseProtectionQualities(card) { + if (!card) return []; + var oracle = (card.oracleText || '').toLowerCase(); + var qualities = []; + // Match: "protection from X", "protection from X and from Y", "protection from X, Y, and Z" + var matches = oracle.match(/protection from [a-z, ]+/g) || []; + matches.forEach(function(m) { + var raw = m.replace('protection from ', '').trim(); + // Split on 'and from', 'and', or commas + var parts = raw.split(/(?:\s+and\s+from\s+|\s+and\s+|,\s*)/); + parts.forEach(function(p) { + p = p.trim(); + if (p) qualities.push(p); + }); + }); + return qualities; +} + +/** + * Check if a source card's colors match any of the protection qualities. + * Colors: white, blue, black, red, green + * Also handles: 'everything', 'all colors', 'each color' + * @returns {boolean} true if protection blocks this source + */ +function protectionBlocksSource(protQualities, sourceCard) { + if (!protQualities || protQualities.length === 0) return false; + if (!sourceCard) return false; + + var colorMap = { W: 'white', U: 'blue', B: 'black', R: 'red', G: 'green' }; + var sourceColors = []; + if (Array.isArray(sourceCard.colors)) { + sourceCard.colors.forEach(function(c) { + if (colorMap[c]) sourceColors.push(colorMap[c]); + }); + } + // Fallback: parse mana cost + if (sourceColors.length === 0 && sourceCard.manaCost) { + var mc = sourceCard.manaCost.toUpperCase(); + Object.keys(colorMap).forEach(function(code) { + if (mc.indexOf(code) !== -1) sourceColors.push(colorMap[code]); + }); + } + + for (var i = 0; i < protQualities.length; i++) { + var q = protQualities[i]; + if (q === 'everything' || q === 'all colors' || q === 'each color') return true; + if (q === 'colorless' && sourceColors.length === 0) return true; + if (q === 'creatures' && sourceCard.typeLine && /creature/i.test(sourceCard.typeLine)) return true; + if (q === 'instants' && sourceCard.typeLine && /instant/i.test(sourceCard.typeLine)) return true; + if (q === 'sorceries' && sourceCard.typeLine && /sorcery/i.test(sourceCard.typeLine)) return true; + if (q === 'enchantments' && sourceCard.typeLine && /enchantment/i.test(sourceCard.typeLine)) return true; + if (q === 'artifacts' && sourceCard.typeLine && /artifact/i.test(sourceCard.typeLine)) return true; + // Color match + if (sourceColors.indexOf(q) !== -1) return true; + } + return false; +} + +/** + * Check if protection prevents a creature from being blocked by a specific blocker. + * Protection means the protected creature can't be blocked by creatures matching the quality. + * @returns {boolean} true if blocker is prevented from blocking + */ +function protectionPreventsBlocking(attacker, blocker) { + var atkProt = parseProtectionQualities(attacker); + return protectionBlocksSource(atkProt, blocker); +} + +function getTargetingWarning(sourceOwnerId, targetCardId) { + var result = canTarget(sourceOwnerId, targetCardId); + return result.allowed ? '' : result.reason; +} + +function getCardProtectionKeywords(card) { + if (!card) return []; + var oracle = (card.oracleText || '').toLowerCase(); + var keywords = []; + if (/shroud/.test(oracle)) keywords.push('shroud'); + if (/hexproof/.test(oracle)) keywords.push('hexproof'); + var protMatches = oracle.match(/protection from [a-z]+/g) || []; + protMatches.forEach(function(m) { keywords.push(m); }); + return keywords; +} + +function showTargetingWarningBadge(targetCardId, sourceOwnerId) { + var card = gameState.battlefieldCards.find(function(c) { return c.id === targetCardId; }); + if (!card) return; + var result = canTarget(sourceOwnerId, targetCardId); + if (!result.allowed) { + addLogEntry('🚫 Targeting warning: ' + result.reason); + // Show a brief floating warning + var el = document.getElementById('bc-' + targetCardId); + if (el) { + var warn = document.createElement('div'); + warn.className = 'targeting-warning-overlay'; + warn.textContent = '🚫'; + warn.title = result.reason; + el.appendChild(warn); + setTimeout(function() { if (warn.parentNode) warn.parentNode.removeChild(warn); }, 2500); + } + } +} + +function isCommander(cardData) { + if (!gameState) return false; + var owner = gameState.players[cardData.ownerIndex]; + if (!owner) return false; + if (owner.commanderCardName && owner.commanderCardName === cardData.name) return true; + if (owner.commanderCardName2 && owner.commanderCardName2 === cardData.name) return true; + return false; +} + +// Feature 8: Color Identity +function getCommanderColorIdentity(playerId) { + if (!gameState) return ['W','U','B','R','G','C']; + var player = gameState.players[playerId]; + if (!player) return ['W','U','B','R','G','C']; + var colors = []; + var COLOR_SYMBOLS = ['W','U','B','R','G']; + function extractFromCost(cost) { + if (!cost) return; + COLOR_SYMBOLS.forEach(function(c) { + if (cost.indexOf(c) !== -1 && colors.indexOf(c) === -1) colors.push(c); + }); + } + // Get from battlefield cards that are this player's commander + gameState.battlefieldCards.forEach(function(card) { + if (card.ownerIndex === playerId && isCommander(card)) { + extractFromCost(card.manaCost || ''); + extractFromCost(card.oracleText || ''); + } + }); + // Also check command zone + var cmdZone = player.zones && player.zones.command || []; + cmdZone.forEach(function(card) { + extractFromCost(card.manaCost || ''); + extractFromCost(card.oracle_text || card.oracleText || ''); + }); + // If commander name is set but we couldn't find colors, allow all (unknown) + if (!player.commanderCardName && !player.commanderCardName2) return ['W','U','B','R','G','C']; + if (colors.length === 0) return ['W','U','B','R','G','C']; + colors.push('C'); // colorless always allowed + return colors; +} + +function checkColorIdentityViolation(cardData, ownerIndex) { + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceColorIdentity) return false; + var player = gameState.players[ownerIndex]; + if (!player) return false; + if (!player.commanderCardName && !player.commanderCardName2) return false; // no commander set + var identity = getCommanderColorIdentity(ownerIndex); + var cardCost = cardData.manaCost || cardData.mana_cost || ''; + if (!cardCost) return false; // can't check without mana cost + var COLOR_SYMBOLS = ['W','U','B','R','G']; + var violations = []; + COLOR_SYMBOLS.forEach(function(c) { + if (cardCost.indexOf(c) !== -1 && identity.indexOf(c) === -1) { + violations.push(c); + } + }); + return violations.length > 0 ? violations : false; +} + +function showColorIdentityWarning(cardName, violations) { + var existing = document.getElementById('color-identity-toast'); + if (existing) existing.remove(); + var toast = document.createElement('div'); + toast.id = 'color-identity-toast'; + toast.className = 'color-identity-toast'; + var colorNames = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green' }; + var vNames = violations.map(function(v) { return colorNames[v] || v; }).join(', '); + toast.innerHTML = '⚠ ' + cardName + ' contains ' + vNames + ' \u2014 outside commander color identity'; + document.body.appendChild(toast); + setTimeout(function() { toast.classList.add('visible'); }, 10); + setTimeout(function() { + toast.classList.remove('visible'); + setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 400); + }, 4000); +} + +function showCommanderZonePrompt(cardId, cardData, intendedZone) { + var existing = document.getElementById('commander-zone-prompt'); + if (existing) existing.remove(); + + var panel = document.createElement('div'); + panel.id = 'commander-zone-prompt'; + panel.className = 'commander-zone-prompt'; + panel.innerHTML = '
' + + '
Commander Zone Replacement
' + + '
' + cardData.name + ' would go to ' + intendedZone + '. Send to command zone instead?
' + + '
' + + '' + + '' + + '
' + + '
'; + document.body.appendChild(panel); +} + +function resolveCommanderZoneChoice(cardId, chosenZone, originalZone) { + var prompt = document.getElementById('commander-zone-prompt'); + if (prompt) prompt.remove(); + + // Temporarily disable commander zone replacement to avoid recursion + var prev = gameState.rulesEngine.commanderZoneReplacement; + gameState.rulesEngine.commanderZoneReplacement = false; + moveCardToZone(cardId, chosenZone); + gameState.rulesEngine.commanderZoneReplacement = prev; + + if (chosenZone === 'command') { + addLogEntry('👑 Commander returned to command zone'); + } +} + +// Feature 11: Command Zone Casting Flow +function castCommanderFromZone(playerId, isPartner) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + var cmdName = isPartner ? (player.commanderCardName2 || '') : (player.commanderCardName || ''); + if (!cmdName) { + addLogEntry('⚠ No commander name set for ' + player.name); + return; + } + // Find commander in command zone + var cmdZone = player.zones.command; + var cmdIdx = -1; + for (var i = 0; i < cmdZone.length; i++) { + if (cmdZone[i].name === cmdName) { cmdIdx = i; break; } + } + var tax = isPartner ? getCommanderTax2(playerId) : getCommanderTax(playerId); + // Show cast confirmation + var existing = document.getElementById('cast-commander-modal'); + if (existing) existing.remove(); + var baseCMC = '?'; + var cardInZone = cmdIdx >= 0 ? cmdZone[cmdIdx] : null; + if (cardInZone && cardInZone.cmc !== undefined) baseCMC = cardInZone.cmc; + var modal = document.createElement('div'); + modal.id = 'cast-commander-modal'; + modal.className = 'cast-commander-modal'; + var costDisplay = tax > 0 ? (baseCMC + ' + ' + tax + ' (tax) = ' + (baseCMC !== '?' ? (parseInt(baseCMC)||0) + tax : '?' + '+' + tax)) : baseCMC; + modal.innerHTML = + '
' + + '

👑 Cast Commander

' + + '
' + + '
' + + '

' + cmdName + '

' + + '

Cost: ' + costDisplay + '

' + + '
' + + '' + + '' + + '
'; + document.body.appendChild(modal); +} + +function confirmCastCommander(playerId, isPartner) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + var cmdName = isPartner ? (player.commanderCardName2 || '') : (player.commanderCardName || ''); + if (!cmdName) return; + pushUndoState('Cast commander ' + cmdName); + // Increment cast count + if (isPartner) { + player.commanderCastCount2 = (player.commanderCastCount2 || 0) + 1; + } else { + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + } + // Find in command zone and move to battlefield + var cmdZone = player.zones.command; + var cmdIdx = -1; + for (var i = 0; i < cmdZone.length; i++) { + if (cmdZone[i].name === cmdName) { cmdIdx = i; break; } + } + var tax = isPartner ? getCommanderTax2(playerId) : getCommanderTax(playerId); + addLogEntry('👑 ' + player.name + ' cast ' + cmdName + ' from command zone (tax: +' + tax + ')'); + if (cmdIdx >= 0) { + var cardData = cmdZone.splice(cmdIdx, 1)[0]; + addCardToBattlefield(cardData.name, cardData.imageUrl || cardData.image_uris && cardData.image_uris.normal || '', + cardData.typeLine || cardData.type_line || '', playerId, undefined, undefined, + cardData.oracleText || cardData.oracle_text || '', + { manaCost: cardData.manaCost || cardData.mana_cost || '', power: cardData.power, toughness: cardData.toughness }); + } else { + // No physical card in zone, just add to battlefield by name + addCardToBattlefield(cmdName, '', 'Legendary Creature', playerId, undefined, undefined, '', {}); + } + var modal = document.getElementById('cast-commander-modal'); + if (modal) modal.remove(); + updatePlayerZones(); + renderPlayerPanels(); + // Fire commander_cast trigger for all battlefield permanents watching + fireBattlefieldTriggers('commander_cast'); + // Also fire generic cast triggers + fireBattlefieldTriggers('cast'); +} + +function checkHandSize() { + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceHandSize) return; + var p = gameState.players[gameState.currentPlayerIndex]; + if (!p || p.eliminated) return; + var maxHand = 7; + if (p.zones.hand.length > maxHand) { + var excess = p.zones.hand.length - maxHand; + showDiscardPrompt(gameState.currentPlayerIndex, excess); + } +} + +function showDiscardPrompt(playerId, count) { + var existing = document.getElementById('discard-prompt-panel'); + if (existing) existing.remove(); + + var player = gameState.players[playerId]; + if (!player) return; + + var panel = document.createElement('div'); + panel.id = 'discard-prompt-panel'; + panel.className = 'discard-prompt-panel'; + + var html = '
'; + html += '✋ Discard to Hand Size'; + html += ''; + html += '
'; + html += '
' + player.name + ' has ' + player.zones.hand.length + ' cards in hand. Discard ' + count + ' card' + (count > 1 ? 's' : '') + ' to reach 7.
'; + html += '
'; + player.zones.hand.forEach(function(card, idx) { + html += '
'; + html += '' + (card.name || 'Unknown') + ''; + html += '
'; + }); + html += '
'; + html += ''; + + panel.innerHTML = html; + document.body.appendChild(panel); +} + +function discardFromPrompt(playerId, handIndex) { + if (!gameState) return; + pushUndoState(); + var player = gameState.players[playerId]; + if (!player || handIndex < 0 || handIndex >= player.zones.hand.length) return; + var card = player.zones.hand.splice(handIndex, 1)[0]; + player.zones.graveyard.push(card); + addLogEntry('🗑 ' + player.name + ' discarded ' + (card.name || 'a card') + ''); + updatePlayerZones(); + + // Fire discard triggers + fireBattlefieldTriggers('discard'); + + // Check if still over hand size + var maxHand = 7; + if (player.zones.hand.length > maxHand) { + var excess = player.zones.hand.length - maxHand; + showDiscardPrompt(playerId, excess); + } else { + closeDiscardPrompt(); + } +} + +function closeDiscardPrompt() { + var panel = document.getElementById('discard-prompt-panel'); + if (panel) panel.remove(); +} + +function discardFromHand(playerId, handIndex) { + if (!gameState) return; + pushUndoState(); + var player = gameState.players[playerId]; + if (!player || handIndex < 0 || handIndex >= player.zones.hand.length) return; + var card = player.zones.hand.splice(handIndex, 1)[0]; + player.zones.graveyard.push(card); + addLogEntry('🗑 ' + player.name + ' discarded ' + (card.name || 'a card') + ''); + fireBattlefieldTriggers('discard'); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId === playerId) renderZoneModal(); +} + +function sacrificeCard(cardId) { + if (!gameState) return; + hideContextMenu(); + pushUndoState(); + var idx = gameState.battlefieldCards.findIndex(function(c) { return c.id === cardId; }); + if (idx === -1) return; + var cardData = gameState.battlefieldCards[idx]; + var owner = gameState.players[cardData.ownerIndex]; + + // Fire sacrifice triggers BEFORE removing + fireBattlefieldTriggers('sacrifice'); + + // Clean up stack membership + cleanupCardFromStack(cardId); + + // Move to graveyard (tokens cease to exist) + if (cardData.isToken) { + gameState.battlefieldCards.splice(idx, 1); + var cardEl = document.getElementById('bc-' + cardId); + if (cardEl) { + cardEl.style.transition = 'opacity 0.3s, transform 0.3s'; + cardEl.style.opacity = '0'; + cardEl.style.transform = 'scale(0.8)'; + setTimeout(function() { cardEl.remove(); }, 300); + } + addLogEntry('💀 ' + cardData.name + ' was sacrificed (token ceased to exist)'); + } else { + if (owner) owner.zones.graveyard.push({ name: cardData.name, imageUrl: cardData.imageUrl, typeLine: cardData.typeLine, oracleText: cardData.oracleText || '', manaCost: cardData.manaCost || '' }); + gameState.battlefieldCards.splice(idx, 1); + var cardEl2 = document.getElementById('bc-' + cardId); + if (cardEl2) { + cardEl2.style.transition = 'opacity 0.3s, transform 0.3s'; + cardEl2.style.opacity = '0'; + cardEl2.style.transform = 'scale(0.8)'; + setTimeout(function() { cardEl2.remove(); }, 300); + } + addLogEntry('💀 ' + cardData.name + ' was sacrificed'); + } + + // Fire dies triggers (sacrifice = goes to graveyard = dies) + fireBattlefieldTriggers('dies'); + + updatePlayerZones(); + if (!gameState.battlefieldCards.length) { + var label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + updateBattlefieldSectionCounts(); + updateTokenSummaryBar(); +} + +var activeSettingsTab = 'rules'; + +function toggleRulesPanel() { + var panel = document.getElementById('rules-settings-panel'); + if (!panel) return; + panel.classList.toggle('visible'); + if (panel.classList.contains('visible')) renderSettingsPanel(); +} + +function closeRulesPanel() { + var panel = document.getElementById('rules-settings-panel'); + if (panel) panel.classList.remove('visible'); +} + +function switchSettingsTab(tabId) { + activeSettingsTab = tabId; + renderSettingsPanel(); +} + +function renderSettingsPanel() { + if (!gameState) return; + var tabsEl = document.getElementById('settings-hub-tabs'); + var bodyEl = document.getElementById('settings-hub-body'); + if (!tabsEl || !bodyEl) return; + + var tabs = [ + { id: 'rules', label: 'Rules' }, + { id: 'display', label: 'Display' }, + { id: 'gameplay', label: 'Gameplay' }, + { id: 'ai', label: 'AI' }, + ]; + + // Render tabs + var tabHtml = ''; + tabs.forEach(function(t) { + var cls = t.id === activeSettingsTab ? ' active' : ''; + tabHtml += ''; + }); + tabsEl.innerHTML = tabHtml; + + // Render body based on active tab + switch (activeSettingsTab) { + case 'rules': bodyEl.innerHTML = renderSettingsRulesTab(); break; + case 'display': bodyEl.innerHTML = renderSettingsDisplayTab(); break; + case 'gameplay': bodyEl.innerHTML = renderSettingsGameplayTab(); break; + case 'ai': bodyEl.innerHTML = renderSettingsAITab(); break; + default: bodyEl.innerHTML = renderSettingsRulesTab(); + } +} + +// ---- RULES TAB ---- +function renderSettingsRulesTab() { + var r = gameState.rulesEngine; + if (!r) return ''; + + var toggles = [ + { key: 'autoUntap', label: 'Auto-untap on new turn', desc: 'Automatically untap all your permanents at the start of your turn' }, + { key: 'autoDraw', label: 'Auto-draw on new turn', desc: 'Automatically draw a card at the start of your turn' }, + { key: 'sbaChecks', label: 'State-based action checks', desc: 'Check for lethal damage, 0 toughness, legend rule, etc.' }, + { key: 'enforceLandLimit', label: 'Land drop limit (1/turn)', desc: 'Enforce the one land per turn limit' }, + { key: 'enforceHandSize', label: 'Hand size enforcement (7)', desc: 'Discard to max hand size at end of turn' }, + { key: 'commanderTaxEnabled', label: 'Commander tax tracking', desc: 'Track and apply +2 tax for each cast from command zone' }, + { key: 'commanderZoneReplacement',label: 'Commander zone replacement', desc: 'Option to return commander to command zone instead of graveyard/exile' }, + { key: 'useStack', label: 'Spell stack & priority', desc: 'Spells go on the stack with priority passing' }, + { key: 'enforceTurnStructure', label: 'Turn structure & priority', desc: 'Enforce phases, sorcery-speed timing, and priority passes (auto-enables stack)' }, + { key: 'enforceColorIdentity', label: 'Color identity enforcement', desc: 'Prevent casting spells outside your commander\'s color identity' }, + { key: 'autoResolveTriggers', label: 'Auto-resolve simple triggers', desc: 'Automatically resolve draw, gain life, and other simple triggers' }, + { key: 'autoEliminateOnSBA', label: 'Auto-eliminate on SBA', desc: 'Automatically eliminate players at 0 life or 21 commander damage' }, + { key: 'autoTapLands', label: 'Auto-tap lands when casting', desc: 'Automatically tap lands to pay for spells' }, + { key: 'autoSpendMana', label: 'Auto-spend mana from pool', desc: 'Automatically spend mana from your mana pool' }, + ]; + + var html = ''; + toggles.forEach(function(s) { + var checked = r[s.key] ? ' checked' : ''; + html += ''; + if (s.desc) html += '
' + s.desc + '
'; + }); + + // Timer section + html += '
Timer
'; + var timerChecked = r.turnTimerEnabled ? ' checked' : ''; + html += ''; + if (r.turnTimerEnabled) { + html += '
'; + html += 'Minutes per player'; + html += ''; + html += '
'; + } + + return html; +} + +// ---- DISPLAY TAB ---- +function renderSettingsDisplayTab() { + // Initialize display settings if not present + if (!gameState.displaySettings) { + gameState.displaySettings = { + cardScale: 100, + animationSpeed: 1, + showCardNames: true, + showPowerToughness: true, + compactPlayerPanels: false, + showFloatingLog: true, + logFontSize: 12, + }; + } + var ds = gameState.displaySettings; + + var html = ''; + + // Theme + html += '
Theme
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Card Scale + html += '
Cards
'; + html += '
'; + html += 'Card Size'; + html += ''; + html += '' + ds.cardScale + '%'; + html += '
'; + + // Animation Speed + html += '
'; + html += 'Animation Speed'; + html += ''; + html += '' + (ds.animationSpeed === 0 ? 'Off' : ds.animationSpeed + 'x') + ''; + html += '
'; + + // Toggles + html += '
Visibility
'; + var displayToggles = [ + { key: 'showCardNames', label: 'Show card name labels on battlefield' }, + { key: 'showPowerToughness', label: 'Show power/toughness badges' }, + { key: 'compactPlayerPanels', label: 'Compact player panels' }, + { key: 'showFloatingLog', label: 'Show floating game log' }, + ]; + displayToggles.forEach(function(t) { + var checked = ds[t.key] ? ' checked' : ''; + html += ''; + }); + + // Log font size + html += '
'; + html += 'Log Font Size'; + html += ''; + html += '' + ds.logFontSize + 'px'; + html += '
'; + + return html; +} + +// ---- GAMEPLAY TAB ---- +function renderSettingsGameplayTab() { + if (!gameState) return ''; + + var html = ''; + + // Starting life + html += '
Life
'; + html += '
'; + html += 'Starting Life'; + html += '' + (gameState.startingLife || 40) + ''; + html += '
'; + html += '
Starting life is set during game setup and cannot be changed mid-game.
'; + + // Max lands per turn + html += '
Lands
'; + html += '
'; + html += 'Max Lands Per Turn'; + html += ''; + html += '
'; + html += '
Increase for effects like Exploration or Azusa.
'; + + // Storm count display + html += '
Counters
'; + html += '
'; + html += 'Storm Count (this turn)'; + html += ''; + html += '
'; + + // Undo limit + html += '
History
'; + html += '
'; + html += 'Undo Stack Size'; + html += '' + UNDO_MAX + ''; + html += '
'; + html += '
Maximum undo steps stored. Currently fixed at ' + UNDO_MAX + '.
'; + + // Turn number + html += '
'; + html += 'Current Turn'; + html += '' + (gameState.currentTurn || 1) + ''; + html += '
'; + + return html; +} + +// ---- AI TAB ---- +function renderSettingsAITab() { + if (!gameState) return ''; + var diff = gameState.aiDifficulty || 'easy'; + var hasAI = gameState.players.some(function(p) { return p.isAI; }); + + var html = ''; + + if (!hasAI) { + html += '
No AI players in this game.
Add AI players during game setup to use these settings.
'; + return html; + } + + // Difficulty + html += '
Difficulty
'; + html += '
'; + var difficulties = ['easy', 'normal', 'hard']; + difficulties.forEach(function(d) { + var cls = d === diff ? ' active' : ''; + var label = d.charAt(0).toUpperCase() + d.slice(1); + html += ''; + }); + html += '
'; + + // Difficulty descriptions + var diffDescs = { + easy: 'Random play order, limited spells per turn, no bluffing, attacks weakest player.', + normal: 'Smart sequencing (ramp first), threat assessment, some bluffing, attacks the leader.', + hard: 'Optimal sequencing, full threat assessment, holds removal, bluffs with open mana, goes for kills.' + }; + html += '
' + diffDescs[diff] + '
'; + + // AI Behavior toggles + if (!gameState.aiSettings) { + gameState.aiSettings = { + showThoughts: true, + turnDelay: 600, + }; + } + var ais = gameState.aiSettings; + + html += '
Behavior
'; + + // Show AI thoughts toggle + var thoughtsChecked = ais.showThoughts ? ' checked' : ''; + html += ''; + html += '
Display AI decision reasoning in the game log.
'; + + // Turn delay + html += '
'; + html += 'AI Turn Speed'; + html += ''; + html += '' + (ais.turnDelay / 1000).toFixed(1) + 's'; + html += '
'; + html += '
Delay between AI actions. Lower = faster turns.
'; + + // AI player list + html += '
AI Players
'; + gameState.players.forEach(function(p, i) { + if (!p.isAI) return; + var color = p.color || '#999'; + html += '
'; + html += ''; + html += ''; + html += p.name; + html += ''; + html += '' + diff + ''; + html += '
'; + }); + + // Training section + html += '
Self-Play Training
'; + html += '
Train the AI via headless self-play games. Hill-climbing optimizer mutates weight vectors and keeps improvements.
'; + html += '
'; + + // After rendering, populate the training panel + setTimeout(function() { renderTrainingUI(); }, 0); + + return html; +} + +// ---- Settings action handlers ---- + +function setSettingsTheme(theme) { + currentTheme = theme; + document.documentElement.setAttribute('data-theme', theme); + var toggle = document.querySelector('[data-theme-toggle]'); + if (toggle) { + toggle.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + toggle.innerHTML = theme === 'dark' + ? '' + : ''; + } + renderSettingsPanel(); +} + +function updateDisplaySetting(key, value) { + if (!gameState) return; + if (!gameState.displaySettings) gameState.displaySettings = {}; + gameState.displaySettings[key] = value; + + // Apply display changes immediately + if (key === 'cardScale') { + var scale = value / 100; + document.documentElement.style.setProperty('--card-scale', scale); + // Update battlefield card sizes + var cards = document.querySelectorAll('.battlefield-card'); + cards.forEach(function(el) { + el.style.width = Math.round(100 * scale) + 'px'; + el.style.height = Math.round(140 * scale) + 'px'; + }); + } + if (key === 'animationSpeed') { + var speed = value; + document.documentElement.style.setProperty('--anim-speed', speed === 0 ? '0s' : (0.3 / speed) + 's'); + } + if (key === 'showFloatingLog') { + var logSidebar = document.getElementById('game-log-sidebar'); + if (logSidebar) { + if (value) logSidebar.classList.remove('collapsed'); + else logSidebar.classList.add('collapsed'); + } + } + if (key === 'logFontSize') { + var logSidebar = document.getElementById('game-log-sidebar'); + if (logSidebar) logSidebar.style.fontSize = value + 'px'; + } + if (key === 'compactPlayerPanels') { + document.body.classList.toggle('compact-panels', !!value); + } +} + +function updateGameplaySetting(key, value) { + if (!gameState) return; + if (key === 'maxLandsPerTurn') { + gameState.maxLandsPerTurn = Math.max(1, Math.min(10, value)); + addLogEntry('Lands per turn set to ' + gameState.maxLandsPerTurn + ''); + } +} + +function updateAIDifficulty(diff) { + if (!gameState) return; + gameState.aiDifficulty = diff; + addLogEntry('AI difficulty set to ' + diff + ''); + renderSettingsPanel(); +} + +function updateAISetting(key, value) { + if (!gameState) return; + if (!gameState.aiSettings) gameState.aiSettings = {}; + gameState.aiSettings[key] = value; +} + +function setTimerMinutes(mins) { + if (!gameState || !gameState.rulesEngine) return; + gameState.rulesEngine.turnTimerMinutes = mins; + // Re-init timer banks + if (gameState.turnTimer) { + gameState.players.forEach(function(p) { + gameState.turnTimer.timePerPlayer[p.id] = mins * 60; + }); + renderAllTimerDisplays(); + } + addLogEntry('⏱ Timer set to ' + mins + ' minutes per player'); +} + +function toggleRule(key, value) { + if (!gameState || !gameState.rulesEngine) return; + gameState.rulesEngine[key] = value; + addLogEntry('⚙ Rule ' + key + ' ' + (value ? 'enabled' : 'disabled')); + if (key === 'useStack') renderStackPanel(); + if (key === 'enforceTurnStructure') { + renderLegalActionsBar(); + // Enforce turn structure implies stack & priority + if (value && !gameState.rulesEngine.useStack) { + gameState.rulesEngine.useStack = true; + addLogEntry('\u2699 Spell stack auto-enabled with turn structure enforcement'); + renderStackPanel(); + renderSettingsPanel(); + } + // Close any active priority window when disabling + if (!value && gameState.priority && gameState.priority.active) { + closePriorityWindow(); + } + } + if (key === 'turnTimerEnabled') { + if (!value) { stopTurnTimer(); renderAllTimerDisplays(); } + else { initTurnTimers(); if (gameState) startTurnTimer(gameState.currentPlayerIndex); } + } +} + +// ============================================================ +// FEATURE 3: SPELL STACK & PRIORITY SYSTEM +// ============================================================ + +function renderStackPanel() { + var panel = document.getElementById('spell-stack-panel'); + if (!panel) return; + + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.useStack) { + panel.style.display = 'none'; + updateUnifiedStack(); + return; + } + + var stack = gameState.theStack || []; + panel.style.display = stack.length > 0 ? 'flex' : 'none'; + + if (stack.length === 0) { + updateUnifiedStack(); + return; + } + + var activePlayer = gameState.players[gameState.currentPlayerIndex]; + var activePlayerName = activePlayer ? activePlayer.name : 'Player'; + + var html = '
📚 The Stack (' + stack.length + ')'; + html += 'Priority: ' + activePlayerName + '
'; + html += '
'; + for (var i = stack.length - 1; i >= 0; i--) { + var entry = stack[i]; + var isTop = (i === stack.length - 1); + var casterPlayer = gameState.players[entry.ownerIndex]; + var casterColor = casterPlayer ? casterPlayer.color : '#888'; + var casterName = casterPlayer ? casterPlayer.name : 'Unknown'; + html += '
'; + if (entry.imageUrl) { + html += '' + escapeHtml(entry.name) + ''; + } + html += ''; + if (isTop) { + html += ''; + } + html += '
'; + } + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + panel.innerHTML = html; + updateUnifiedStack(); +} + +/** + * Resolve the entire theStack in LIFO order with 500ms delays. + */ +function resolveAllOfStack() { + if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return; + function resolveNext() { + if (!gameState.theStack || gameState.theStack.length === 0) return; + resolveTopOfStack(); + if (gameState.theStack.length > 0) setTimeout(resolveNext, 500); + } + resolveNext(); +} + +/** + * Toggle the unified stack panel collapsed/expanded. + */ +function toggleUnifiedStack() { + var panel = document.getElementById('unified-stack-panel'); + if (!panel) return; + panel.classList.toggle('collapsed'); +} + +/** + * Update the unified stack panel — merges theStack and triggerStack visually. + */ +function updateUnifiedStack() { + var panel = document.getElementById('unified-stack-panel'); + if (!panel) return; + + var spellStack = (gameState && gameState.theStack) || []; + var trigStack = (gameState && gameState.triggerStack) + ? gameState.triggerStack.filter(function(t) { return !t.resolved; }) + : []; + var totalCount = spellStack.length + trigStack.length; + + var countEl = document.getElementById('usp-count'); + if (countEl) countEl.textContent = totalCount; + + if (totalCount === 0) { + panel.style.display = 'none'; + return; + } + panel.style.display = 'flex'; + + var body = document.getElementById('usp-body'); + if (!body) return; + if (panel.classList.contains('collapsed')) return; + + var html = ''; + + // Spell stack entries (LIFO — top resolves first) + for (var i = spellStack.length - 1; i >= 0; i--) { + var entry = spellStack[i]; + var isTop = (i === spellStack.length - 1); + var cPl = gameState.players[entry.ownerIndex]; + var cColor = cPl ? cPl.color : '#888'; + var cName = cPl ? cPl.name : 'Unknown'; + html += '
'; + if (entry.imageUrl) { + html += '' + escapeHtml(entry.name) + ''; + } else { + html += '
✦
'; + } + html += '
'; + if (isTop) html += '
Resolving Next
'; + html += '
' + escapeHtml(entry.name) + '
'; + if (entry.typeLine) html += ''; + if (entry.manaCost) html += ''; + html += '
' + escapeHtml(cName) + '
'; + html += '
'; + if (isTop) html += ''; + html += '
'; + } + + // Trigger stack entries + for (var j = 0; j < trigStack.length; j++) { + var trig = trigStack[j]; + var isFirst = (j === 0 && spellStack.length === 0); + var tPl = gameState.players[trig.ownerIndex]; + var tColor = tPl ? tPl.color : '#888'; + var tName = tPl ? tPl.name : 'Unknown'; + var tIcon = TRIGGER_TYPE_ICONS[trig.type] || '⚡'; + html += '
'; + html += '
' + tIcon + '
'; + html += '
'; + if (isFirst) html += '
Trigger Next
'; + html += '
' + escapeHtml(trig.cardName) + '
'; + html += ''; + if (trig.effectHint) { + html += ''; + } + html += '
' + escapeHtml(tName) + '
'; + html += '
'; + if (isFirst) html += ''; + html += ''; + html += '
'; + } + + // Actions bar + html += '
'; + if (spellStack.length > 1) { + html += ''; + } + if (trigStack.length > 1) { + html += ''; + } + if (spellStack.length > 0) { + html += ''; + html += ''; + } + html += '
'; + + body.innerHTML = html; +} + + +function resolveTopOfStack() { + if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return; + pushUndoState(); + var entry = gameState.theStack.pop(); + var typeLine = (entry.typeLine || '').toLowerCase(); + var isInstantOrSorcery = /instant|sorcery/.test(typeLine); + + if (isInstantOrSorcery) { + // Instants and sorceries resolve their effect, then go to the graveyard (not battlefield) + addLogEntry('\u2713 ' + escapeHtml(entry.name) + ' resolves:'); + if (entry.oracleText) { + addLogEntry('' + escapeHtml(entry.oracleText).substring(0, 200) + ''); + } + // Send to owner's graveyard + var owner = gameState.players[entry.ownerIndex]; + if (owner) { + owner.zones.graveyard.push({ + name: entry.name, + imageUrl: entry.imageUrl, + typeLine: entry.typeLine || '', + oracleText: entry.oracleText || '', + manaCost: entry.manaCost || '', + cmc: entry.cmc || 0, + power: entry.power || '', + toughness: entry.toughness || '', + pt: entry.pt || '', + }); + addLogEntry('\u2192 ' + escapeHtml(entry.name) + ' goes to ' + escapeHtml(owner.name) + "'s graveyard"); + } + // Fire 'cast' triggers on existing battlefield permanents (spell resolved) + fireBattlefieldTriggers('cast'); + updatePlayerZones(); + } else { + // Permanents (creatures, enchantments, artifacts, planeswalkers, lands) go to battlefield + addCardToBattlefield(entry.name, entry.imageUrl, entry.typeLine, entry.ownerIndex, undefined, undefined, entry.oracleText, + { manaCost: entry.manaCost || '', power: entry.power || '', toughness: entry.toughness || '', pt: entry.pt || '', cmc: entry.cmc || 0, loyalty: entry.loyalty || '', colorIdentity: entry.colorIdentity, keywords: entry.keywords }); + addLogEntry('\u2713 ' + escapeHtml(entry.name) + ' enters the battlefield'); + } + + renderStackPanel(); + renderHandArea(); + // After a spell resolves, open priority if there are more items on stack + if (isPriorityEnabled() && gameState.theStack.length > 0) { + openPriorityWindow('spell_resolve'); + } +} + +function passStackPriority() { + if (!gameState || !gameState.theStack) return; + var stack = gameState.theStack; + if (stack.length === 0) return; + var top = stack[stack.length - 1]; + top.passCount = (top.passCount || 0) + 1; + var playerCount = gameState.players.filter(function(p) { return !p.eliminated; }).length; + if (top.passCount >= playerCount) { + resolveTopOfStack(); + addLogEntry('📚 All players passed priority — spell resolves'); + } else { + // Cycle priority to next player + var nextIdx = (gameState.currentPlayerIndex + 1) % gameState.players.length; + while (gameState.players[nextIdx] && gameState.players[nextIdx].eliminated) { + nextIdx = (nextIdx + 1) % gameState.players.length; + } + var nextName = gameState.players[nextIdx] ? gameState.players[nextIdx].name : 'Next Player'; + addLogEntry('→ Priority passed to ' + nextName + ''); + renderStackPanel(); + } +} + +function holdStackPriority() { + if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return; + var top = gameState.theStack[gameState.theStack.length - 1]; + top.passCount = 0; // Reset pass count — active player is holding + var activePlayer = gameState.players[gameState.currentPlayerIndex]; + addLogEntry('⏸ ' + (activePlayer ? activePlayer.name : 'Active player') + ' is holding priority'); + renderStackPanel(); +} + +function clearEntireStack() { + if (!gameState) return; + pushUndoState(); + gameState.theStack = []; + renderStackPanel(); + addLogEntry('\ud83d\udcda Stack cleared'); +} + +// ============================================================ +// PRIORITY SYSTEM +// ============================================================ + +/** + * Check if the priority system is enabled. + * Priority is active when enforceTurnStructure is on. + */ +function isPriorityEnabled() { + return gameState && gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure; +} + +/** + * Open a priority window. Each player (starting with active player) + * gets a chance to act (cast instants, activate abilities) or pass. + * When ALL players have passed in succession, the priority window closes + * and the game proceeds (phase advances, spell resolves, etc.). + * + * @param {string} reason - why priority opened: 'phase', 'spell_cast', 'spell_resolve', 'trigger', 'combat_damage' + * @param {function} [onAllPass] - callback when all players pass priority in succession + */ +var _priorityOnAllPass = null; + +function openPriorityWindow(reason, onAllPass) { + if (!gameState || !isPriorityEnabled()) { + // If priority not enabled, just run the callback immediately + if (typeof onAllPass === 'function') onAllPass(); + return; + } + // If we're in the very beginning phase (auto-resolved), skip priority + var phase = PHASES[gameState.currentPhaseIndex]; + var phaseId = phase ? phase.id : ''; + + gameState.priority.active = true; + gameState.priority.playerIndex = gameState.currentPlayerIndex; + gameState.priority.passCount = 0; + gameState.priority.reason = reason; + gameState.priority.sourcePhase = phaseId; + _priorityOnAllPass = onAllPass || null; + + renderPriorityBanner(); +} + +/** + * Current player passes priority to the next non-eliminated player. + * If all players have passed in succession, close the priority window. + */ +function passPriority() { + if (!gameState || !gameState.priority.active) return; + + var currentPriorityPlayer = gameState.players[gameState.priority.playerIndex]; + var activePlayers = gameState.players.filter(function(p) { return !p.eliminated; }).length; + + gameState.priority.passCount++; + + // Log the pass + if (currentPriorityPlayer) { + addLogEntry('\u27a1 ' + currentPriorityPlayer.name + ' passes priority'); + } + + // Check if all active players have passed + if (gameState.priority.passCount >= activePlayers) { + closePriorityWindow(); + return; + } + + // Advance priority to next non-eliminated player + var next = (gameState.priority.playerIndex + 1) % gameState.players.length; + var safety = 0; + while (gameState.players[next].eliminated && safety < gameState.players.length) { + next = (next + 1) % gameState.players.length; + safety++; + } + gameState.priority.playerIndex = next; + + // If next player is AI, auto-pass after brief delay + if (gameState.players[next].isAI) { + renderPriorityBanner(); + setTimeout(function() { + if (gameState && gameState.priority.active && gameState.priority.playerIndex === next) { + passPriority(); + } + }, 600); + return; + } + + renderPriorityBanner(); +} + +/** + * Player wants to hold priority to cast an instant or activate an ability. + * This resets the pass count since a new action is being taken. + */ +function holdPriorityForAction() { + if (!gameState || !gameState.priority.active) return; + + var currentPriorityPlayer = gameState.players[gameState.priority.playerIndex]; + if (currentPriorityPlayer) { + addLogEntry('\u23f8 ' + currentPriorityPlayer.name + ' is acting (casting / activating)'); + } + + // Close the banner — player can play instants/flash cards or activate abilities. + // After they finish, they can click "Done Acting" which re-opens priority round. + gameState.priority.passCount = 0; // Reset passes since someone is acting + showPriorityActionMode(); +} + +/** + * Player is done acting. Reopen priority round starting with the active player + * (per MTG rules, after a spell/ability is added, the active player gets priority). + */ +function doneActing() { + if (!gameState || !gameState.priority.active) return; + // If something was put on the stack, start a new round of priority + gameState.priority.passCount = 0; + gameState.priority.playerIndex = gameState.currentPlayerIndex; + renderPriorityBanner(); +} + +/** + * Close the priority window and fire the callback. + */ +function closePriorityWindow() { + if (!gameState) return; + gameState.priority.active = false; + gameState.priority.passCount = 0; + gameState.priority.reason = ''; + hidePriorityBanner(); + + var callback = _priorityOnAllPass; + _priorityOnAllPass = null; + if (typeof callback === 'function') callback(); +} + +/** + * Render the priority banner UI. + */ +function renderPriorityBanner() { + var banner = document.getElementById('priority-banner'); + if (!banner) return; + if (!gameState || !gameState.priority.active) { + banner.style.display = 'none'; + return; + } + + banner.style.display = 'flex'; + + var player = gameState.players[gameState.priority.playerIndex]; + var playerName = player ? player.name : 'Unknown'; + var playerColor = player ? (player.color || '#C8A843') : '#C8A843'; + var isHuman = player && !player.isAI; + var isPlayer1 = gameState.priority.playerIndex === 0; + + var reasonText = ''; + var reason = gameState.priority.reason; + if (reason === 'phase') { + var phase = PHASES[gameState.currentPhaseIndex]; + reasonText = phase ? phase.label + ' — any responses?' : 'Phase change'; + } else if (reason === 'spell_cast') { + reasonText = 'Spell cast — any responses?'; + } else if (reason === 'spell_resolve') { + reasonText = 'Spell resolved — any responses?'; + } else if (reason === 'trigger') { + reasonText = 'Trigger fired — any responses?'; + } else if (reason === 'combat_damage') { + reasonText = 'Combat damage resolved — any responses?'; + } + + var labelEl = document.getElementById('priority-banner-label'); + var reasonEl = document.getElementById('priority-banner-reason'); + var actionsEl = document.getElementById('priority-banner-actions'); + + if (labelEl) { + labelEl.innerHTML = 'Priority: ' + escapeHtml(playerName) + ''; + if (gameState.priority.passCount > 0) { + var activePlayers = gameState.players.filter(function(p) { return !p.eliminated; }).length; + labelEl.innerHTML += ' (' + gameState.priority.passCount + '/' + activePlayers + ' passed)'; + } + } + if (reasonEl) reasonEl.textContent = reasonText; + + if (actionsEl) { + if (isPlayer1) { + actionsEl.innerHTML = '' + + ''; + } else if (isHuman) { + actionsEl.innerHTML = '' + + ''; + } else { + actionsEl.innerHTML = 'AI thinking...'; + } + } + + // Color-code the banner by player + banner.style.borderColor = playerColor; +} + +/** + * Show "Done Acting" mode when a player is holding priority. + */ +function showPriorityActionMode() { + var actionsEl = document.getElementById('priority-banner-actions'); + var reasonEl = document.getElementById('priority-banner-reason'); + if (reasonEl) reasonEl.textContent = 'Play instants, flash cards, or activate abilities. Click Done when finished.'; + if (actionsEl) { + actionsEl.innerHTML = ''; + } +} + +function hidePriorityBanner() { + var banner = document.getElementById('priority-banner'); + if (banner) banner.style.display = 'none'; +} + +/** + * Check if priority should block phase advancement. + * Returns true if priority is active (meaning we're waiting for players to pass). + */ +function isPriorityBlocking() { + return gameState && gameState.priority && gameState.priority.active; +} + +// ============================================================ +// FEATURE 4: TURN STRUCTURE ENFORCEMENT — LEGAL ACTIONS HINTS +// ============================================================ + +var PHASE_LEGAL_ACTIONS = { + 'beginning': 'Untap, upkeep triggers, draw — no spells or land plays allowed', + 'main1': 'Play a land (if not yet this turn), cast spells and activate abilities', + 'combat': 'Declare attackers, declare blockers, resolve combat damage (instants only)', + 'main2': 'Cast spells, activate abilities, play a land (if not yet this turn)', + 'end': 'Resolve end step triggers, discard to hand size (instants only)', +}; + +function renderLegalActionsBar() { + var bar = document.getElementById('legal-actions-bar'); + if (!bar) return; + + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceTurnStructure) { + bar.style.display = 'none'; + return; + } + + bar.style.display = 'flex'; + + var phaseId; + if (gameState.inCombat && gameState.currentPhaseIndex === 2) { + phaseId = 'combat'; + } else { + var phase = (typeof PHASES !== 'undefined' && PHASES[gameState.currentPhaseIndex]) ? PHASES[gameState.currentPhaseIndex] : null; + phaseId = phase ? phase.id : ''; + } + + var hint = PHASE_LEGAL_ACTIONS[phaseId] || 'No specific restrictions this phase'; + var warnings = []; + + var activePlayer = gameState.players[gameState.currentPlayerIndex]; + if (activePlayer) { + if (phaseId === 'beginning' || phaseId === 'end') { + warnings.push('Casting sorcery-speed spells not allowed'); + } + if (phaseId === 'main1' || phaseId === 'main2') { + if (gameState.landsPlayedThisTurn >= gameState.maxLandsPerTurn) { + warnings.push('Land drop used (' + gameState.landsPlayedThisTurn + '/' + gameState.maxLandsPerTurn + ')'); + } else { + warnings.push('Land drop available (' + gameState.landsPlayedThisTurn + '/' + gameState.maxLandsPerTurn + ')'); + } + } + } + + var html = '
ℹ' + hint + '
'; + if (warnings.length) { + html += '
'; + warnings.forEach(function(w) { + html += '' + w + ''; + }); + html += '
'; + } + bar.innerHTML = html; +} + +// ============================================================ +// TURN & PHASE MANAGEMENT +// ============================================================ + +// Check if any triggers or spells are pending on the stack +function hasUnresolvedStack() { + if (!gameState) return false; + var spellsPending = gameState.theStack && gameState.theStack.length > 0; + var triggersPending = gameState.triggerStack && gameState.triggerStack.filter(function(t) { return !t.resolved; }).length > 0; + return spellsPending || triggersPending; +} + +function showStackBlockedWarning() { + addLogEntry('\u26a0\ufe0f Resolve all triggers/spells on the stack before advancing.', 'rules'); + // Flash the unified stack panel to draw attention + var panel = document.getElementById('unified-stack-panel'); + if (panel) { + panel.classList.add('stack-flash'); + setTimeout(function() { panel.classList.remove('stack-flash'); }, 800); + } +} + +function nextPhase() { + if (!gameState) return; + // Block manual phase advances while AI is playing + if (aiTurnActive) return; + // Block phase advance while priority window is open + if (isPriorityBlocking()) { + addLogEntry('\u26a0\ufe0f Must resolve priority before advancing phases.'); + return; + } + // Block phase advance while stack has unresolved triggers or spells + if (hasUnresolvedStack()) { + showStackBlockedWarning(); + return; + } + pushUndoState(); + + // If we're in combat, cycle through combat sub-phases + if (gameState.currentPhaseIndex === 2 && gameState.inCombat) { + gameState.combatPhaseIndex++; + if (gameState.combatPhaseIndex >= COMBAT_PHASES.length) { + endCombat(); + gameState.inCombat = false; + gameState.combatPhaseIndex = 0; + gameState.currentPhaseIndex = 3; // Move to Main 2 + } else { + const combatPhase = COMBAT_PHASES[gameState.combatPhaseIndex]; + if (combatPhase.id === 'begin-combat') { + fireBattlefieldTriggers('combat_begin'); + } else if (combatPhase.id === 'attackers') { + // Pause and show attacker declaration UI + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + startAttackerDeclaration(); + return; // Don't advance further — wait for Confirm + } else if (combatPhase.id === 'blockers') { + // Pause and show blocker declaration UI + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + startBlockerDeclaration(); + return; // Don't advance further — wait for Confirm + } else if (combatPhase.id === 'damage') { + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + resolveCombatDamage(); + // Priority after combat damage + openPriorityWindow('combat_damage', function() { + setTimeout(function() { nextPhase(); }, 400); + }); + return; + } else if (combatPhase.id === 'end-combat') { + endCombat(); + } + } + } else { + // If entering combat, start combat sub-phases + if (gameState.currentPhaseIndex === 1) { // After Main 1 → Combat + gameState.currentPhaseIndex = 2; + gameState.inCombat = true; + gameState.combatPhaseIndex = 0; + // begin-combat is the first combat sub-phase (index 0) + fireBattlefieldTriggers('combat_begin'); + } else if (gameState.currentPhaseIndex < PHASES.length - 1) { + gameState.currentPhaseIndex++; + } else { + // End of turn — advance to next player + nextTurn(); + return; + } + } + + // Phase-specific trigger firing + const phase = PHASES[gameState.currentPhaseIndex]; + if (phase) { + if (phase.id === 'end') { + fireBattlefieldTriggers('end_step'); + // Monarch: draw extra card at end step + if (gameState.monarchPlayerIndex !== undefined && gameState.monarchPlayerIndex !== null) { + var monarchP = gameState.players[gameState.monarchPlayerIndex]; + if (monarchP && !monarchP.eliminated && monarchP.zones.library.length > 0) { + var monarchCard = monarchP.zones.library.shift(); + monarchP.zones.hand.push(monarchCard); + addLogEntry('\ud83d\udc51 ' + monarchP.name + ' draws a card as the Monarch.'); + renderHandArea(); + } + } + checkHandSize(); + // Cleanup step: clear damage counters and 'until end of turn' effects + performCleanupStep(); + } + } + + // Mana pools empty between phases (MTG rules) + emptyAllManaPools(); + + renderPhaseTracker(); + renderHandArea(); // Re-render hand to update phase-locked visual state + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + showShortcutHint(); + + // Open priority window for the new phase (skipped during Beginning phase since it auto-resolves) + if (isPriorityEnabled()) { + var newPhase = PHASES[gameState.currentPhaseIndex]; + var newPhaseId = newPhase ? newPhase.id : ''; + // Skip priority during beginning phase (it auto-resolves untap/upkeep/draw in nextTurn) + if (newPhaseId !== 'beginning') { + openPriorityWindow('phase'); + } + } +} + +// Feature 12: Turn Timer / Chess Clock +var _turnTimerInterval = null; + +function initTurnTimers() { + if (!gameState || !gameState.turnTimer) return; + gameState.players.forEach(function(p) { + if (gameState.turnTimer.timePerPlayer[p.id] === undefined) { + var mins = (gameState.rulesEngine && gameState.rulesEngine.turnTimerMinutes) || 30; + gameState.turnTimer.timePerPlayer[p.id] = mins * 60; + } + }); +} + +function startTurnTimer(playerId) { + if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.turnTimerEnabled) return; + stopTurnTimer(); + initTurnTimers(); + gameState.turnTimer.activePlayerId = playerId; + _turnTimerInterval = setInterval(function() { + if (!gameState || !gameState.turnTimer) return; + var pid = gameState.turnTimer.activePlayerId; + if (pid === null || pid === undefined) return; + var remaining = gameState.turnTimer.timePerPlayer[pid]; + if (remaining === undefined) return; + remaining = Math.max(0, remaining - 1); + gameState.turnTimer.timePerPlayer[pid] = remaining; + updateTimerDisplay(pid); + if (remaining === 0) { + addLogEntry('⏱ ⚠ ' + gameState.players[pid].name + ' time is up!'); + flashTimerWarning(pid); + } + }, 1000); + gameState.turnTimer.intervalId = _turnTimerInterval; +} + +function stopTurnTimer() { + if (_turnTimerInterval) { clearInterval(_turnTimerInterval); _turnTimerInterval = null; } + if (gameState && gameState.turnTimer) { gameState.turnTimer.activePlayerId = null; gameState.turnTimer.intervalId = null; } +} + +function formatTimerTime(seconds) { + var m = Math.floor(seconds / 60); + var s = seconds % 60; + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; +} + +function updateTimerDisplay(playerId) { + var el = document.getElementById('timer-display-' + playerId); + if (!el || !gameState || !gameState.turnTimer) return; + var remaining = gameState.turnTimer.timePerPlayer[playerId]; + if (remaining === undefined) { el.style.display = 'none'; return; } + el.style.display = 'flex'; + el.textContent = formatTimerTime(remaining); + el.className = 'player-timer-display'; + if (remaining <= 60) el.classList.add('timer-critical'); + else if (remaining <= 180) el.classList.add('timer-warning'); +} + +function flashTimerWarning(playerId) { + var panel = document.getElementById('player-panel-' + playerId); + if (!panel) return; + panel.classList.add('timer-expired'); + setTimeout(function() { panel.classList.remove('timer-expired'); }, 2000); +} + +function renderAllTimerDisplays() { + if (!gameState) return; + gameState.players.forEach(function(p) { updateTimerDisplay(p.id); }); +} + +function nextTurn() { + if (!gameState) return; + // Block manual turn advances while AI is playing + if (aiTurnActive) return; + // Block turn advance while priority window is open + if (isPriorityBlocking()) { + addLogEntry('\u26a0\ufe0f Must resolve priority before advancing turns.'); + return; + } + // Block turn advance while stack has unresolved triggers or spells + if (hasUnresolvedStack()) { + showStackBlockedWarning(); + return; + } + pushUndoState(); + + // Perform cleanup step for the ending turn + performCleanupStep(); + + // Find next active player + let nextIdx = (gameState.currentPlayerIndex + 1) % gameState.players.length; + let attempts = 0; + while (gameState.players[nextIdx].eliminated && attempts < gameState.players.length) { + nextIdx = (nextIdx + 1) % gameState.players.length; + attempts++; + } + + // If all are eliminated, don't advance + if (attempts >= gameState.players.length) return; + + // Increment turn number when we wrap around to a lower index (completing a round) + if (nextIdx <= gameState.currentPlayerIndex) { + gameState.turnNumber++; + } + + gameState.currentPlayerIndex = nextIdx; + gameState.currentPhaseIndex = 0; + gameState.inCombat = false; + gameState.combatPhaseIndex = 0; + gameState.stormCount = 0; + gameState.landsPlayedThisTurn = 0; + // Reset combat state for new turn + gameState.combat = { + attackers: [], + blockers: [], + damageAssignments: [], + phase: null, + pendingBlockerFor: null, + damagePreview: null, + }; + hideCombatOverlay(); + + const manaSelect = document.getElementById('mana-pool-player-select'); + if (manaSelect) manaSelect.value = nextIdx; + renderManaPool(); + + const activePlayer = gameState.players[nextIdx]; + + // Auto-untap active player's permanents (Beginning Phase — Untap Step) + let untapCount = 0; + gameState.battlefieldCards.forEach(card => { + if (card.ownerId === nextIdx && card.tapped) { + card.tapped = false; + untapCount++; + const cardEl = document.getElementById(`bc-${card.id}`); + if (cardEl) cardEl.classList.remove('tapped'); + } + }); + // Feature 3: Clear damage-this-turn counters at start of each new turn + clearAllDamageCounters(); + + // Reset planeswalker loyalty ability usage for the new turn + resetPlaneswalkerLoyaltyUsage(); + + // Forge CardScript: Clear temporary keywords and reset SpellAbility once-per-turn flags + gameState.battlefieldCards.forEach(function(card) { + if (card.tempKeywords) card.tempKeywords = []; + if (card.powerMod !== undefined && card._tempPowerMod) { card.powerMod -= card._tempPowerMod; card._tempPowerMod = 0; } + if (card.toughnessMod !== undefined && card._tempToughMod) { card.toughnessMod -= card._tempToughMod; card._tempToughMod = 0; } + }); + if (typeof CardScriptManager !== 'undefined') { CardScriptManager.clearCache(); } + if (untapCount > 0) { + addLogEntry(`✨ ${activePlayer.name} untapped ${untapCount} permanent${untapCount > 1 ? 's' : ''}`); + } + + // Clear summoning sickness visuals (turn changed, so previous creatures are no longer sick) + document.querySelectorAll('.battlefield-card.summoning-sick').forEach(el => el.classList.remove('summoning-sick')); + // Re-apply to any creatures that still have it this turn + gameState.battlefieldCards.forEach(card => { + if (hasSummoningSickness(card)) { + const cardEl = document.getElementById(`bc-${card.id}`); + if (cardEl) cardEl.classList.add('summoning-sick'); + } + }); + + // Fire upkeep triggers (Beginning Phase — Upkeep Step) + addLogEntry('Phase: Upkeep Step -- checking battlefield triggers'); + fireBattlefieldTriggers('upkeep'); + + // Priority window during Upkeep (before draw) — if priority is enabled + if (isPriorityEnabled()) { + addLogEntry('Phase: Upkeep \u2014 priority open before draw step'); + renderPhaseTracker(); + openPriorityWindow('upkeep', function() { + // After upkeep priority resolves, proceed to draw step + _proceedToDrawStep(nextIdx, activePlayer); + }); + return; // Wait for priority to resolve + } + + // No priority — draw immediately + _proceedToDrawStep(nextIdx, activePlayer); +} + +/** + * Internal: Proceed from upkeep to draw step and then to Main 1. + * Called after upkeep priority resolves (or immediately if priority is off). + */ +function _proceedToDrawStep(nextIdx, activePlayer) { + if (!gameState) return; + // Auto-draw a card (Beginning Phase — Draw Step) + if (activePlayer.zones.library.length > 0) { + var drawnCard = activePlayer.zones.library.shift(); + activePlayer.zones.hand.push(drawnCard); + addLogEntry('\ud83d\udce4 ' + activePlayer.name + ' drew a card for turn'); + } else { + addLogEntry('\u26a0\ufe0f ' + activePlayer.name + ' cannot draw \u2014 library is empty!'); + } + + // Auto-advance past Beginning phase to Main 1 + gameState.currentPhaseIndex = 1; + + updateStormCounter(); + + // Track turn history + gameState.turnHistory.push({ + turnNumber: gameState.turnNumber, + playerIndex: nextIdx, + playerName: activePlayer.name, + playerColor: activePlayer.color || PLAYER_COLORS[nextIdx] || '#666', + }); + // Keep max 20 entries + if (gameState.turnHistory.length > 20) gameState.turnHistory.shift(); + + renderPhaseTracker(); + renderPlayerPanels(); + renderHandArea(); // Re-render hand to update phase-locked visual state + addLogEntry(`Turn ${gameState.turnNumber}: ${activePlayer.name}`); + updateTurnDisplay(); + showShortcutHint(); + // Feature 12: Turn timer + if (gameState.rulesEngine && gameState.rulesEngine.turnTimerEnabled) { + startTurnTimer(nextIdx); + } + + // If the new active player is AI, auto-play their turn + if (activePlayer.isAI && !aiTurnActive) { + setTimeout(() => runAiTurn(), 800); + } else if (isPriorityEnabled()) { + // Open priority for Main Phase 1 start — gives all players a chance to respond + openPriorityWindow('phase'); + } +} + +function getCurrentPhaseName() { + if (gameState.inCombat) { + return `Combat — ${COMBAT_PHASES[gameState.combatPhaseIndex]?.label || ''}`; + } + return PHASES[gameState.currentPhaseIndex]?.label || ''; +} + +function renderPhaseTracker() { + renderLegalActionsBar(); + const track = document.getElementById('phase-track'); + if (!track || !gameState) return; + + track.innerHTML = ''; + + // If in combat, show expanded combat sub-phases instead of the flat 5 + if (gameState.inCombat && gameState.currentPhaseIndex === 2) { + // Show a condensed pre-combat indicator + const preCombat = document.createElement('div'); + preCombat.className = 'phase-step phase-step-mini done'; + preCombat.textContent = 'MAIN 1'; + preCombat.title = 'Main Phase 1 (completed)'; + preCombat.onclick = function() { jumpToPhase(1); }; + track.appendChild(preCombat); + + // Connector + const conn = document.createElement('div'); + conn.className = 'phase-connector'; + track.appendChild(conn); + + // Combat sub-phases expanded + COMBAT_PHASES.forEach((cp, ci) => { + const step = document.createElement('div'); + step.className = 'phase-step phase-step-combat'; + + const isActive = ci === gameState.combatPhaseIndex; + const isDone = ci < gameState.combatPhaseIndex; + + if (isActive) step.classList.add('active'); + if (isDone) step.classList.add('done'); + + step.innerHTML = `${cp.icon}${cp.short}`; + step.title = cp.label; + if (isActive) { + step.style.setProperty('--phase-accent', cp.color); + step.style.background = hexToRgba(cp.color, 0.18); + step.style.boxShadow = '0 0 10px ' + hexToRgba(cp.color, 0.25) + ', inset 0 0 0 1px ' + hexToRgba(cp.color, 0.25); + } + + track.appendChild(step); + + // Connector between sub-phases + if (ci < COMBAT_PHASES.length - 1) { + const subConn = document.createElement('div'); + subConn.className = 'phase-connector' + (isDone ? ' done' : ''); + track.appendChild(subConn); + } + }); + + // Post-combat connector + indicator + const postConn = document.createElement('div'); + postConn.className = 'phase-connector'; + track.appendChild(postConn); + + const postCombat = document.createElement('div'); + postCombat.className = 'phase-step phase-step-mini'; + postCombat.textContent = 'MAIN 2'; + postCombat.title = 'Main Phase 2'; + postCombat.onclick = function() { jumpToPhase(3); }; + track.appendChild(postCombat); + } else { + // Normal 5-phase display + PHASES.forEach((phase, i) => { + const step = document.createElement('div'); + step.className = 'phase-step'; + + const isActive = i === gameState.currentPhaseIndex; + const isDone = i < gameState.currentPhaseIndex; + + if (isActive) step.classList.add('active'); + if (isDone) step.classList.add('done'); + + step.innerHTML = `${phase.icon}${phase.short}`; + step.title = phase.tip; + step.onclick = function() { jumpToPhase(i); }; + if (isActive) { + step.style.setProperty('--phase-accent', phase.color); + step.style.background = hexToRgba(phase.color, 0.18); + step.style.boxShadow = '0 0 10px ' + hexToRgba(phase.color, 0.25) + ', inset 0 0 0 1px ' + hexToRgba(phase.color, 0.25); + } + + track.appendChild(step); + + // Connector between phases + if (i < PHASES.length - 1) { + const conn = document.createElement('div'); + conn.className = 'phase-connector' + (isDone ? ' done' : ''); + track.appendChild(conn); + } + }); + } + + // Land drop indicator + if (gameState && gameState.landsPlayedThisTurn !== undefined) { + var landIndicator = document.createElement('div'); + landIndicator.className = 'land-drop-indicator'; + var remaining = Math.max(0, gameState.maxLandsPerTurn - gameState.landsPlayedThisTurn); + landIndicator.innerHTML = '🏔 ' + remaining + '/' + gameState.maxLandsPerTurn; + landIndicator.title = remaining > 0 ? 'Land drops remaining this turn' : 'No land drops remaining'; + if (remaining === 0) landIndicator.classList.add('exhausted'); + track.appendChild(landIndicator); + } + + // Render turn history ribbon + renderTurnHistory(); +} + +/** + * Jump to a specific main phase index. Only allows forward movement + * within the current turn (no going back to prevent rule violations). + * Fires appropriate triggers for each skipped/entered phase. + */ +function jumpToPhase(targetIdx) { + if (!gameState) return; + // Don't allow jumping backwards (would break turn order rules) + if (targetIdx <= gameState.currentPhaseIndex) return; + // Don't allow jumping during active combat + if (gameState.inCombat) { + // Allow jumping past combat to main2 or end + if (targetIdx > 2) { + endCombat(); + gameState.inCombat = false; + gameState.combatPhaseIndex = 0; + } else { + return; + } + } + pushUndoState(); + + // Skip from current to target, firing triggers for each intermediate phase + for (let i = gameState.currentPhaseIndex + 1; i <= targetIdx; i++) { + gameState.currentPhaseIndex = i; + const phase = PHASES[i]; + if (phase) { + if (phase.id === 'combat') { + // Don't enter combat — skip it + continue; + } else if (phase.id === 'beginning') { + fireBattlefieldTriggers('upkeep'); + } else if (phase.id === 'end') { + fireBattlefieldTriggers('end_step'); + } + } + emptyAllManaPools(); + } + + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()} (jumped)`); + showShortcutHint(); +} + +/** + * Render the turn history ribbon below the phase track. + */ +function renderTurnHistory() { + const ribbon = document.getElementById('turn-history-ribbon'); + if (!ribbon || !gameState || !gameState.turnHistory) return; + + const history = gameState.turnHistory; + if (history.length <= 1) { + ribbon.style.display = 'none'; + return; + } + + ribbon.style.display = 'flex'; + ribbon.innerHTML = ''; + + // Show recent entries (last 8) + const visible = history.slice(-8); + visible.forEach((entry, i) => { + const isCurrent = (i === visible.length - 1); + const chip = document.createElement('div'); + chip.className = 'turn-history-chip' + (isCurrent ? ' current' : ''); + chip.style.setProperty('--chip-color', entry.playerColor); + if (isCurrent) { + chip.style.background = hexToRgba(entry.playerColor, 0.15); + chip.style.border = '1px solid ' + hexToRgba(entry.playerColor, 0.25); + } + chip.innerHTML = `T${entry.turnNumber}`; + chip.title = `Turn ${entry.turnNumber}: ${entry.playerName}`; + ribbon.appendChild(chip); + }); +} + +/** + * Flash the active phase step with a trigger-fired animation. + */ +function flashActivePhaseStep() { + const activeStep = document.querySelector('.phase-step.active'); + if (!activeStep) return; + activeStep.classList.remove('trigger-flash'); + activeStep.offsetWidth; // force reflow + activeStep.classList.add('trigger-flash'); + setTimeout(() => activeStep.classList.remove('trigger-flash'), 800); +} + +function updateTurnDisplay() { + if (!gameState) return; + + const turnEl = document.getElementById('turn-number'); + if (turnEl) turnEl.textContent = gameState.turnNumber; + + const activePlayerEl = document.getElementById('active-player-name'); + if (activePlayerEl) { + const player = gameState.players[gameState.currentPlayerIndex]; + activePlayerEl.textContent = player?.name || ''; + } + + const activeDot = document.getElementById('active-player-dot'); + if (activeDot) { + const player = gameState.players[gameState.currentPlayerIndex]; + activeDot.style.background = player?.color || '#666'; + } +} + +// ============================================================ +// STORM COUNTER +// ============================================================ + +function updateStormCounter() { + const badge = document.getElementById('storm-counter'); + if (!badge) return; + if (gameState.stormCount > 0) { + badge.textContent = `Storm: ${gameState.stormCount}`; + badge.classList.add('visible'); + } else { + badge.classList.remove('visible'); + } +} + +// ============================================================ +// ZONE MODAL +// ============================================================ + +// zoneModalPlayerId declared in ui.js +// activeZoneTab declared in ui.js +// _zoneCardCtx declared in ui.js + +function openZoneModal(playerId) { + zoneModalPlayerId = playerId; + activeZoneTab = 'graveyard'; + renderZoneModal(); + + const modal = document.getElementById('zone-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeZoneModal() { + const modal = document.getElementById('zone-modal'); + if (modal) modal.classList.remove('visible'); + zoneModalPlayerId = null; + hideZoneCardCtx(); +} + +function hideZoneCardCtx() { + if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible'); +} + +function renderZoneModal() { + if (zoneModalPlayerId === null) return; + const player = gameState.players[zoneModalPlayerId]; + if (!player) return; + + const titleEl = document.getElementById('zone-modal-title'); + if (titleEl) titleEl.textContent = player.name + "'s Zones"; + + // Player color indicator + var playerBar = document.getElementById('zone-modal-player-bar'); + if (!playerBar) { + playerBar = document.createElement('div'); + playerBar.id = 'zone-modal-player-bar'; + playerBar.className = 'zone-modal-player-bar'; + var header = document.querySelector('.zone-modal-header'); + if (header) header.parentNode.insertBefore(playerBar, header.nextSibling); + } + playerBar.innerHTML = + '
' + + '' + player.name + (player.isAI ? ' (AI)' : '') + ''; + + // Update tabs with counts and zone-specific styling + const tabs = document.querySelectorAll('.zone-tab'); + tabs.forEach(function(tab) { + var zone = tab.dataset.zone; + tab.classList.toggle('active', zone === activeZoneTab); + var count = (player.zones[zone] || []).length; + var label = zone.charAt(0).toUpperCase() + zone.slice(1); + var icons = { graveyard: '\u2620', exile: '\u2b21', command: '\u{1F451}', hand: '\u270B', library: '\uD83D\uDCDA' }; + tab.innerHTML = (icons[zone] || '') + ' ' + label + ' (' + count + ')'; + }); + + // Render zone content + const body = document.getElementById('zone-modal-body'); + if (!body) return; + + const cards = player.zones[activeZoneTab] || []; + + if (!cards.length) { + body.innerHTML = '
No cards in ' + activeZoneTab + '.
'; + return; + } + + // Info bar + var infoHtml = '
' + + '' + cards.length + ' card' + (cards.length !== 1 ? 's' : '') + ' in ' + activeZoneTab + '' + + '
'; + + // Library-specific buttons + if (activeZoneTab === 'library') { + infoHtml += + '' + + '' + + ''; + } + + infoHtml += '
'; + + body.innerHTML = infoHtml + '
'; + const grid = body.querySelector('.zone-cards-grid'); + + cards.forEach(function(card, idx) { + var thumb = document.createElement('div'); + thumb.className = 'zone-card-thumb'; + thumb.title = card.name + (card.typeLine ? ' \u2014 ' + card.typeLine : ''); + + // Type badge + var typeShort = ''; + var tl = (card.typeLine || '').toLowerCase(); + if (tl.includes('creature')) typeShort = 'Creature'; + else if (tl.includes('instant')) typeShort = 'Instant'; + else if (tl.includes('sorcery')) typeShort = 'Sorcery'; + else if (tl.includes('artifact')) typeShort = 'Artifact'; + else if (tl.includes('enchantment')) typeShort = 'Enchant.'; + else if (tl.includes('planeswalker')) typeShort = 'Walker'; + else if (tl.includes('land')) typeShort = 'Land'; + + var typeBadgeHtml = typeShort ? '
' + typeShort + '
' : ''; + + thumb.innerHTML = + '' + (card.name || '').replace(/' + + typeBadgeHtml + + '
' + (card.name || 'Unknown') + '
'; + + // Hover preview + thumb.setAttribute('onmouseenter', "showFloatingCardPreview('" + (card.imageUrl || '').replace(/'/g, "\\'") + "', event, { name: '" + (card.name || '').replace(/'/g, "\\'") + "', typeLine: '" + (card.typeLine || '').replace(/'/g, "\\'") + "', oracleText: '" + (card.oracleText || '').replace(/'/g, "\\'").replace(/\n/g, '\\n') + "' })"); + thumb.setAttribute('onmousemove', 'positionFloatingPreview(event)'); + thumb.setAttribute('onmouseleave', 'hideFloatingCardPreview()'); + + // Left-click: play to battlefield + thumb.setAttribute('onclick', '(function(){zmPlayCard(' + zoneModalPlayerId + ',"' + activeZoneTab + '",' + idx + ')})()'); + + // Right-click: context menu + thumb.setAttribute('oncontextmenu', '(function(e){e.preventDefault();showZoneCardCtx(e.clientX,e.clientY,' + zoneModalPlayerId + ',"' + activeZoneTab + '",' + idx + ')})(event)'); + + grid.appendChild(thumb); + }); +} + +// Zone modal — play card to battlefield +function zmPlayCard(playerId, zone, cardIndex) { + pushUndoState(); + var player = gameState.players[playerId]; + if (!player) return; + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + // Commander tax tracking + if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) { + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + } + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords }); + addLogEntry('' + card.name + ' moved from ' + player.name + "'s " + zone + ' to battlefield'); + updatePlayerZones(); + renderZoneModal(); +} + +// Zone modal context menu +function showZoneCardCtx(x, y, playerId, zone, cardIndex) { + hideZoneCardCtx(); + if (!_zoneCardCtx) { + _zoneCardCtx = document.createElement('div'); + _zoneCardCtx.className = 'zone-card-ctx'; + _zoneCardCtx.id = 'zone-card-ctx'; + document.body.appendChild(_zoneCardCtx); + document.addEventListener('click', function(e) { + if (_zoneCardCtx && !_zoneCardCtx.contains(e.target)) hideZoneCardCtx(); + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') hideZoneCardCtx(); + }); + } + + var player = gameState.players[playerId]; + var card = player && player.zones[zone] ? player.zones[zone][cardIndex] : null; + if (!card) return; + + var items = []; + items.push({ icon: '\u2694\ufe0f', label: 'Play to Battlefield', action: 'battlefield' }); + if (zone !== 'hand') items.push({ icon: '\u270B', label: 'Return to Hand', action: 'hand' }); + items.push({ divider: true }); + if (zone !== 'graveyard') items.push({ icon: '\u2620', label: 'Send to Graveyard', action: 'graveyard' }); + if (zone !== 'exile') items.push({ icon: '\u2b21', label: 'Send to Exile', action: 'exile' }); + if (zone !== 'command') items.push({ icon: '\u{1F451}', label: 'Send to Command Zone', action: 'command' }); + items.push({ divider: true }); + items.push({ icon: '\uD83D\uDCDA', label: 'Top of Library', action: 'library-top' }); + items.push({ icon: '\uD83D\uDCDA', label: 'Bottom of Library', action: 'library-bottom' }); + items.push({ divider: true }); + items.push({ icon: '\u2715', label: 'Remove from Game', action: 'remove', danger: true }); + + _zoneCardCtx.innerHTML = '
' + card.name + '
'; + items.forEach(function(item) { + if (item.divider) { + var div = document.createElement('div'); + div.className = 'zone-card-ctx-divider'; + _zoneCardCtx.appendChild(div); + return; + } + var btn = document.createElement('button'); + btn.className = 'zone-card-ctx-item' + (item.danger ? ' danger' : ''); + btn.innerHTML = '' + item.icon + ' ' + item.label; + btn.setAttribute('onclick', '(function(){hideZoneCardCtx();handleZmCtxAction(' + playerId + ',"' + zone + '",' + cardIndex + ',"' + item.action + '")})()'); + _zoneCardCtx.appendChild(btn); + }); + + // Position + _zoneCardCtx.style.left = Math.min(x, window.innerWidth - 200) + 'px'; + _zoneCardCtx.style.top = Math.min(y, window.innerHeight - 300) + 'px'; + _zoneCardCtx.classList.add('visible'); +} + +function handleZmCtxAction(playerId, zone, cardIndex, action) { + pushUndoState(); + var player = gameState.players[playerId]; + if (!player) return; + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards[cardIndex]; + var zoneLabels = { graveyard: 'graveyard', exile: 'exile', command: 'command zone', hand: 'hand', library: 'library' }; + + if (action === 'battlefield') { + cards.splice(cardIndex, 1); + if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) { + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + } + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords }); + addLogEntry('' + card.name + ' moved from ' + (zoneLabels[zone] || zone) + ' to battlefield'); + } else if (action === 'hand') { + cards.splice(cardIndex, 1); + player.zones.hand.push(card); + addLogEntry('' + card.name + ' returned to ' + player.name + "'s hand from " + (zoneLabels[zone] || zone)); + } else if (action === 'graveyard' || action === 'exile' || action === 'command') { + cards.splice(cardIndex, 1); + player.zones[action].push(card); + addLogEntry('' + card.name + ' moved to ' + (zoneLabels[action] || action) + ' from ' + (zoneLabels[zone] || zone)); + } else if (action === 'library-top') { + cards.splice(cardIndex, 1); + player.zones.library.unshift(card); + addLogEntry('' + card.name + ' put on top of ' + player.name + "'s library"); + } else if (action === 'library-bottom') { + cards.splice(cardIndex, 1); + player.zones.library.push(card); + addLogEntry('' + card.name + ' put on bottom of ' + player.name + "'s library"); + } else if (action === 'remove') { + cards.splice(cardIndex, 1); + addLogEntry('' + card.name + ' removed from game'); + } + updatePlayerZones(); + renderZoneModal(); + renderHandArea(); +} + +// ============================================================ +// LIBRARY FUNCTIONS +// ============================================================ + +function moveCardToLibrary(cardId, position) { + pushUndoState(); + const idx = gameState.battlefieldCards.findIndex(c => c.id === cardId); + if (idx === -1) return; + const cardData = gameState.battlefieldCards[idx]; + const owner = gameState.players[cardData.ownerIndex]; + if (!owner) return; + + // Clean up stack membership before leaving battlefield + cleanupCardFromStack(cardId); + + // Tokens cease to exist when they leave the battlefield + if (cardData.isToken) { + fireBattlefieldTriggers('ltb', bfCard => bfCard.id !== cardData.id); + fireCardTriggers(cardData, ['ltb']); + gameState.battlefieldCards.splice(idx, 1); + const cardEl = document.getElementById('bc-' + cardId); + if (cardEl) { + cardEl.style.transition = 'opacity 0.3s, transform 0.3s'; + cardEl.style.opacity = '0'; + cardEl.style.transform = 'scale(0.8)'; + setTimeout(() => cardEl.remove(), 300); + } + addLogEntry(`${cardData.name} ceased to exist (token)`); + if (!gameState.battlefieldCards.length) { + const label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + updateBattlefieldSectionCounts(); + updatePlayerZones(); + return; + } + + const cardObj = { + name: cardData.name, + imageUrl: cardData.imageUrl, + typeLine: cardData.typeLine, + oracleText: cardData.oracleText || '', + manaCost: cardData.manaCost || '', + power: cardData.power, + toughness: cardData.toughness, + loyalty: cardData.loyalty, + }; + + // Fire LTB triggers + fireBattlefieldTriggers('ltb', bfCard => bfCard.id !== cardData.id); + fireCardTriggers(cardData, ['ltb']); + + if (position === 'top') { + owner.zones.library.unshift(cardObj); // top of library = index 0 + } else { + owner.zones.library.push(cardObj); // bottom = last index + } + + // Remove from battlefield + gameState.battlefieldCards.splice(idx, 1); + const cardEl = document.getElementById('bc-' + cardId); + if (cardEl) { + cardEl.style.transition = 'opacity 0.3s, transform 0.3s'; + cardEl.style.opacity = '0'; + cardEl.style.transform = 'scale(0.8)'; + setTimeout(() => cardEl.remove(), 300); + } + + addLogEntry('' + cardData.name + ' put on ' + position + ' of ' + owner.name + "'s library"); + + if (!gameState.battlefieldCards.length) { + const label = document.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + updateBattlefieldSectionCounts(); + updatePlayerZones(); +} + +function drawCard(playerId) { + if (!gameState) return; + pushUndoState(); + + // Apply replacement effects to draw + var drawEvent = applyReplacements('draw', { playerIdx: playerId }); + if (!drawEvent) return; // Draw was fully replaced/prevented + var actualPlayer = drawEvent.playerIdx; + + const player = gameState.players[actualPlayer]; + if (!player) return; + + if (player.zones.library.length === 0) { + addLogEntry('' + player.name + ' tried to draw but library is empty!'); + return; + } + + const card = player.zones.library.shift(); // Take from top (index 0) + player.zones.hand.push(card); + addLogEntry('' + player.name + ' drew a card (' + player.zones.hand.length + ' in hand)'); + updatePlayerZones(); + + // Fire draw triggers for all battlefield permanents watching for card draw + fireBattlefieldTriggers('draw'); + + // If zone modal is open, re-render + if (zoneModalPlayerId === actualPlayer) renderZoneModal(); +} + +// ============================================================ +// MULLIGAN SYSTEM (London Mulligan — Commander variant) +// Commander: first mulligan is free (no cards put back). +// Each subsequent mulligan: draw 7, then put N on bottom. +// ============================================================ + +/** + * Start or continue a mulligan for a player. + * - Shuffles current hand back into library + * - Shuffles library + * - Draws 7 new cards + * - Increments mulliganCount + * - If mulliganCount > 1 (past the free mull), enters "bottom" phase + */ +function performMulligan(playerId) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + + // Initialize mulligan tracking if needed + if (player.mulliganCount === undefined) player.mulliganCount = 0; + + // Put hand back into library + while (player.zones.hand.length > 0) { + player.zones.library.push(player.zones.hand.pop()); + } + + // Shuffle library (Fisher-Yates) + 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]]; + } + + // Draw 7 new cards + for (let i = 0; i < 7 && player.zones.library.length > 0; i++) { + player.zones.hand.push(player.zones.library.shift()); + } + + player.mulliganCount++; + + // Commander free mulligan: first mulligan (count=1) is free, no cards go back + // Second mulligan (count=2): put 1 on bottom, third: put 2, etc. + const cardsToPutBack = Math.max(0, player.mulliganCount - 1); + + if (cardsToPutBack > 0) { + // Enter "pick cards to put on bottom" phase + player.mulliganBottomCount = cardsToPutBack; + player.mulliganPhase = 'bottom'; // waiting for player to pick cards + addLogEntry(`🔄 ${player.name} mulliganed (${player.mulliganCount}x) — must put ${cardsToPutBack} card${cardsToPutBack > 1 ? 's' : ''} on the bottom`); + } else { + // Free mulligan, no cards to put back + player.mulliganPhase = 'deciding'; // can keep or mull again + addLogEntry(`🔄 ${player.name} took a free mulligan — drew 7 new cards`); + } + + updatePlayerZones(); + renderHandArea(); + renderMulliganUI(); +} + +/** + * Player keeps their current hand — ends mulligan phase. + */ +function keepHand(playerId) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + + player.mulliganPhase = null; + player.mulliganBottomCount = 0; + + const mullCount = player.mulliganCount || 0; + if (mullCount === 0) { + addLogEntry(`✋ ${player.name} kept their opening hand`); + } else { + addLogEntry(`✋ ${player.name} kept their hand after ${mullCount} mulligan${mullCount > 1 ? 's' : ''}`); + } + + renderHandArea(); + renderMulliganUI(); +} + +/** + * Put a specific card from hand on the bottom of the library (mulligan bottom phase). + */ +function mulliganPutOnBottom(playerId, handIndex) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player || player.mulliganPhase !== 'bottom') return; + + const card = player.zones.hand.splice(handIndex, 1)[0]; + if (!card) return; + + player.zones.library.push(card); // goes to bottom + player.mulliganBottomCount--; + + addLogEntry(`↩ ${player.name} put a card on the bottom (${player.mulliganBottomCount} remaining)`); + + if (player.mulliganBottomCount <= 0) { + // Done putting cards back — now they can decide keep vs. mulligan again + player.mulliganPhase = 'deciding'; + addLogEntry(`✅ ${player.name} finished putting cards on bottom — ${player.zones.hand.length} cards in hand`); + } + + updatePlayerZones(); + renderHandArea(); + renderMulliganUI(); +} + +/** + * Render the mulligan UI bar above the hand area. + */ +function renderMulliganUI() { + const existing = document.getElementById('mulligan-bar'); + if (existing) existing.remove(); + + if (!gameState) return; + const player = gameState.players[0]; // Player 1 + if (!player) return; + + // Only show if player has a deck loaded (hand or library has cards) + const hasCards = player.zones.hand.length > 0 || player.zones.library.length > 0; + if (!hasCards) return; + + // If no mulligan phase active, don't show + if (!player.mulliganPhase) return; + + const handArea = document.getElementById('hand-area'); + if (!handArea) return; + + const bar = document.createElement('div'); + bar.id = 'mulligan-bar'; + bar.className = 'mulligan-bar'; + + const mullCount = player.mulliganCount || 0; + + if (player.mulliganPhase === 'bottom') { + // Player needs to pick cards to put on bottom + const remaining = player.mulliganBottomCount || 0; + bar.innerHTML = ` + Select ${remaining} card${remaining !== 1 ? 's' : ''} to put on bottom of library + Click a card in your hand to put it on bottom + `; + } else if (player.mulliganPhase === 'deciding') { + // Player can keep or mulligan again + const nextPutBack = Math.max(0, mullCount); // after next mull, they'd put back this many + bar.innerHTML = ` + ${mullCount === 0 ? 'Opening hand — 7 cards' : 'Mulligan #' + mullCount + ' — ' + player.zones.hand.length + ' cards in hand'} + + + `; + } + + // Insert before hand-cards + const header = handArea.querySelector('.hand-area-header'); + if (header) { + header.after(bar); + } else { + handArea.prepend(bar); + } +} + +/** + * Enter mulligan phase for a player (called after deck load/import). + */ +function enterMulliganPhase(playerId) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + player.mulliganCount = 0; + player.mulliganBottomCount = 0; + player.mulliganPhase = 'deciding'; + renderMulliganUI(); +} + +function shuffleLibrary(playerId) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + + // Fisher-Yates shuffle + 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('' + player.name + ' shuffled their library'); + + // If zone modal is open showing library, re-render + if (zoneModalPlayerId === playerId && activeZoneTab === 'library') renderZoneModal(); +} + +function millCards(playerId, count) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + const milled = []; + for (let i = 0; i < count && player.zones.library.length > 0; i++) { + const card = player.zones.library.shift(); + player.zones.graveyard.push(card); + milled.push(card.name); + } + + if (milled.length > 0) { + addLogEntry('' + player.name + ' milled ' + milled.length + ' card' + (milled.length !== 1 ? 's' : '') + ': ' + milled.join(', ')); + } + updatePlayerZones(); + + // Fire mill triggers + if (milled.length > 0) fireBattlefieldTriggers('mill'); + + if (zoneModalPlayerId === playerId) renderZoneModal(); +} + +function addCardToPlayerZone(card, imgSrc, zone, position) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[gameState.currentPlayerIndex]; + if (!player) return; + + const cardObj = { + name: card.name, + imageUrl: imgSrc, + typeLine: card.type_line || '', + oracleText: card.oracle_text || '', + manaCost: card.mana_cost || '', + cmc: card.cmc || 0, + power: card.power || '', + toughness: card.toughness || '', + pt: (card.power && card.toughness) ? card.power + '/' + card.toughness : '', + loyalty: card.loyalty || '', + }; + + if (position === 'top') { + player.zones[zone].unshift(cardObj); + } else { + player.zones[zone].push(cardObj); + } + + const zoneLabel = zone.charAt(0).toUpperCase() + zone.slice(1); + addLogEntry('' + player.name + ' added ' + card.name + ' to ' + zoneLabel + (position === 'top' ? ' (top)' : '')); + updatePlayerZones(); +} + +// ============================================================ +// WHOLE DECK VIEWER +// ============================================================ + +let deckViewPlayerId = null; +let deckViewTypeFilter = 'all'; + +function openDeckView(playerId) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + deckViewPlayerId = playerId; + deckViewTypeFilter = 'all'; + + // Reset stats panel + deckStatsVisible = false; + const statsPanel = document.getElementById('deckview-stats-panel'); + const statsBtn = document.getElementById('deckview-stats-toggle'); + if (statsPanel) statsPanel.style.display = 'none'; + if (statsBtn) statsBtn.classList.remove('active'); + + const titleEl = document.getElementById('deckview-modal-title'); + if (titleEl) titleEl.textContent = `${player.name}'s Library`; + + const searchEl = document.getElementById('deckview-search'); + if (searchEl) searchEl.value = ''; + + // Reset type filter buttons + document.querySelectorAll('.deckview-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'all')); + + renderDeckView(); + + const modal = document.getElementById('deckview-modal'); + if (modal) modal.classList.add('visible'); + + // Auto-focus search + setTimeout(() => { if (searchEl) searchEl.focus(); }, 100); + + addLogEntry(`\uD83D\uDD0D ${player.name} is browsing their library`); +} + +function closeDeckView() { + const modal = document.getElementById('deckview-modal'); + if (modal) modal.classList.remove('visible'); + deckViewPlayerId = null; +} + +function filterDeckView(type) { + deckViewTypeFilter = type; + document.querySelectorAll('.deckview-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type)); + renderDeckView(); +} + +function renderDeckView() { + if (deckViewPlayerId === null || !gameState) return; + const player = gameState.players[deckViewPlayerId]; + if (!player) return; + + const body = document.getElementById('deckview-body'); + const countEl = document.getElementById('deckview-count'); + if (!body) return; + + const searchTerm = (document.getElementById('deckview-search')?.value || '').toLowerCase().trim(); + + let cards = player.zones.library.map((card, idx) => ({ ...card, libIndex: idx })); + + // Type filter + if (deckViewTypeFilter !== 'all') { + cards = cards.filter(c => { + const tl = (c.typeLine || '').toLowerCase(); + return tl.includes(deckViewTypeFilter); + }); + } + + // Search filter + if (searchTerm) { + cards = cards.filter(c => { + const name = (c.name || '').toLowerCase(); + const tl = (c.typeLine || '').toLowerCase(); + const oracle = (c.oracleText || '').toLowerCase(); + return name.includes(searchTerm) || tl.includes(searchTerm) || oracle.includes(searchTerm); + }); + } + + if (countEl) countEl.textContent = `${cards.length} of ${player.zones.library.length} cards`; + + if (!cards.length) { + body.innerHTML = '
No cards match your search.
'; + return; + } + + body.innerHTML = ''; + const grid = document.createElement('div'); + grid.className = 'deckview-grid'; + + cards.forEach(card => { + const el = document.createElement('div'); + el.className = 'deckview-card'; + el.innerHTML = ` +
+ ${escapeHtml(card.name)} +
+
+
${escapeHtml(card.name)}
+
${escapeHtml(card.typeLine || '')}
+
+ + + + +
+
+ `; + + // Hover preview + const imgWrap = el.querySelector('.deckview-card-img'); + imgWrap.addEventListener('mouseenter', e => showFloatingCardPreview(card.imageUrl, e, { name: card.name, typeLine: card.typeLine, oracleText: card.oracleText })); + imgWrap.addEventListener('mousemove', positionFloatingPreview); + imgWrap.addEventListener('mouseleave', hideFloatingCardPreview); + + // Action buttons + el.querySelectorAll('.dvact').forEach(btn => { + btn.addEventListener('click', () => { + const zone = btn.dataset.zone; + moveDeckViewCard(card, zone); + }); + }); + + grid.appendChild(el); + }); + + body.appendChild(grid); +} + +function moveDeckViewCard(card, zone) { + if (deckViewPlayerId === null || !gameState) return; + pushUndoState(); + const player = gameState.players[deckViewPlayerId]; + if (!player) return; + + // Find the card in library by its current libIndex + const libIdx = player.zones.library.findIndex(c => + c.name === card.name && c.imageUrl === card.imageUrl + ); + if (libIdx === -1) return; + + const removed = player.zones.library.splice(libIdx, 1)[0]; + + const zoneLabels = { hand: 'Hand', battlefield: 'Battlefield', graveyard: 'Graveyard', exile: 'Exile' }; + + if (zone === 'battlefield') { + addCardToBattlefield(removed.name, removed.imageUrl, removed.typeLine, deckViewPlayerId, undefined, undefined, removed.oracleText || '', + { manaCost: removed.manaCost || '', power: removed.power || '', toughness: removed.toughness || '', pt: removed.pt || '', cmc: removed.cmc || 0, loyalty: removed.loyalty || '', colorIdentity: removed.colorIdentity, keywords: removed.keywords }); + } else { + player.zones[zone].push({ + name: removed.name, + imageUrl: removed.imageUrl, + typeLine: removed.typeLine, + oracleText: removed.oracleText || '', + }); + } + + addLogEntry(`\uD83D\uDD0D ${removed.name} pulled from ${player.name}'s library to ${zoneLabels[zone]}`); + + updatePlayerZones(); + renderDeckView(); +} + +// Wire up search input live-filtering +document.addEventListener('DOMContentLoaded', () => { + const searchEl = document.getElementById('deckview-search'); + if (searchEl) { + searchEl.addEventListener('input', () => renderDeckView()); + } +}); + +// ============================================================ +// DECK STATS PANEL +// ============================================================ + +let deckStatsVisible = false; + +function toggleDeckStats() { + deckStatsVisible = !deckStatsVisible; + const panel = document.getElementById('deckview-stats-panel'); + const btn = document.getElementById('deckview-stats-toggle'); + if (panel) panel.style.display = deckStatsVisible ? 'block' : 'none'; + if (btn) btn.classList.toggle('active', deckStatsVisible); + if (deckStatsVisible) renderDeckStats(); +} + +function parseManaSymbols(manaCost) { + if (!manaCost) return { cmc: 0, colors: [], colorless: 0, symbols: [] }; + const symbols = manaCost.match(/\{[^}]+\}/g) || []; + let cmc = 0; + let colorless = 0; + const colors = []; + const colorMap = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green' }; + + symbols.forEach(sym => { + const inner = sym.replace(/[{}]/g, ''); + // Numeric generic mana + if (/^\d+$/.test(inner)) { + cmc += parseInt(inner); + colorless += parseInt(inner); + } + // X cost + else if (inner === 'X') { + // X doesn't contribute to CMC for curve purposes + } + // Color symbols (W, U, B, R, G) + else if (colorMap[inner]) { + cmc += 1; + colors.push(inner); + } + // Hybrid mana (e.g., W/U, 2/W) + else if (inner.includes('/')) { + cmc += 1; + const parts = inner.split('/'); + // Take the first color found for distribution + for (const p of parts) { + if (colorMap[p]) { colors.push(p); break; } + } + } + // Phyrexian mana (e.g., W/P, U/P) + else if (inner.includes('P')) { + cmc += 1; + const colorPart = inner.replace('/P', '').replace('P', ''); + if (colorMap[colorPart]) colors.push(colorPart); + } + // Snow, colorless symbol {C} + else if (inner === 'C' || inner === 'S') { + cmc += 1; + colorless += 1; + } + else { + // Unknown symbol, count as 1 + cmc += 1; + } + }); + + return { cmc, colors, colorless, symbols }; +} + +function getCardMainType(typeLine) { + if (!typeLine) return 'Other'; + const tl = typeLine.toLowerCase(); + if (tl.includes('creature')) return 'Creature'; + if (tl.includes('land')) return 'Land'; + if (tl.includes('instant')) return 'Instant'; + if (tl.includes('sorcery')) return 'Sorcery'; + if (tl.includes('artifact')) return 'Artifact'; + if (tl.includes('enchantment')) return 'Enchantment'; + if (tl.includes('planeswalker')) return 'Planeswalker'; + if (tl.includes('battle')) return 'Battle'; + return 'Other'; +} + +function renderDeckStats() { + if (deckViewPlayerId === null || !gameState) return; + const player = gameState.players[deckViewPlayerId]; + if (!player) return; + + const cards = player.zones.library; + if (!cards.length) return; + + // Parse all cards + const parsed = cards.map(c => { + const mana = parseManaSymbols(c.manaCost); + const type = getCardMainType(c.typeLine); + return { ...c, cmc: mana.cmc, manaColors: mana.colors, mainType: type, colorless: mana.colorless }; + }); + + // --- Mana Curve (excluding lands) --- + const nonLands = parsed.filter(c => c.mainType !== 'Land'); + const cmcBuckets = {}; + nonLands.forEach(c => { + const bucket = c.cmc >= 7 ? '7+' : String(c.cmc); + cmcBuckets[bucket] = (cmcBuckets[bucket] || 0) + 1; + }); + const cmcLabels = ['0', '1', '2', '3', '4', '5', '6', '7+']; + const maxCmc = Math.max(1, ...cmcLabels.map(l => cmcBuckets[l] || 0)); + const avgCmc = nonLands.length > 0 + ? (nonLands.reduce((s, c) => s + c.cmc, 0) / nonLands.length).toFixed(2) + : '0.00'; + + const curveEl = document.getElementById('dstats-curve'); + if (curveEl) { + curveEl.innerHTML = '
' + + cmcLabels.map(label => { + const count = cmcBuckets[label] || 0; + const pct = (count / maxCmc) * 100; + return `
+
${count || ''}
+
+
${label}
+
`; + }).join('') + '
'; + } + + const avgEl = document.getElementById('dstats-avg-cmc'); + if (avgEl) avgEl.innerHTML = `Average CMC: ${avgCmc} (excluding lands)`; + + // --- Color Distribution --- + const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; + const colorNames = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' }; + const colorHexes = { W: '#F9FAF4', U: '#0E68AB', B: '#150B00', R: '#D3202A', G: '#00733E', C: '#CAC5C0' }; + const colorTextColors = { W: '#333', U: '#fff', B: '#fff', R: '#fff', G: '#fff', C: '#333' }; + + parsed.forEach(c => { + if (c.manaColors.length === 0 && c.mainType !== 'Land') { + colorCounts.C += 1; + } + c.manaColors.forEach(col => { + if (colorCounts.hasOwnProperty(col)) colorCounts[col] += 1; + }); + }); + + const totalPips = Object.values(colorCounts).reduce((s, v) => s + v, 0) || 1; + + const colorsEl = document.getElementById('dstats-colors'); + if (colorsEl) { + // Color pip bar (stacked horizontal bar) + const pipBar = Object.entries(colorCounts) + .filter(([, v]) => v > 0) + .map(([k, v]) => { + const pct = ((v / totalPips) * 100).toFixed(1); + return `
+ ${v >= 2 ? v : ''} +
`; + }).join(''); + + const legend = Object.entries(colorCounts) + .filter(([, v]) => v > 0) + .map(([k, v]) => { + return `
+ + ${colorNames[k]} + ${v} +
`; + }).join(''); + + colorsEl.innerHTML = `
${pipBar}
${legend}
`; + } + + // --- Card Type Breakdown --- + const typeCounts = {}; + parsed.forEach(c => { + typeCounts[c.mainType] = (typeCounts[c.mainType] || 0) + 1; + }); + + const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other']; + const typeIcons = { + Creature: '\u2694\uFE0F', + Instant: '\u26A1', + Sorcery: '\uD83C\uDF1F', + Artifact: '\u2699\uFE0F', + Enchantment: '\u2728', + Planeswalker: '\uD83D\uDC64', + Land: '\u26F0\uFE0F', + Battle: '\uD83D\uDEE1\uFE0F', + Other: '\u2753' + }; + const maxType = Math.max(1, ...Object.values(typeCounts)); + + const typesEl = document.getElementById('dstats-types'); + if (typesEl) { + typesEl.innerHTML = typeOrder + .filter(t => typeCounts[t]) + .map(t => { + const count = typeCounts[t]; + const pct = ((count / maxType) * 100).toFixed(1); + return `
+
${typeIcons[t] || ''} ${t}
+
+
+
+
${count}
+
`; + }).join(''); + } + + // --- Deck Overview --- + const overviewEl = document.getElementById('dstats-overview'); + if (overviewEl) { + const totalCards = cards.length; + const landCount = typeCounts['Land'] || 0; + const creatureCount = typeCounts['Creature'] || 0; + const nonlandNonCreature = totalCards - landCount - creatureCount; + + overviewEl.innerHTML = ` +
+
+
${totalCards}
+
Total Cards
+
+
+
${landCount}
+
Lands
+
+
+
${creatureCount}
+
Creatures
+
+
+
${nonlandNonCreature}
+
Other Spells
+
+
+ `; + } +} + +// ============================================================ +// TOKEN CARDS +// ============================================================ + +const TOKEN_PRESETS = [ + { name: 'Soldier', power: '1', toughness: '1', type: 'Creature \u2014 Soldier', color: 'W', abilities: '', cat: 'creature' }, + { name: 'Spirit', power: '1', toughness: '1', type: 'Creature \u2014 Spirit', color: 'W', abilities: 'Flying', cat: 'creature' }, + { name: 'Angel', power: '4', toughness: '4', type: 'Creature \u2014 Angel', color: 'W', abilities: 'Flying', cat: 'creature' }, + { name: 'Human', power: '1', toughness: '1', type: 'Creature \u2014 Human', color: 'W', abilities: '', cat: 'creature' }, + { name: 'Knight', power: '2', toughness: '2', type: 'Creature \u2014 Knight', color: 'W', abilities: 'Vigilance', cat: 'creature' }, + { name: 'Cat', power: '1', toughness: '1', type: 'Creature \u2014 Cat', color: 'W', abilities: 'Lifelink', cat: 'creature' }, + { name: 'Bird', power: '1', toughness: '1', type: 'Creature \u2014 Bird', color: 'U', abilities: 'Flying', cat: 'creature' }, + { name: 'Illusion', power: '1', toughness: '1', type: 'Creature \u2014 Illusion', color: 'U', abilities: '', cat: 'creature' }, + { name: 'Thopter', power: '1', toughness: '1', type: 'Artifact Creature \u2014 Thopter', color: 'U', abilities: 'Flying', cat: 'creature' }, + { name: 'Drake', power: '2', toughness: '2', type: 'Creature \u2014 Drake', color: 'U', abilities: 'Flying', cat: 'creature' }, + { name: 'Zombie', power: '2', toughness: '2', type: 'Creature \u2014 Zombie', color: 'B', abilities: '', cat: 'creature' }, + { name: 'Vampire', power: '1', toughness: '1', type: 'Creature \u2014 Vampire', color: 'B', abilities: 'Lifelink', cat: 'creature' }, + { name: 'Rat', power: '1', toughness: '1', type: 'Creature \u2014 Rat', color: 'B', abilities: '', cat: 'creature' }, + { name: 'Skeleton', power: '1', toughness: '1', type: 'Creature \u2014 Skeleton', color: 'B', abilities: '', cat: 'creature' }, + { name: 'Goblin', power: '1', toughness: '1', type: 'Creature \u2014 Goblin', color: 'R', abilities: '', cat: 'creature' }, + { name: 'Dragon', power: '5', toughness: '5', type: 'Creature \u2014 Dragon', color: 'R', abilities: 'Flying', cat: 'creature' }, + { name: 'Elemental', power: '1', toughness: '1', type: 'Creature \u2014 Elemental', color: 'R', abilities: 'Haste', cat: 'creature' }, + { name: 'Devil', power: '1', toughness: '1', type: 'Creature \u2014 Devil', color: 'R', abilities: '', cat: 'creature' }, + { name: 'Saproling', power: '1', toughness: '1', type: 'Creature \u2014 Saproling', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Beast', power: '3', toughness: '3', type: 'Creature \u2014 Beast', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Elf Warrior',power: '1', toughness: '1', type: 'Creature \u2014 Elf Warrior', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Wolf', power: '2', toughness: '2', type: 'Creature \u2014 Wolf', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Insect', power: '1', toughness: '1', type: 'Creature \u2014 Insect', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Plant', power: '0', toughness: '1', type: 'Creature \u2014 Plant', color: 'G', abilities: '', cat: 'creature' }, + { name: 'Treasure', power: '', toughness: '', type: 'Artifact \u2014 Treasure', color: 'C', abilities: 'Sacrifice: Add one mana of any color', cat: 'artifact' }, + { name: 'Food', power: '', toughness: '', type: 'Artifact \u2014 Food', color: 'C', abilities: '{2}, Sacrifice: Gain 3 life', cat: 'artifact' }, + { name: 'Clue', power: '', toughness: '', type: 'Artifact \u2014 Clue', color: 'C', abilities: '{2}, Sacrifice: Draw a card', cat: 'artifact' }, + { name: 'Blood', power: '', toughness: '', type: 'Artifact \u2014 Blood', color: 'C', abilities: '{1}, Discard, Sacrifice: Draw a card', cat: 'artifact' }, + { name: 'Map', power: '', toughness: '', type: 'Artifact \u2014 Map', color: 'C', abilities: '{1}, Sacrifice: Target creature explores', cat: 'artifact' }, + { name: 'Powerstone', power: '', toughness: '', type: 'Artifact \u2014 Powerstone', color: 'C', abilities: 'Tap: Add {C} (not for nonartifact spells)', cat: 'artifact' }, + { name: 'Incubator', power: '0', toughness: '0', type: 'Artifact \u2014 Incubator', color: 'C', abilities: '{2}: Transform into Phyrexian token', cat: 'artifact' }, + { name: 'Copy', power: '', toughness: '', type: 'Token Copy', color: 'C', abilities: '(Copy of any permanent)', cat: 'artifact' }, +]; + +const TOKEN_COLOR_MAP = { + W: { bg: '#f8e7b9', border: '#d4b85c', text: '#3d3520', accent: '#8b7a3c' }, + U: { bg: '#b8d4e8', border: '#4a8ab5', text: '#1a3345', accent: '#2c6e9b' }, + B: { bg: '#c4b5c9', border: '#6b5278', text: '#2a1e30', accent: '#5c3d6e' }, + R: { bg: '#e8b8b0', border: '#c04030', text: '#3d1815', accent: '#a83020' }, + G: { bg: '#b5d4b0', border: '#4a8b3e', text: '#1a3315', accent: '#367029' }, + C: { bg: '#d0cec8', border: '#8a8880', text: '#2a2a28', accent: '#6a6860' }, +}; + +function generateTokenSVG(name, power, toughness, typeLine, color, abilities) { + const c = TOKEN_COLOR_MAP[color] || TOKEN_COLOR_MAP.C; + const hasPT = power !== '' && toughness !== ''; + + // Escape for SVG + const esc = s => s.replace(/&/g,'&').replace(//g,'>'); + + // Truncate long names + const displayName = name.length > 18 ? name.substring(0, 17) + '\u2026' : name; + const displayType = typeLine.length > 28 ? typeLine.substring(0, 27) + '\u2026' : typeLine; + const displayAbil = abilities.length > 40 ? abilities.substring(0, 39) + '\u2026' : abilities; + + const svg = ` + + + + + + + + + ${esc(displayName)} + + + TOKEN + ${abilities ? `${esc(displayAbil)}` : ''} + + ${esc(displayType)} + ${hasPT ? `${esc(power)}/${esc(toughness)}` : ''} + `; + + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); +} + +let tokenModalPlayerId = null; +let tokenHistory = []; // { name, power, toughness, typeLine, color, abilities, imageUrl, isScryfall } +let tokenPresetFilter = 'all'; + +function getTokenQty() { + return Math.max(1, Math.min(50, parseInt(document.getElementById('token-qty')?.value) || 1)); +} + +function adjustTokenQty(delta) { + const input = document.getElementById('token-qty'); + if (!input) return; + const val = Math.max(1, Math.min(50, (parseInt(input.value) || 1) + delta)); + input.value = val; +} + +function switchTokenTab(btn, tabId) { + document.querySelectorAll('.token-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.token-tab-panel').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + const panel = document.getElementById(tabId); + if (panel) panel.classList.add('active'); + // Auto-focus search when switching to Scryfall tab + if (tabId === 'tab-scryfall') { + setTimeout(() => { document.getElementById('token-browser-input')?.focus(); }, 50); + } +} + +function filterTokenPresets(btn, cat) { + tokenPresetFilter = cat; + document.querySelectorAll('.token-cat-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.token-preset-btn').forEach(el => { + if (cat === 'all' || el.dataset.cat === cat) { + el.classList.remove('hidden'); + } else { + el.classList.add('hidden'); + } + }); +} + +function openTokenModal(playerId) { + tokenModalPlayerId = playerId; + const titleEl = document.getElementById('token-modal-title'); + const player = gameState?.players[playerId]; + if (titleEl && player) titleEl.textContent = `Create Token \u2014 ${player.name}`; + + // Render presets with category data + const presetsEl = document.getElementById('token-presets'); + if (presetsEl) { + presetsEl.innerHTML = TOKEN_PRESETS.map((p, i) => { + const c = TOKEN_COLOR_MAP[p.color] || TOKEN_COLOR_MAP.C; + const hasPT = p.power !== '' && p.toughness !== ''; + const ptLabel = hasPT ? `${p.power}/${p.toughness}` : ''; + const abilShort = p.abilities ? ` \u2014 ${p.abilities.length > 20 ? p.abilities.substring(0,19) + '\u2026' : p.abilities}` : ''; + const hiddenCls = (tokenPresetFilter !== 'all' && p.cat !== tokenPresetFilter) ? ' hidden' : ''; + return ``; + }).join(''); + } + + // Reset qty + const qtyInput = document.getElementById('token-qty'); + if (qtyInput) qtyInput.value = '1'; + + // Reset custom form + document.getElementById('token-name').value = ''; + document.getElementById('token-power').value = ''; + document.getElementById('token-toughness').value = ''; + document.getElementById('token-type').value = ''; + document.getElementById('token-abilities').value = ''; + // Reset color picker to white only + document.querySelectorAll('.token-color-btn').forEach(b => b.classList.toggle('active', b.dataset.color === 'W')); + updateTokenPreview(); + + // Reset Scryfall token browser + const browserInput = document.getElementById('token-browser-input'); + if (browserInput) browserInput.value = ''; + const browserResults = document.getElementById('token-browser-results'); + if (browserResults) browserResults.innerHTML = '
Search for official token card art from Scryfall.
'; + const browserStatus = document.getElementById('token-browser-status'); + if (browserStatus) browserStatus.textContent = ''; + + // Render history + renderTokenHistory(); + + // Switch to presets tab + const firstTab = document.querySelector('.token-tab[data-tab="tab-presets"]'); + if (firstTab) switchTokenTab(firstTab, 'tab-presets'); + + const modal = document.getElementById('token-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeTokenModal() { + const modal = document.getElementById('token-modal'); + if (modal) modal.classList.remove('visible'); + tokenModalPlayerId = null; +} + +function toggleTokenColor(btn) { + const color = btn.dataset.color; + if (!color) return; + // If clicking Colorless, deselect everything else + if (color === 'C') { + document.querySelectorAll('.token-color-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + } else { + // Deselect Colorless if selecting a color + document.querySelector('.token-color-btn[data-color="C"]')?.classList.remove('active'); + btn.classList.toggle('active'); + // If nothing selected, default to Colorless + const anyActive = document.querySelector('.token-color-btn.active'); + if (!anyActive) { + document.querySelector('.token-color-btn[data-color="C"]')?.classList.add('active'); + } + } + updateTokenPreview(); +} + +function getSelectedTokenColor() { + const actives = document.querySelectorAll('.token-color-btn.active'); + if (actives.length === 0) return 'C'; + if (actives.length === 1) return actives[0].dataset.color; + // Multicolor: return first color for SVG purposes + return actives[0].dataset.color; +} + +function getSelectedTokenColors() { + const actives = document.querySelectorAll('.token-color-btn.active'); + return Array.from(actives).map(b => b.dataset.color); +} + +function updateTokenPreview() { + const previewEl = document.getElementById('token-preview-img'); + if (!previewEl) return; + const name = document.getElementById('token-name')?.value.trim() || 'Token'; + const power = document.getElementById('token-power')?.value.trim() || ''; + const toughness = document.getElementById('token-toughness')?.value.trim() || ''; + const typeLine = document.getElementById('token-type')?.value.trim() || 'Token'; + const abilities = document.getElementById('token-abilities')?.value.trim() || ''; + const color = getSelectedTokenColor(); + const src = generateTokenSVG(name, power, toughness, typeLine, color, abilities); + previewEl.innerHTML = `Token preview`; +} + +function addToTokenHistory(entry) { + // Deduplicate by name + color combo + const key = entry.name + '|' + entry.color + '|' + (entry.imageUrl || ''); + tokenHistory = tokenHistory.filter(h => (h.name + '|' + h.color + '|' + (h.imageUrl || '')) !== key); + tokenHistory.unshift(entry); + if (tokenHistory.length > 20) tokenHistory.length = 20; +} + +function renderTokenHistory() { + const container = document.getElementById('token-history-list'); + if (!container) return; + if (tokenHistory.length === 0) { + container.innerHTML = '
No tokens created yet this game.
'; + return; + } + container.innerHTML = tokenHistory.map((h, i) => { + const thumbSrc = h.imageUrl || generateTokenSVG(h.name, h.power || '', h.toughness || '', h.typeLine || 'Token', h.color || 'C', h.abilities || ''); + const hasPT = h.power && h.toughness; + const ptText = hasPT ? ` ${h.power}/${h.toughness}` : ''; + return ``; + }).join(''); +} + +function spawnFromHistory(idx) { + const h = tokenHistory[idx]; + if (!h || tokenModalPlayerId === null) return; + const qty = getTokenQty(); + if (h.isScryfall && h.imageUrl) { + spawnTokenWithImage(h.name, h.power || '', h.toughness || '', h.typeLine || 'Token', h.color || 'C', h.abilities || '', qty, h.imageUrl); + } else { + spawnTokenOnBattlefield(h.name, h.power || '', h.toughness || '', h.typeLine || 'Token', h.color || 'C', h.abilities || '', qty); + } +} + +function spawnPresetToken(presetIdx) { + const p = TOKEN_PRESETS[presetIdx]; + if (!p || tokenModalPlayerId === null) return; + const qty = getTokenQty(); + spawnTokenOnBattlefield(p.name, p.power, p.toughness, p.type, p.color, p.abilities, qty); +} + +function createCustomToken() { + if (tokenModalPlayerId === null) return; + const name = document.getElementById('token-name').value.trim() || 'Token'; + const power = document.getElementById('token-power').value.trim(); + const toughness = document.getElementById('token-toughness').value.trim(); + const typeLine = document.getElementById('token-type').value.trim() || 'Token'; + const abilities = document.getElementById('token-abilities').value.trim(); + const qty = getTokenQty(); + const color = getSelectedTokenColor(); + spawnTokenOnBattlefield(name, power, toughness, typeLine, color, abilities, qty); +} + +function spawnTokenOnBattlefield(name, power, toughness, typeLine, color, abilities, qty) { + if (tokenModalPlayerId === null || !gameState) return; + pushUndoState(); + const player = gameState.players[tokenModalPlayerId]; + if (!player) return; + + const imageUrl = generateTokenSVG(name, power, toughness, typeLine, color, abilities); + const hasPT = power !== '' && toughness !== ''; + const oracleText = abilities || ''; + + const tokenFullTypeLine = 'Token ' + typeLine; + const tokenZone = getTokenPlacementZone(tokenFullTypeLine); + const tokenZoneMap = { + 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 } + }; + const tokenZoneBounds = tokenZoneMap[tokenZone] || tokenZoneMap.other; + + for (let i = 0; i < qty; i++) { + const battlefield = document.getElementById('battlefield'); + if (!battlefield) continue; + var tokenSectionArea = document.getElementById('bf-section-area-' + tokenModalPlayerId); + var tokenRect = tokenSectionArea ? tokenSectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect(); + const zoneW = tokenRect.width; + const zoneH = tokenRect.height; + const x = tokenZoneBounds.x1 * zoneW + Math.random() * ((tokenZoneBounds.x2 - tokenZoneBounds.x1) * zoneW - 90) + (i * 12) % 40; + const y = tokenZoneBounds.y1 * zoneH + Math.random() * ((tokenZoneBounds.y2 - tokenZoneBounds.y1) * zoneH - 120) + (i * 12) % 40; + + const cardData = { + id: nextCardId++, + name: name + ' Token', + imageUrl, + typeLine: tokenFullTypeLine, + ownerIndex: tokenModalPlayerId, + x: Math.max(0, x), + y: Math.max(0, y), + tapped: false, + faceDown: false, + oracleText, + triggers: parseTriggers(name, oracleText), + isToken: true, + power: hasPT ? power : undefined, + toughness: hasPT ? toughness : undefined, + }; + + gameState.battlefieldCards.push(cardData); + renderBattlefieldCard(cardData); + } + + const ptText = hasPT ? ` ${power}/${toughness}` : ''; + const qtyText = qty > 1 ? `${qty}x ` : ''; + addLogEntry(`\u2728 ${player.name} created ${qtyText}${name}${ptText} token${qty > 1 ? 's' : ''}`); + + // Add to history + addToTokenHistory({ name, power, toughness, typeLine, color, abilities, imageUrl: null, isScryfall: false }); + + fireBattlefieldTriggers('etb'); + // Note: tokens do NOT fire nontoken_etb — that only fires for non-token creatures + fireBattlefieldTriggers('token_create'); + updateTokenSummaryBar(); + closeTokenModal(); +} +// ============================================================ + +let tokenBrowserDebounce = null; +let tokenBrowserLastSearch = 0; +let selectedScryfallToken = null; + +function debounceScryfallTokenSearch() { + clearTimeout(tokenBrowserDebounce); + tokenBrowserDebounce = setTimeout(() => { + const input = document.getElementById('token-browser-input'); + if (input) searchScryfallTokens(input.value.trim()); + }, 350); +} + +async function searchScryfallTokens(query) { + const statusEl = document.getElementById('token-browser-status'); + const resultsEl = document.getElementById('token-browser-results'); + if (!query || query.length < 2) { + if (resultsEl) resultsEl.innerHTML = '
Search for official token card art from Scryfall.
'; + if (statusEl) statusEl.textContent = ''; + return; + } + + if (statusEl) statusEl.innerHTML = '
'; + + const now = Date.now(); + const waitMs = Math.max(0, tokenBrowserLastSearch + RATE_LIMIT_MS - now); + + await new Promise(r => setTimeout(r, waitMs)); + tokenBrowserLastSearch = Date.now(); + + try { + const q = encodeURIComponent(`${query} layout:token`); + const url = `${SCRYFALL_BASE}/cards/search?q=${q}&include_extras=true&unique=art&order=name`; + const resp = await fetch(url); + + if (!resp.ok) { + if (resp.status === 404) { + renderTokenBrowserResults([]); + if (statusEl) statusEl.textContent = 'No tokens found.'; + } else { + if (statusEl) statusEl.textContent = 'Search error.'; + } + return; + } + + const data = await resp.json(); + const tokens = (data.data || []).filter(c => c.layout === 'token' || c.layout === 'double_faced_token'); + renderTokenBrowserResults(tokens); + if (statusEl) { + const total = tokens.length; + statusEl.textContent = total ? `${total} token${total === 1 ? '' : 's'}` : 'No tokens found.'; + } + } catch (err) { + console.error('[Token Browser]', err); + if (statusEl) statusEl.textContent = 'Network error.'; + } +} + +function renderTokenBrowserResults(tokens) { + const container = document.getElementById('token-browser-results'); + if (!container) return; + + if (!tokens.length) { + container.innerHTML = '
No tokens found.
'; + return; + } + + container.innerHTML = tokens.slice(0, 40).map((token, i) => { + const thumbUrl = getTokenBrowserImage(token, 'small'); + if (!thumbUrl) return ''; + const tokenName = (token.name || '').replace(/"/g, '"'); + const ptText = token.power ? ` ${token.power}/${token.toughness}` : ''; + return ``; + }).join(''); + + // Store token data for onclick lookup + container._tokenData = tokens.slice(0, 40); +} + +function getTokenBrowserImage(token, size) { + if (token.image_uris && token.image_uris[size]) return token.image_uris[size]; + if (token.card_faces && token.card_faces[0] && token.card_faces[0].image_uris) { + return token.card_faces[0].image_uris[size]; + } + return null; +} + +function selectScryfallTokenByIndex(idx) { + const container = document.getElementById('token-browser-results'); + const tokens = container?._tokenData; + if (!tokens || !tokens[idx]) return; + const token = tokens[idx]; + const imgUrl = getTokenBrowserImage(token, 'normal'); + if (imgUrl) selectScryfallToken(token, imgUrl); +} + +function selectScryfallToken(token, imageUrl) { + if (tokenModalPlayerId === null || !gameState) return; + + const scryfallColors = token.colors || []; + let color = 'C'; + if (scryfallColors.length === 1) color = scryfallColors[0]; + else if (scryfallColors.length > 1) color = scryfallColors[0]; + + const name = token.name || 'Token'; + const power = token.power || ''; + const toughness = token.toughness || ''; + const typeLine = (token.type_line || 'Token').replace(/^Token\s*/, ''); + const abilities = token.oracle_text || ''; + const qty = getTokenQty(); + + spawnTokenWithImage(name, power, toughness, typeLine, color, abilities, qty, imageUrl); +} + +function spawnTokenWithImage(name, power, toughness, typeLine, color, abilities, qty, imageUrl) { + if (tokenModalPlayerId === null || !gameState) return; + pushUndoState(); + const player = gameState.players[tokenModalPlayerId]; + if (!player) return; + + const hasPT = power !== '' && toughness !== ''; + const oracleText = abilities || ''; + + const tokenFullTypeLine2 = 'Token ' + typeLine; + const tokenZone2 = getTokenPlacementZone(tokenFullTypeLine2); + const tokenZoneMap2 = { + 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 } + }; + const tokenZoneBounds2 = tokenZoneMap2[tokenZone2] || tokenZoneMap2.other; + + for (let i = 0; i < qty; i++) { + const battlefield = document.getElementById('battlefield'); + if (!battlefield) continue; + var tokenSectionArea2 = document.getElementById('bf-section-area-' + tokenModalPlayerId); + var tokenRect2 = tokenSectionArea2 ? tokenSectionArea2.getBoundingClientRect() : battlefield.getBoundingClientRect(); + const zoneW2 = tokenRect2.width; + const zoneH2 = tokenRect2.height; + const x = tokenZoneBounds2.x1 * zoneW2 + Math.random() * ((tokenZoneBounds2.x2 - tokenZoneBounds2.x1) * zoneW2 - 90) + (i * 12) % 40; + const y = tokenZoneBounds2.y1 * zoneH2 + Math.random() * ((tokenZoneBounds2.y2 - tokenZoneBounds2.y1) * zoneH2 - 120) + (i * 12) % 40; + + const cardData = { + id: nextCardId++, + name: name + ' Token', + imageUrl, + typeLine: tokenFullTypeLine2, + ownerIndex: tokenModalPlayerId, + x: Math.max(0, x), + y: Math.max(0, y), + tapped: false, + faceDown: false, + oracleText, + triggers: parseTriggers(name, oracleText), + isToken: true, + power: hasPT ? power : undefined, + toughness: hasPT ? toughness : undefined, + }; + + gameState.battlefieldCards.push(cardData); + renderBattlefieldCard(cardData); + } + + const ptText = hasPT ? ` ${power}/${toughness}` : ''; + const qtyText = qty > 1 ? `${qty}x ` : ''; + addLogEntry(`\u2728 ${player.name} created ${qtyText}${name}${ptText} token${qty > 1 ? 's' : ''}`); + + // Add to history + addToTokenHistory({ name, power, toughness, typeLine, color, abilities, imageUrl, isScryfall: true }); + + fireBattlefieldTriggers('etb'); + // Note: tokens do NOT fire nontoken_etb + fireBattlefieldTriggers('token_create'); + updateTokenSummaryBar(); + closeTokenModal(); +} +// ============================================================ + +function getTokenPlacementZone(typeLine) { + var tl = (typeLine || '').toLowerCase(); + // Artifact tokens (Treasure, Food, Clue, Blood, Map, Powerstone) go to artifact zone + if (/treasure|food|clue|blood\s+token|map\s+token|powerstone/.test(tl)) return 'artifact'; + if (/creature/.test(tl)) return 'creature'; + if (/artifact/.test(tl)) return 'artifact'; + if (/land/.test(tl)) return 'land'; + if (/instant|sorcery/.test(tl)) return 'spell'; + return 'other'; +} + +// ============================================================ +// TOKEN SUMMARY BAR +// ============================================================ + +function updateTokenSummaryBar() { + if (!gameState) return; + var bar = document.getElementById('token-summary-bar'); + if (!bar) return; + var tokens = gameState.battlefieldCards.filter(function(c) { return c.isToken; }); + if (tokens.length === 0) { + bar.style.display = 'none'; + return; + } + bar.style.display = 'flex'; + // Build counts by canonical name (strip " Token" suffix for display) + var counts = {}; + tokens.forEach(function(c) { + var displayName = c.name.replace(/ Token$/, ''); + var pt = (c.power !== undefined && c.toughness !== undefined) ? ' ' + c.power + '/' + c.toughness : ''; + var key = displayName + pt; + if (!counts[key]) counts[key] = { name: displayName, pt: pt, count: 0, ids: [], ownerIndex: c.ownerIndex }; + counts[key].count++; + counts[key].ids.push(c.id); + }); + var html = '🎭 Tokens:'; + Object.keys(counts).forEach(function(key) { + var entry = counts[key]; + var idsJson = JSON.stringify(entry.ids); + html += ''; + }); + html += ''; + bar.innerHTML = html; +} + +function highlightTokenGroup(ids) { + if (!Array.isArray(ids)) return; + // Flash highlight on the specified token cards + ids.forEach(function(id) { + var el = document.getElementById('bc-' + id); + if (el) { + el.classList.add('token-highlight-flash'); + setTimeout(function() { el.classList.remove('token-highlight-flash'); }, 1500); + } + }); +} + +function destroyAllTokensConfirm() { + if (!gameState) return; + var tokens = gameState.battlefieldCards.filter(function(c) { return c.isToken; }); + if (tokens.length === 0) return; + if (!confirm('Destroy all ' + tokens.length + ' token(s) on the battlefield?')) return; + pushUndoState(); + var tokenIds = tokens.map(function(c) { return c.id; }); + tokenIds.forEach(function(id) { + var el = document.getElementById('bc-' + id); + if (el) el.remove(); + gameState.battlefieldCards = gameState.battlefieldCards.filter(function(c) { return c.id !== id; }); + }); + addLogEntry('🗑 All tokens destroyed.'); + fireBattlefieldTriggers('dies'); + fireBattlefieldTriggers('ltb'); + updateTokenSummaryBar(); + applyBattlefieldFilter(); +} + +function destroyAllTokensByName(tokenName, ownerIndex) { + if (!gameState) return; + pushUndoState(); + var toDestroy = gameState.battlefieldCards.filter(function(c) { + return c.isToken && c.ownerIndex === ownerIndex && c.name.replace(/ Token$/, '') === tokenName; + }); + if (toDestroy.length === 0) return; + toDestroy.forEach(function(c) { + var el = document.getElementById('bc-' + c.id); + if (el) el.remove(); + gameState.battlefieldCards = gameState.battlefieldCards.filter(function(b) { return b.id !== c.id; }); + }); + addLogEntry('🗑 Destroyed ' + toDestroy.length + 'x ' + tokenName + ' token(s).'); + fireBattlefieldTriggers('dies'); + fireBattlefieldTriggers('ltb'); + updateTokenSummaryBar(); + applyBattlefieldFilter(); +} + +function destroyAllTokensByNameFromCtx(cardId) { + if (!gameState) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card || !card.isToken) return; + var tokenName = card.name.replace(/ Token$/, ''); + destroyAllTokensByName(tokenName, card.ownerIndex); +} + +// ============================================================ +// TOKEN COPY SYSTEM +// ============================================================ + +function createTokenCopy(cardId) { + if (!gameState) return; + var original = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!original) return; + pushUndoState(); + var ownerIndex = original.ownerIndex; + var sectionArea = document.getElementById('bf-section-area-' + ownerIndex); + var battlefield = document.getElementById('battlefield'); + var containerEl = sectionArea || battlefield; + var containerRect = containerEl ? containerEl.getBoundingClientRect() : { width: 800, height: 500 }; + var zone = getTokenPlacementZone(original.typeLine || ''); + var zoneMap = { + 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 zb = zoneMap[zone] || zoneMap.other; + var x = zb.x1 * containerRect.width + Math.random() * ((zb.x2 - zb.x1) * containerRect.width - 90) + 8; + var y = zb.y1 * containerRect.height + Math.random() * ((zb.y2 - zb.y1) * containerRect.height - 120) + 8; + + var copyName = original.name.replace(/ Token$/, ''); + var cardData = { + id: nextCardId++, + name: copyName + ' Token', + imageUrl: original.imageUrl, + typeLine: original.typeLine || 'Token Creature', + ownerIndex: ownerIndex, + x: Math.max(0, x), + y: Math.max(0, y), + tapped: false, + faceDown: false, + oracleText: original.oracleText || '', + triggers: parseTriggers(copyName, original.oracleText || ''), + isToken: true, + isCopy: true, + power: original.power, + toughness: original.toughness, + }; + gameState.battlefieldCards.push(cardData); + renderBattlefieldCard(cardData); + var ptText = (original.power !== undefined && original.toughness !== undefined) ? ' ' + original.power + '/' + original.toughness : ''; + addLogEntry('📋 Token copy of ' + copyName + ptText + ' created.'); + fireBattlefieldTriggers('etb'); + fireBattlefieldTriggers('token_create'); + updateTokenSummaryBar(); +} + +// ============================================================ +// BATCH TOKEN OPERATIONS +// ============================================================ + +function createAnotherToken(cardId) { + if (!gameState) return; + var original = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!original || !original.isToken) return; + var ownerIdx = original.ownerIndex; + // Save tokenModalPlayerId and restore after + var prevPlayerId = tokenModalPlayerId; + tokenModalPlayerId = ownerIdx; + var name = original.name.replace(/ Token$/, ''); + // Reconstruct typeLine without 'Token ' prefix for the spawn function + var tl = (original.typeLine || '').replace(/^Token\s+/i, ''); + var color = 'C'; // default, no color info stored on card + var oracleText = original.oracleText || ''; + var power = original.power !== undefined ? original.power : ''; + var toughness = original.toughness !== undefined ? original.toughness : ''; + // Use image from original + if (original.imageUrl && original.imageUrl.startsWith('http')) { + spawnTokenWithImage(name, power, toughness, tl, color, oracleText, 1, original.imageUrl); + } else { + spawnTokenOnBattlefield(name, power, toughness, tl, color, oracleText, 1); + } + tokenModalPlayerId = prevPlayerId; +} + +function createXMoreTokens(cardId) { + if (!gameState) return; + var original = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!original || !original.isToken) return; + var qtyStr = prompt('How many more ' + original.name.replace(/ Token$/, '') + ' tokens?', '2'); + if (!qtyStr) return; + var qty = parseInt(qtyStr, 10); + if (isNaN(qty) || qty < 1) return; + var ownerIdx = original.ownerIndex; + var prevPlayerId = tokenModalPlayerId; + tokenModalPlayerId = ownerIdx; + var name = original.name.replace(/ Token$/, ''); + var tl = (original.typeLine || '').replace(/^Token\s+/i, ''); + var color = 'C'; + var oracleText = original.oracleText || ''; + var power = original.power !== undefined ? original.power : ''; + var toughness = original.toughness !== undefined ? original.toughness : ''; + if (original.imageUrl && original.imageUrl.startsWith('http')) { + spawnTokenWithImage(name, power, toughness, tl, color, oracleText, qty, original.imageUrl); + } else { + spawnTokenOnBattlefield(name, power, toughness, tl, color, oracleText, qty); + } + tokenModalPlayerId = prevPlayerId; +} + +// ============================================================ +// SMART TOKEN QUICK-CREATE +// ============================================================ + +function parseTokenCreationAbilities(oracleText) { + if (!oracleText) return []; + var results = []; + // Patterns: "create a X/X COLOR TYPE creature token [with ABILITY]" + // and "create a TYPE token" (for artifact tokens) + var patterns = [ + // create N P/T color type token with abilities + /create (a|an|one|two|three|four|five|\d+|X) (\d+|\*)\/(\d+|\*) (white|blue|black|red|green|colorless|gold|multicolored)?\s*([\w/\s]+?) (creature )?tokens?(?:\s+with ([^.]+))?/gi, + // create a TYPE token (no P/T) - Treasure, Food, Clue, etc. + /create (a|an|one|two|three|four|five|\d+|X) (Treasure|Food|Clue|Blood|Map|Powerstone|Gold|Emblem|Incubator) tokens?/gi, + ]; + + var countWords = { 'a': 1, 'an': 1, 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5 }; + + // Pattern 1: creature tokens with P/T + var pat1 = /create (a|an|one|two|three|four|five|\d+|X) (\d+|\*)\/(\d+|\*) (white|blue|black|red|green|colorless)?\s*([\w]+(?:\s+[\w]+)??) (creature )?tokens?(?:\s+with ([^.\n]+))?/gi; + var m; + while ((m = pat1.exec(oracleText)) !== null) { + var countStr = m[1].toLowerCase(); + var count = countWords[countStr] !== undefined ? countWords[countStr] : (parseInt(countStr, 10) || 1); + var power = m[2]; + var toughness = m[3]; + var color = m[4] ? m[4].charAt(0).toUpperCase() + m[4].slice(1) : 'C'; + var typeName = m[5] ? m[5].trim() : 'Creature'; + var abilities = m[7] ? m[7].trim() : ''; + // Map color to short code + var colorMap = { White: 'W', Blue: 'U', Black: 'B', Red: 'R', Green: 'G', Colorless: 'C', Gold: 'C' }; + var colorCode = colorMap[color] || 'C'; + // Capitalize type name + var tokenName = typeName.charAt(0).toUpperCase() + typeName.slice(1); + results.push({ + name: tokenName, + power: power, + toughness: toughness, + typeLine: 'Creature', + color: colorCode, + abilities: abilities, + count: count, + displayLabel: count + 'x ' + tokenName + ' ' + power + '/' + toughness + }); + } + + // Pattern 2: artifact tokens (Treasure, Food, Clue, Blood, Map, Powerstone) + var pat2 = /create (a|an|one|two|three|four|five|\d+|X) (Treasure|Food|Clue|Blood|Map|Powerstone|Gold) tokens?/gi; + while ((m = pat2.exec(oracleText)) !== null) { + var countStr2 = m[1].toLowerCase(); + var count2 = countWords[countStr2] !== undefined ? countWords[countStr2] : (parseInt(countStr2, 10) || 1); + var tokenType = m[2]; + results.push({ + name: tokenType, + power: '', + toughness: '', + typeLine: 'Artifact — ' + tokenType, + color: 'C', + abilities: '', + count: count2, + displayLabel: count2 + 'x ' + tokenType + }); + } + + // Deduplicate by name+pt + var seen = {}; + return results.filter(function(r) { + var key = r.name + r.power + r.toughness; + if (seen[key]) return false; + seen[key] = true; + return true; + }); +} + +function createQuickToken(ownerIndex, name, power, toughness, typeLine, color, abilities, count) { + if (!gameState) return; + var prevPlayerId = tokenModalPlayerId; + tokenModalPlayerId = ownerIndex; + if (power !== undefined && power !== '' && toughness !== undefined && toughness !== '') { + spawnTokenOnBattlefield(name, power, toughness, typeLine, color, abilities, count || 1); + } else { + spawnTokenOnBattlefield(name, '', '', typeLine, color, abilities, count || 1); + } + tokenModalPlayerId = prevPlayerId; +} + +// ============================================================ +// SCRY +// ============================================================ + +let scryState = { + playerId: null, + topCards: [], + bottomCards: [], +}; + +function openScryModal(playerId, count) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + if (player.zones.library.length === 0) { + addLogEntry('' + player.name + ' tried to scry but library is empty!'); + return; + } + + const actualCount = Math.min(count, player.zones.library.length); + + // Take top N cards from library + const scryCards = player.zones.library.splice(0, actualCount); + + scryState = { + playerId: playerId, + topCards: scryCards.slice(), + bottomCards: [], + }; + + const nameEl = document.getElementById('scry-player-name'); + if (nameEl) nameEl.textContent = player.name + ' — Scry ' + actualCount; + + const instrEl = document.getElementById('scry-instructions'); + if (instrEl) instrEl.textContent = 'Click a card to move it between Top and Bottom. Drag to reorder.'; + + addLogEntry('\uD83D\uDD2E ' + player.name + ' is scrying ' + actualCount); + + renderScryZones(); + + const modal = document.getElementById('scry-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeScryModal() { + const modal = document.getElementById('scry-modal'); + if (modal) modal.classList.remove('visible'); +} + +function renderScryZones() { + renderScryZone('scry-top-zone', scryState.topCards, 'top'); + renderScryZone('scry-bottom-zone', scryState.bottomCards, 'bottom'); +} + +function renderScryZone(containerId, cards, zone) { + const container = document.getElementById(containerId); + if (!container) return; + + if (!cards.length) { + container.innerHTML = '
No cards
'; + return; + } + + container.innerHTML = ''; + cards.forEach((card, idx) => { + const el = document.createElement('div'); + el.className = 'scry-card'; + el.draggable = true; + el.dataset.zone = zone; + el.dataset.index = idx; + + el.innerHTML = '' + escapeHtml(card.name) + '' + + '
' + escapeHtml(card.name) + '
'; + + // Click to toggle between top/bottom + el.addEventListener('click', () => { + if (zone === 'top') { + const removed = scryState.topCards.splice(idx, 1)[0]; + scryState.bottomCards.push(removed); + } else { + const removed = scryState.bottomCards.splice(idx, 1)[0]; + scryState.topCards.push(removed); + } + renderScryZones(); + }); + + // Hover preview + el.addEventListener('mouseenter', e => showFloatingCardPreview(card.imageUrl, e, { name: card.name, typeLine: card.typeLine, oracleText: card.oracleText })); + el.addEventListener('mousemove', positionFloatingPreview); + el.addEventListener('mouseleave', hideFloatingCardPreview); + + // Drag to reorder within zone + el.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', JSON.stringify({ zone, index: idx })); + e.dataTransfer.effectAllowed = 'move'; + setTimeout(() => { el.style.opacity = '0.4'; }, 0); + }); + + el.addEventListener('dragend', () => { + el.style.opacity = ''; + }); + + el.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + el.classList.add('scry-drag-over'); + }); + + el.addEventListener('dragleave', () => { + el.classList.remove('scry-drag-over'); + }); + + el.addEventListener('drop', e => { + e.preventDefault(); + el.classList.remove('scry-drag-over'); + try { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + const srcZone = data.zone; + const srcIdx = data.index; + const dstZone = zone; + const dstIdx = idx; + + const srcArr = srcZone === 'top' ? scryState.topCards : scryState.bottomCards; + const dstArr = dstZone === 'top' ? scryState.topCards : scryState.bottomCards; + + const [moved] = srcArr.splice(srcIdx, 1); + dstArr.splice(dstIdx, 0, moved); + + renderScryZones(); + } catch(err) {} + }); + + container.appendChild(el); + }); +} + +function confirmScry() { + if (!gameState || scryState.playerId === null) return; + pushUndoState(); + const player = gameState.players[scryState.playerId]; + if (!player) return; + + // Put top cards back on top of library (index 0 = first drawn) + for (let i = scryState.topCards.length - 1; i >= 0; i--) { + player.zones.library.unshift(scryState.topCards[i]); + } + + // Put bottom cards on bottom of library + scryState.bottomCards.forEach(card => { + player.zones.library.push(card); + }); + + const topNames = scryState.topCards.map(c => c.name); + const bottomNames = scryState.bottomCards.map(c => c.name); + + let msg = '\uD83D\uDD2E ' + player.name + ' scried: '; + if (topNames.length) msg += topNames.length + ' to top'; + if (topNames.length && bottomNames.length) msg += ', '; + if (bottomNames.length) msg += bottomNames.length + ' to bottom'; + addLogEntry(msg); + + scryState = { playerId: null, topCards: [], bottomCards: [] }; + updatePlayerZones(); + closeScryModal(); + + if (zoneModalPlayerId !== null && gameState.players[zoneModalPlayerId]) renderZoneModal(); +} + +function scryAllTop() { + scryState.topCards = scryState.topCards.concat(scryState.bottomCards); + scryState.bottomCards = []; + renderScryZones(); +} + +function scryAllBottom() { + scryState.bottomCards = scryState.bottomCards.concat(scryState.topCards); + scryState.topCards = []; + renderScryZones(); +} + +function showScryDialog(playerId) { + const player = gameState && gameState.players[playerId]; + if (!player) return; + + const existing = document.getElementById('scry-dialog'); + if (existing) existing.remove(); + + const libSize = player.zones.library.length; + if (libSize === 0) { + addLogEntry('' + player.name + ' has no cards in library to scry'); + return; + } + + const dialog = document.createElement('div'); + dialog.id = 'scry-dialog'; + dialog.style.cssText = 'position:fixed;inset:0;z-index:800;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);'; + dialog.innerHTML = + '
' + + '
\uD83D\uDD2E Scry
' + + '
' + player.name + ' (' + libSize + ' cards in library)
' + + '
How many cards to look at?
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + 'Custom:' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.body.appendChild(dialog); + + const input = document.getElementById('scry-count-input'); + + // Quick buttons + dialog.querySelectorAll('.scry-quick-btn').forEach(btn => { + btn.addEventListener('click', () => { + const n = parseInt(btn.dataset.n); + dialog.remove(); + openScryModal(playerId, n); + }); + }); + + document.getElementById('scry-dialog-cancel').addEventListener('click', () => dialog.remove()); + document.getElementById('scry-dialog-confirm').addEventListener('click', () => { + const n = parseInt(input.value); + if (!isNaN(n) && n > 0) { + dialog.remove(); + openScryModal(playerId, Math.min(n, libSize)); + } + }); + + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + const n = parseInt(input.value); + if (!isNaN(n) && n > 0) { + dialog.remove(); + openScryModal(playerId, Math.min(n, libSize)); + } + } + if (e.key === 'Escape') dialog.remove(); + }); + + dialog.addEventListener('click', e => { if (e.target === dialog) dialog.remove(); }); + input.focus(); + input.select(); +} + +function showMillDialog(playerId) { + const player = gameState && gameState.players[playerId]; + if (!player) return; + + const existing = document.getElementById('mill-dialog'); + if (existing) existing.remove(); + + const libSize = player.zones.library.length; + if (libSize === 0) { + addLogEntry('' + player.name + ' has no cards in library to mill'); + return; + } + + const dialog = document.createElement('div'); + dialog.id = 'mill-dialog'; + dialog.style.cssText = 'position:fixed;inset:0;z-index:800;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);'; + dialog.innerHTML = + '
' + + '
Mill Cards
' + + '
' + player.name + ' (' + libSize + ' in library)
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + + document.body.appendChild(dialog); + const input = document.getElementById('mill-count-input'); + input.focus(); + input.select(); + + document.getElementById('mill-dialog-cancel').addEventListener('click', () => dialog.remove()); + document.getElementById('mill-dialog-confirm').addEventListener('click', () => { + const n = parseInt(input.value); + if (!isNaN(n) && n > 0) { + dialog.remove(); + millCards(playerId, Math.min(n, libSize)); + } + }); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + const n = parseInt(input.value); + if (!isNaN(n) && n > 0) { dialog.remove(); millCards(playerId, Math.min(n, libSize)); } + } + if (e.key === 'Escape') dialog.remove(); + }); + dialog.addEventListener('click', e => { if (e.target === dialog) dialog.remove(); }); +} + +// Zone panel command zone cast button helper (called from renderZoneBody) +function getCastCommanderButtonsHtml(playerId) { + if (!gameState) return ''; + var player = gameState.players[playerId]; + if (!player) return ''; + var html = ''; + if (player.commanderCardName) { + var tax = getCommanderTax(playerId); + html += ''; + } + if (player.commanderCardName2) { + var tax2 = getCommanderTax2(playerId); + html += ''; + } + return html; +} + +function updatePlayerZones() { + // Update zone counts in all player panels + gameState.players.forEach(player => { + const panel = document.getElementById(`player-panel-${player.id}`); + if (panel) { + const zonesBtn = panel.querySelector('.zones-btn'); + if (zonesBtn) { + const g = player.zones.graveyard.length; + const e = player.zones.exile.length; + const c = player.zones.command.length; + const h = player.zones.hand.length; + const l = player.zones.library.length; + zonesBtn.textContent = `⚰ ${g} ⬡ ${e} ⚔ ${c} ✋ ${h} 📚 ${l}`; + } + } + }); + + // Render Player 1's hand area + renderHandArea(); + + // Update zone panel (bottom-right persistent panel) + renderZonePanel(); +} + +// ============================================================ +// ZONE PANEL — Persistent Bottom-Right UI +// ============================================================ + +var zonePanelActiveTab = 'graveyard'; +var zonePanelPlayerId = 0; +var _zpContextMenu = null; + +function initZonePanel() { + // Toggle open/close + var toggle = document.getElementById('zone-panel-toggle'); + if (toggle) { + toggle.addEventListener('click', function() { + var panel = document.getElementById('zone-panel'); + if (panel) { + panel.classList.toggle('collapsed'); + if (!panel.classList.contains('collapsed')) { + renderZonePanel(); + } + } + }); + } + + // Tab clicks + var tabs = document.querySelectorAll('.zone-panel-tab'); + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + zonePanelActiveTab = tab.dataset.zpZone; + tabs.forEach(function(t) { t.classList.toggle('active', t.dataset.zpZone === zonePanelActiveTab); }); + renderZonePanelBody(); + }); + }); + + // Player select + var sel = document.getElementById('zone-panel-player-select'); + if (sel) { + sel.addEventListener('change', function() { + zonePanelPlayerId = parseInt(sel.value, 10) || 0; + renderZonePanelBody(); + }); + } + + // Create the zone-card context menu element + _zpContextMenu = document.createElement('div'); + _zpContextMenu.className = 'zp-context-menu'; + _zpContextMenu.id = 'zp-context-menu'; + document.body.appendChild(_zpContextMenu); + + // Close zone-card context menu on click outside + document.addEventListener('click', function(e) { + if (_zpContextMenu && !_zpContextMenu.contains(e.target)) { + _zpContextMenu.classList.remove('visible'); + } + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && _zpContextMenu) { + _zpContextMenu.classList.remove('visible'); + } + }); +} + +function renderZonePanel() { + if (!gameState || !gameState.players.length) return; + + // Update player select dropdown + var sel = document.getElementById('zone-panel-player-select'); + if (sel) { + var currentVal = sel.value; + sel.innerHTML = ''; + gameState.players.forEach(function(p) { + var opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + sel.appendChild(opt); + }); + // Restore selection + if (currentVal !== '' && gameState.players.some(function(p) { return p.id === parseInt(currentVal, 10); })) { + sel.value = currentVal; + zonePanelPlayerId = parseInt(currentVal, 10); + } else { + sel.value = gameState.players[0].id; + zonePanelPlayerId = gameState.players[0].id; + } + } + + // Update tab counts + var player = gameState.players.find(function(p) { return p.id === zonePanelPlayerId; }); + if (!player) player = gameState.players[0]; + if (!player) return; + + var tabs = document.querySelectorAll('.zone-panel-tab'); + tabs.forEach(function(tab) { + var zone = tab.dataset.zpZone; + var count = (player.zones[zone] || []).length; + var countEl = tab.querySelector('.zp-count'); + if (countEl) countEl.textContent = count; + }); + + // Update badge total (graveyard + exile + command) + var totalCards = (player.zones.graveyard || []).length + + (player.zones.exile || []).length + + (player.zones.command || []).length; + var badge = document.getElementById('zone-panel-badge'); + if (badge) { + badge.textContent = totalCards; + badge.setAttribute('data-count', totalCards); + } + + renderZonePanelBody(); +} + +function renderZonePanelBody() { + var body = document.getElementById('zone-panel-body'); + if (!body || !gameState) return; + + var player = gameState.players.find(function(p) { return p.id === zonePanelPlayerId; }); + if (!player) player = gameState.players[0]; + if (!player) return; + + var cards = player.zones[zonePanelActiveTab] || []; + + if (!cards.length) { + body.innerHTML = '
No cards in ' + zonePanelActiveTab + '.
'; + return; + } + + body.innerHTML = '
'; + var grid = body.querySelector('.zp-cards-grid'); + + cards.forEach(function(card, idx) { + var el = document.createElement('div'); + el.className = 'zp-card'; + el.title = card.name; + + // Commander tax badge for command zone cards + var taxBadge = ''; + if (zonePanelActiveTab === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) { + var pIdx2 = gameState.players.indexOf(player); + var tax = getCommanderTax(pIdx2 >= 0 ? pIdx2 : 0); + if (tax > 0) { + taxBadge = '
(Tax: +' + tax + ')
'; + } + } + + // Discard button for hand zone + var discardBtn = ''; + if (zonePanelActiveTab === 'hand') { + var pIdx3 = gameState.players.indexOf(player); + discardBtn = ''; + } + + el.innerHTML = + '' + card.name.replace(/' + + '
' + card.name + taxBadge + '
' + + discardBtn; + + // Hover preview + el.setAttribute('onmouseenter', "showFloatingCardPreview('" + card.imageUrl.replace(/'/g, "\\'") + "', event, { name: '" + card.name.replace(/'/g, "\\'") + "', typeLine: '" + (card.typeLine || '').replace(/'/g, "\\'") + "', oracleText: '" + (card.oracleText || '').replace(/'/g, "\\'").replace(/\n/g, '\\n') + "' })"); + el.setAttribute('onmousemove', 'positionFloatingPreview(event)'); + el.setAttribute('onmouseleave', 'hideFloatingCardPreview()'); + + // Left-click: play to battlefield + var pIdxClick = gameState.players.indexOf(player); + el.setAttribute('onclick', "(function(e){e.preventDefault();e.stopPropagation();zpMoveCardToBattlefield(gameState.players[" + pIdxClick + "],'" + zonePanelActiveTab + "'," + idx + ")})(event)"); + + // Right-click: zone context menu + el.setAttribute('oncontextmenu', "(function(e){e.preventDefault();e.stopPropagation();showZpContextMenu(e.clientX,e.clientY,gameState.players[" + pIdxClick + "],'" + zonePanelActiveTab + "'," + idx + ")})(event)"); + + grid.appendChild(el); + }); +} + +function zpMoveCardToBattlefield(player, zone, cardIndex) { + pushUndoState(); + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + var pIdx = gameState.players.indexOf(player); + // Track commander cast count for tax + if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) { + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + var tax = getCommanderTax(pIdx >= 0 ? pIdx : 0); + if (tax > 0) { + addLogEntry('👑 ' + card.name + ' cast from command zone (Tax: +' + tax + ', cast #' + player.commanderCastCount + ')'); + } + } + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, pIdx >= 0 ? pIdx : 0, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords }); + addLogEntry('' + card.name + ' moved from ' + player.name + "'s " + zone + ' to battlefield'); + updatePlayerZones(); + // Also update the full zone modal if it's open + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); +} + +function zpMoveCardToHand(player, zone, cardIndex) { + pushUndoState(); + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + player.zones.hand.push(card); + addLogEntry('' + card.name + ' moved from ' + player.name + "'s " + zone + ' to hand'); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); +} + +function zpMoveCardToZone(player, fromZone, cardIndex, toZone) { + pushUndoState(); + var cards = player.zones[fromZone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + player.zones[toZone].push(card); + var zoneLabels = { graveyard: 'graveyard', exile: 'exile', command: 'command zone', hand: 'hand', library: 'library' }; + addLogEntry('' + card.name + ' moved from ' + player.name + "'s " + (zoneLabels[fromZone] || fromZone) + ' to ' + (zoneLabels[toZone] || toZone)); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); +} + +function zpRemoveCard(player, zone, cardIndex) { + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + addLogEntry('' + card.name + ' removed from ' + player.name + "'s " + zone); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); +} + +function showZpContextMenu(x, y, player, zone, cardIndex) { + if (!_zpContextMenu) return; + var card = player.zones[zone][cardIndex]; + if (!card) return; + + // Build menu items based on current zone + var items = []; + + // Always offer "Play to Battlefield" + items.push({ label: '⚔ Play to Battlefield', action: 'battlefield' }); + + // Return to hand (unless already in hand) + if (zone !== 'hand') { + items.push({ label: '✋ Return to Hand', action: 'hand' }); + } + + items.push({ divider: true }); + + // Move between zones + if (zone !== 'graveyard') items.push({ label: '☠ Send to Graveyard', action: 'graveyard' }); + if (zone !== 'exile') items.push({ label: '⬡ Send to Exile', action: 'exile' }); + if (zone !== 'command') items.push({ label: '⚔ Send to Command Zone', action: 'command' }); + + items.push({ divider: true }); + + // Library + items.push({ label: '📚 Put on Top of Library', action: 'library-top' }); + items.push({ label: '📚 Put on Bottom of Library', action: 'library-bottom' }); + + items.push({ divider: true }); + items.push({ label: '✕ Remove', action: 'remove', danger: true }); + + // Render + _zpContextMenu.innerHTML = ''; + items.forEach(function(item) { + if (item.divider) { + var div = document.createElement('div'); + div.className = 'zp-context-divider'; + _zpContextMenu.appendChild(div); + return; + } + var btn = document.createElement('button'); + btn.className = 'zp-context-item' + (item.danger ? ' danger' : ''); + btn.textContent = item.label; + var pIdx = gameState.players.indexOf(player); + var act = item.action; + var z = zone; + var ci = cardIndex; + btn.setAttribute('onclick', "(function(){document.getElementById('zp-context-menu').classList.remove('visible');handleZpContextAction(gameState.players[" + pIdx + "],'" + z + "'," + ci + ",'" + act + "')})()"); + _zpContextMenu.appendChild(btn); + }); + + // Position + _zpContextMenu.style.left = x + 'px'; + _zpContextMenu.style.top = y + 'px'; + _zpContextMenu.classList.add('visible'); + + // Adjust if off-screen + requestAnimationFrame(function() { + var rect = _zpContextMenu.getBoundingClientRect(); + if (rect.right > window.innerWidth) _zpContextMenu.style.left = (x - rect.width) + 'px'; + if (rect.bottom > window.innerHeight) _zpContextMenu.style.top = (y - rect.height) + 'px'; + }); +} + +function handleZpContextAction(player, zone, cardIndex, action) { + switch (action) { + case 'battlefield': + zpMoveCardToBattlefield(player, zone, cardIndex); + break; + case 'hand': + zpMoveCardToHand(player, zone, cardIndex); + break; + case 'graveyard': + zpMoveCardToZone(player, zone, cardIndex, 'graveyard'); + break; + case 'exile': + zpMoveCardToZone(player, zone, cardIndex, 'exile'); + break; + case 'command': + zpMoveCardToZone(player, zone, cardIndex, 'command'); + break; + case 'library-top': { + var cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + var card = cards.splice(cardIndex, 1)[0]; + player.zones.library.unshift(card); + addLogEntry('' + card.name + ' put on top of ' + player.name + "'s library from " + zone); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); + break; + } + case 'library-bottom': { + var cards2 = player.zones[zone]; + if (!cards2 || cardIndex < 0 || cardIndex >= cards2.length) return; + var card2 = cards2.splice(cardIndex, 1)[0]; + player.zones.library.push(card2); + addLogEntry('' + card2.name + ' put on bottom of ' + player.name + "'s library from " + zone); + updatePlayerZones(); + if (typeof renderZoneModal === 'function' && zoneModalPlayerId !== null) renderZoneModal(); + break; + } + case 'remove': + zpRemoveCard(player, zone, cardIndex); + break; + } +} + +// ============================================================ +// PLAYER HAND AREA +// ============================================================ + +function renderHandArea() { + renderStackPanel(); + renderLegalActionsBar(); + if (!gameState) return; + const player = gameState.players[0]; // Player 1 (index 0) + if (!player) return; + + const container = document.getElementById('hand-cards'); + const countEl = document.getElementById('hand-card-count'); + const ownerEl = document.getElementById('hand-owner-name'); + if (!container) return; + + if (ownerEl) ownerEl.textContent = player.name + "'s"; + const hand = player.zones.hand; + if (countEl) countEl.textContent = `${hand.length} card${hand.length !== 1 ? 's' : ''}`; + + if (hand.length === 0) { + container.innerHTML = '
No cards in hand. Draw or search to add cards.
'; + return; + } + + container.innerHTML = ''; + + hand.forEach((card, idx) => { + const cardEl = document.createElement('div'); + cardEl.className = 'hand-card'; + cardEl.title = card.name; + cardEl.dataset.handIndex = idx; + + // During mulligan bottom phase, highlight cards as selectable + if (player.mulliganPhase === 'bottom') { + cardEl.classList.add('mulligan-selectable'); + } + + // Phase-locked visual feedback: dim sorcery-speed cards outside main phases + if (gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure) { + var curPhase = PHASES[gameState.currentPhaseIndex]; + var curPhaseId = curPhase ? curPhase.id : ''; + var inMain = (curPhaseId === 'main1' || curPhaseId === 'main2'); + if (!inMain) { + var isInstantType = card.typeLine && /instant/i.test(card.typeLine); + var hasFlashAbil = card.oracleText && /\bflash\b/i.test(card.oracleText); + if (!isInstantType && !hasFlashAbil) { + cardEl.classList.add('phase-locked'); + } + } + } + + // Mana castability badge — considers both pool AND untapped lands with auto-tap + var castBadge = ''; + if (gameState && card.manaCost) { + var activePlayer = gameState.players[gameState.currentPlayerIndex]; + var canCast = canPayManaCost(card.manaCost, activePlayer ? activePlayer.manaPool : null); + if (!canCast && gameState.rulesEngine && gameState.rulesEngine.autoTapLands) { + var tapCheck = calculateAutoTap(card.manaCost, gameState.currentPlayerIndex); + if (tapCheck.success || tapCheck.poolSufficient) canCast = true; + } + var isLandCard = card.typeLine && /land/i.test(card.typeLine); + if (!isLandCard) { + castBadge = '
' + (canCast ? '✓' : '✕') + '
'; + } + } + cardEl.innerHTML = ` + ${card.name} +
${card.name}
+ ${castBadge} + `; + + // Click behavior depends on mulligan phase + if (player.mulliganPhase === 'bottom') { + cardEl.setAttribute('onclick', `mulliganPutOnBottom(0, ${idx})`); + } else if (!player.mulliganPhase) { + // Normal play: click to play to battlefield (inline onclick for Comet compatibility) + cardEl.setAttribute('onclick', `playCardFromHand(0, ${idx})`); + } + // During 'deciding' phase, clicks on cards do nothing (just view) + + // Right-click for hand context menu + cardEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showHandContextMenu(e.clientX, e.clientY, 0, idx); + }); + + // Hover preview + cardEl.addEventListener('mouseenter', (e) => { + showFloatingCardPreview(card.imageUrl, e, { name: card.name, typeLine: card.typeLine, oracleText: card.oracleText, manaCost: card.manaCost || '' }); + }); + cardEl.addEventListener('mousemove', positionFloatingPreview); + cardEl.addEventListener('mouseleave', hideFloatingCardPreview); + + container.appendChild(cardEl); + }); +} + +function playCardFromHand(playerId, handIndex) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + const hand = player.zones.hand; + if (handIndex < 0 || handIndex >= hand.length) return; + + const card = hand[handIndex]; + var isLand = card.typeLine && /land/i.test(card.typeLine); + + // --- Phase restriction: only allow card play during Main phases (when enforcement on) --- + if (gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure) { + var phase = PHASES[gameState.currentPhaseIndex]; + var phaseId = phase ? phase.id : ''; + var isMainPhase = (phaseId === 'main1' || phaseId === 'main2'); + var isLand = card.typeLine && /land/i.test(card.typeLine); + var isInstant = card.typeLine && /instant/i.test(card.typeLine); + var hasFlash = card.oracleText && /\bflash\b/i.test(card.oracleText); + + // Lands can only be played during your main phase + if (isLand && !isMainPhase) { + addLogEntry('\u26a0\ufe0f Cannot play lands outside of a Main Phase.', 'rules'); + return; + } + // Non-instant, non-flash spells (sorcery speed) can only be played during main phases + if (!isLand && !isInstant && !hasFlash && !isMainPhase) { + addLogEntry('\u26a0\ufe0f ' + card.name + ' can only be cast during a Main Phase (sorcery speed).', 'rules'); + return; + } + } + pushUndoState("Play " + card.name + " from hand"); + + // Auto-tap and spend mana for the card cost (skip for lands) + if (!isLand && card.manaCost) { + attemptManaPay(card.manaCost, playerId, card.name); + } + + // Feature 3: If useStack is enabled, push non-lands to the stack + if (gameState.rulesEngine && gameState.rulesEngine.useStack && !isLand) { + hand.splice(handIndex, 1); + var stackEntry = { + id: 'stk_' + Date.now() + '_' + Math.random().toString(36).slice(2), + name: card.name, + imageUrl: card.imageUrl, + typeLine: card.typeLine || '', + oracleText: card.oracleText || '', + manaCost: card.manaCost || '', + cmc: card.cmc || 0, + power: card.power || '', + toughness: card.toughness || '', + pt: card.pt || '', + loyalty: card.loyalty || '', + colorIdentity: card.colorIdentity, + keywords: card.keywords, + ownerIndex: playerId, + passCount: 0, + }; + gameState.theStack.push(stackEntry); + addLogEntry('\ud83d\udcda ' + player.name + ' cast ' + card.name + ' \u2014 on the stack'); + renderStackPanel(); + renderHandArea(); + // Open priority window so opponents can respond (counterspell, etc.) + if (isPriorityEnabled()) { + openPriorityWindow('spell_cast'); + } else if (!player.isAI) { + // Give AI players a chance to respond (e.g., counterspell) + setTimeout(function() { aiRespondToStack(); }, 800); + } + return; + } + + // AI spell cast: show response popup to human player before resolving + if (player.isAI && !isLand) { + var aiTarget = aiSelectSpellTarget(card, playerId); + showAiCastPopup(card, playerId, handIndex, aiTarget); + return; + } + + hand.splice(handIndex, 1); + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords }); + addLogEntry(`\u270b ${player.name} played ${card.name} from hand`); + renderHandArea(); +} + +// Hand-specific context menu +let handContextState = { playerId: null, handIndex: null }; + +function showHandContextMenu(x, y, playerId, handIndex) { + handContextState = { playerId, handIndex }; + const card = gameState.players[playerId].zones.hand[handIndex]; + if (!card) return; + + const menu = document.getElementById('context-menu'); + if (!menu) return; + + menu.innerHTML = ` +
⚔ Play to Battlefield
+
⚰ Discard
+
⬡ Exile
+
📚 Put on Top of Library
+
📚 Put on Bottom of Library
+ `; + + // Position + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + menu.classList.add('visible'); + + // Adjust if offscreen + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = (x - rect.width) + 'px'; + if (rect.bottom > window.innerHeight) menu.style.top = (y - rect.height) + 'px'; + }); +} + +function handleHandContextAction(action) { + const { playerId, handIndex } = handContextState; + if (playerId === null || handIndex === null || !gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + const hand = player.zones.hand; + if (handIndex < 0 || handIndex >= hand.length) return; + + if (action === 'play') { + playCardFromHand(playerId, handIndex); + return; + } + + const card = hand.splice(handIndex, 1)[0]; + + switch (action) { + case 'graveyard': + player.zones.graveyard.push(card); + addLogEntry(`⚰ ${player.name} discarded ${card.name}`); + break; + case 'exile': + player.zones.exile.push(card); + addLogEntry(`⬡ ${player.name} exiled ${card.name} from hand`); + break; + case 'library-top': + player.zones.library.unshift(card); + addLogEntry(`📚 ${player.name} put ${card.name} on top of library`); + break; + case 'library-bottom': + player.zones.library.push(card); + addLogEntry(`📚 ${player.name} put ${card.name} on bottom of library`); + break; + } + + renderHandArea(); + updatePlayerZones(); +} + +// ============================================================ +// GAME LOG +// ============================================================ + +function _getLogCategory(text) { + var t = text || ''; + if (/\u2694|combat|attack|block|damage|dmg/i.test(t)) return 'combat'; + if (/cast|spell|play|played/i.test(t)) return 'spell'; + if (/died|dies|sacrificed|destroyed|death/i.test(t)) return 'death'; + if (/life|heal|gain|lose/i.test(t)) return 'life'; + if (/poison|toxic/i.test(t)) return 'poison'; + if (/\u2699|rule|undo|redo|engine/i.test(t)) return 'rules'; + return 'general'; +} + +var LOG_CATEGORY_ICONS = { + combat: { icon: '\u2694', color: '#e74c3c' }, + spell: { icon: '\uD83C\uDCCF', color: '#3498db' }, + death: { icon: '\u2620', color: '#8e44ad' }, + life: { icon: '\u2665', color: '#e91e8c' }, + poison: { icon: '\u2620', color: '#27ae60' }, + rules: { icon: '\u2699', color: '#95a5a6' }, + general:{ icon: '\u2022', color: 'var(--color-text-faint)' }, +}; + +var _lastLogTurn = -1; + +function addLogEntry(text, category) { + if (!gameState) return; + var now = new Date(); + var time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0'); + var turnNum = gameState.turnNumber || 1; + var cat = category || _getLogCategory(text); + var catInfo = LOG_CATEGORY_ICONS[cat] || LOG_CATEGORY_ICONS.general; + var timestampLabel = '[T' + turnNum + ' ' + time + ']'; + var logEntry = { time: time, text: text, turn: turnNum, category: cat, timestamp: timestampLabel }; + gameState.log.unshift(logEntry); + if (gameState.log.length > 300) gameState.log.pop(); + var logContainer = document.getElementById('log-entries'); + if (!logContainer) return; + // Add turn separator if new turn + if (turnNum !== _lastLogTurn) { + _lastLogTurn = turnNum; + var sep = document.createElement('div'); + sep.className = 'log-turn-separator'; + sep.innerHTML = 'Turn ' + turnNum + ' — ' + (gameState.players[gameState.currentPlayerIndex] ? gameState.players[gameState.currentPlayerIndex].name : ''); + logContainer.insertBefore(sep, logContainer.firstChild); + // Keep only 80 visible entries (separators + entries) + while (logContainer.children.length > 90) logContainer.removeChild(logContainer.lastChild); + } + var entry = document.createElement('div'); + entry.className = 'log-entry log-cat-' + cat; + entry.setAttribute('data-category', cat); + entry.innerHTML = + '' + catInfo.icon + '' + + '' + timestampLabel + '' + + '' + text + ''; + logContainer.insertBefore(entry, logContainer.firstChild); + while (logContainer.children.length > 90) logContainer.removeChild(logContainer.lastChild); + // Also write to floating game log + if (typeof addFloatingLogEntry === 'function') addFloatingLogEntry(text, cat); +} + +// Feature 13: Full log modal +function openFullLogModal() { + if (!gameState) return; + var modal = document.getElementById('full-log-modal'); + if (!modal) return; + modal.classList.add('visible'); + renderFullLog(); +} + +function closeFullLogModal() { + var modal = document.getElementById('full-log-modal'); + if (modal) modal.classList.remove('visible'); +} + +var _logFilterCategory = 'all'; + +function setLogFilter(cat) { + _logFilterCategory = cat; + renderFullLog(); + document.querySelectorAll('.log-filter-btn').forEach(function(b) { + b.classList.toggle('active', b.dataset.cat === cat); + }); +} + +function renderFullLog() { + if (!gameState) return; + var body = document.getElementById('full-log-body'); + if (!body) return; + var logs = gameState.log || []; + var filtered = _logFilterCategory === 'all' ? logs : logs.filter(function(e) { return e.category === _logFilterCategory; }); + var html = ''; + filtered.forEach(function(e) { + var cat = e.category || 'general'; + var catInfo = LOG_CATEGORY_ICONS[cat] || LOG_CATEGORY_ICONS.general; + html += '
' + + '' + catInfo.icon + '' + + '' + (e.timestamp || ('[T' + (e.turn||1) + ' ' + e.time + ']')) + '' + + '' + e.text + '
'; + }); + body.innerHTML = html || '
No log entries.
'; +} + +function exportGameLog() { + if (!gameState) return; + var logs = gameState.log || []; + var lines = logs.map(function(e) { + return (e.timestamp || e.time) + ' ' + (e.text || '').replace(/<[^>]+>/g,''); + }); + var blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = 'commander-log.txt'; a.click(); + URL.revokeObjectURL(url); +} + +// ============================================================ +// DICE & COIN +// ============================================================ + +function openDiceModal() { + const modal = document.getElementById('dice-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeDiceModal() { + const modal = document.getElementById('dice-modal'); + if (modal) modal.classList.remove('visible'); +} + +function rollDice(sides) { + const result = Math.floor(Math.random() * sides) + 1; + const resultEl = document.getElementById('dice-result'); + const labelEl = document.getElementById('dice-label'); + + if (resultEl) { + resultEl.classList.remove('rolling'); + resultEl.offsetWidth; + resultEl.textContent = result; + resultEl.classList.add('rolling'); + resultEl.classList.remove('coin-result'); + } + if (labelEl) labelEl.textContent = `d${sides}`; + + addLogEntry(`🎲 Rolled d${sides}: ${result}`); +} + +function flipCoin() { + const result = Math.random() < 0.5 ? 'HEADS' : 'TAILS'; + const resultEl = document.getElementById('dice-result'); + const labelEl = document.getElementById('dice-label'); + + if (resultEl) { + resultEl.classList.remove('rolling'); + resultEl.offsetWidth; + resultEl.textContent = result === 'HEADS' ? '🪙' : '☽'; + resultEl.classList.add('rolling', 'coin-result'); + } + if (labelEl) labelEl.textContent = result; + + addLogEntry(`🪙 Coin flip: ${result}`); +} + +// ============================================================ +// DECK IMPORT / EXPORT +// ============================================================ + +let deckModalPlayerId = null; +let deckImportAbortController = null; + +/** + * Parse a decklist text string into an array of { qty, name, section }. + * Supports common formats from Moxfield, Archidekt, EDHREC, TappedOut, etc. + */ +function parseDeckList(text) { + const lines = text.split(/\r?\n/); + const cards = []; + let currentSection = 'mainboard'; + + for (let raw of lines) { + let line = raw.trim(); + if (!line) continue; + + // Section headers + if (/^\/\/\s*/i.test(line) || /^(COMMANDER|COMPANION|SIDEBOARD|MAYBEBOARD|MAINBOARD|DECK|LAND)[:\s]*$/i.test(line)) { + const cleaned = line.replace(/^\/\/\s*/, '').replace(/:$/, '').trim().toLowerCase(); + if (/commander/i.test(cleaned)) currentSection = 'commander'; + else if (/companion/i.test(cleaned)) currentSection = 'companion'; + else if (/sideboard/i.test(cleaned)) currentSection = 'sideboard'; + else currentSection = 'mainboard'; + continue; + } + + // Skip comment lines + if (/^#/.test(line) || /^\*/.test(line)) continue; + + // Strip set codes in parentheses/brackets + line = line.replace(/\s*[\(\[][A-Z0-9]+[\)\]]\s*\d*\s*$/, '').trim(); + // Strip collector number at end + line = line.replace(/\s+\d+\s*$/, '').trim(); + // Strip foil/etched tags + line = line.replace(/\s*\*F\*\s*$/i, '').trim(); + + // Match: optional quantity + card name + const match = line.match(/^(?:(\d+)\s*[xX]?\s+)?(.+)$/); + if (!match) continue; + + const qty = match[1] ? parseInt(match[1]) : 1; + const name = match[2].trim(); + + if (!name || name.length < 2) continue; + + cards.push({ qty: Math.min(qty, 99), name, section: currentSection }); + } + + return cards; +} + +/** + * Batch-fetch card data from Scryfall's /cards/collection endpoint. + * Returns a Map. + */ +async function batchFetchCards(cardNames, progressCallback) { + const uniqueNames = [...new Set(cardNames.map(n => n.toLowerCase()))]; + const results = new Map(); + const BATCH_SIZE = 75; + const RATE_LIMIT = 100; + + for (let i = 0; i < uniqueNames.length; i += BATCH_SIZE) { + if (deckImportAbortController?.signal?.aborted) throw new Error('Import cancelled'); + + const batch = uniqueNames.slice(i, i + BATCH_SIZE); + const identifiers = batch.map(name => ({ name })); + + if (progressCallback) { + progressCallback(Math.min(i + BATCH_SIZE, uniqueNames.length), uniqueNames.length); + } + + try { + const resp = await fetch(`${SCRYFALL_BASE}/cards/collection`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifiers }), + signal: deckImportAbortController?.signal, + }); + + if (!resp.ok) { + console.warn('[Deck Import] Batch request failed:', resp.status); + continue; + } + + const data = await resp.json(); + + if (data.data) { + for (const card of data.data) { + results.set(card.name.toLowerCase(), card); + } + } + } catch (err) { + if (err.name === 'AbortError' || err.message === 'Import cancelled') throw err; + console.warn('[Deck Import] Batch fetch error:', err); + } + + if (i + BATCH_SIZE < uniqueNames.length) { + await new Promise(r => setTimeout(r, RATE_LIMIT)); + } + } + + return results; +} + +function scryfallToZoneCard(card) { + let imageUrl = ''; + if (card.image_uris && card.image_uris.normal) { + imageUrl = card.image_uris.normal; + } else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) { + imageUrl = card.card_faces[0].image_uris.normal; + } + // Build power/toughness string from Scryfall fields + var pt = ''; + if (card.power !== undefined && card.toughness !== undefined) { + pt = card.power + '/' + card.toughness; + } + return { + name: card.name, + imageUrl, + typeLine: card.type_line || '', + oracleText: card.oracle_text || (card.card_faces ? card.card_faces.map(f => f.oracle_text || '').join('\n') : ''), + manaCost: card.mana_cost || '', + cmc: card.cmc || 0, + power: card.power || '', + toughness: card.toughness || '', + pt: pt, + loyalty: card.loyalty || '', + colorIdentity: card.color_identity || [], + keywords: card.keywords || [], + }; +} + +function openDeckModal(playerId) { + if (!gameState) return; + deckModalPlayerId = playerId; + const player = gameState.players[playerId]; + if (!player) return; + + const modal = document.getElementById('deck-modal'); + if (!modal) return; + + const titleEl = document.getElementById('deck-modal-title'); + if (titleEl) titleEl.textContent = `${player.name}'s Deck Manager`; + + const textarea = document.getElementById('deck-import-text'); + if (textarea) textarea.value = ''; + + updateDeckImportProgress(0, 0, ''); + const statusEl = document.getElementById('deck-import-status'); + if (statusEl) statusEl.textContent = ''; + + updateDeckSummary(playerId); + + modal.classList.add('visible'); +} + +function closeDeckModal() { + const modal = document.getElementById('deck-modal'); + if (modal) modal.classList.remove('visible'); + cancelDeckImport(); + deckModalPlayerId = null; +} + +function updateDeckSummary(playerId) { + const el = document.getElementById('deck-current-summary'); + if (!el || !gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + + const lib = player.zones.library.length; + const hand = player.zones.hand.length; + const cmd = player.zones.command.length; + const total = lib + hand + cmd; + + el.innerHTML = ` +
${total} total cards
+
${lib} in library
+
${hand} in hand
+
${cmd} in command zone
+ `; +} + +function updateDeckImportProgress(loaded, total, message) { + const bar = document.getElementById('deck-progress-bar'); + const text = document.getElementById('deck-progress-text'); + const container = document.getElementById('deck-progress'); + + if (container) container.style.display = (total > 0) ? '' : 'none'; + if (bar && total > 0) bar.style.width = `${Math.round((loaded / total) * 100)}%`; + if (text) text.textContent = message || `${loaded} / ${total} cards`; +} + +function cancelDeckImport() { + if (deckImportAbortController) { + deckImportAbortController.abort(); + deckImportAbortController = null; + } +} + +async function importDeck() { + if (!gameState || deckModalPlayerId === null) return; + const player = gameState.players[deckModalPlayerId]; + if (!player) return; + + const textarea = document.getElementById('deck-import-text'); + const statusEl = document.getElementById('deck-import-status'); + const text = textarea?.value || ''; + + if (!text.trim()) { + if (statusEl) statusEl.textContent = 'Paste a deck list above first.'; + return; + } + + const parsedCards = parseDeckList(text); + if (!parsedCards.length) { + if (statusEl) statusEl.textContent = 'Could not parse any cards from the list.'; + return; + } + + const allCardNames = []; + const commanderNames = []; + for (const entry of parsedCards) { + for (let q = 0; q < entry.qty; q++) { + allCardNames.push(entry.name); + } + if (entry.section === 'commander') { + commanderNames.push(entry.name); + } + } + + if (statusEl) statusEl.textContent = `Parsed ${allCardNames.length} cards. Fetching from Scryfall...`; + + deckImportAbortController = new AbortController(); + + const importBtn = document.getElementById('deck-import-btn'); + const cancelBtn = document.getElementById('deck-cancel-btn'); + if (importBtn) { importBtn.disabled = true; importBtn.textContent = 'Importing...'; } + if (cancelBtn) cancelBtn.style.display = ''; + + try { + const cardDataMap = await batchFetchCards( + allCardNames, + (loaded, total) => updateDeckImportProgress(loaded, total, `Fetching cards: ${loaded} / ${total}`) + ); + + const libraryCards = []; + const commandCards = []; + const notFound = []; + + for (const entry of parsedCards) { + const scryfallCard = cardDataMap.get(entry.name.toLowerCase()); + + if (!scryfallCard) { + notFound.push(entry.name); + continue; + } + + const cardObj = scryfallToZoneCard(scryfallCard); + + for (let q = 0; q < entry.qty; q++) { + if (entry.section === 'commander') { + commandCards.push({ ...cardObj }); + } else { + libraryCards.push({ ...cardObj }); + } + } + } + + const importMode = document.querySelector('input[name="deck-import-mode"]:checked')?.value || 'replace'; + + if (importMode === 'replace') { + player.zones.library = []; + player.zones.hand = []; + player.zones.command = []; + } + + commandCards.forEach(card => player.zones.command.push(card)); + // Set commander name for zone replacement + if (commandCards.length > 0 && !player.commanderCardName) { + player.commanderCardName = commandCards[0].name || ''; + } + + // Shuffle library + for (let i = libraryCards.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [libraryCards[i], libraryCards[j]] = [libraryCards[j], libraryCards[i]]; + } + + libraryCards.forEach(card => player.zones.library.push(card)); + + // Draw opening hand (7 cards) if replacing + if (importMode === 'replace' && player.zones.library.length >= 7) { + for (let i = 0; i < 7; i++) { + const card = player.zones.library.shift(); + player.zones.hand.push(card); + } + } + + updatePlayerZones(); + renderPlayerPanels(); + updateDeckSummary(deckModalPlayerId); + + let status = `\u2713 Imported ${libraryCards.length + commandCards.length} cards.`; + if (commandCards.length) status += ` ${commandCards.length} commander(s).`; + if (importMode === 'replace') status += ` Drew 7-card opening hand.`; + if (notFound.length) { + status += ` \u26a0 ${notFound.length} not found: ${notFound.slice(0, 5).join(', ')}${notFound.length > 5 ? '...' : ''}`; + } + if (statusEl) { statusEl.textContent = status; statusEl.className = 'deck-import-status success'; } + + addLogEntry(`\ud83d\udccb ${player.name} imported a ${libraryCards.length + commandCards.length}-card deck`); + + // Enter mulligan phase for player 1 + if (importMode === 'replace' && deckModalPlayerId === 0) { + enterMulliganPhase(0); + } + + updateDeckImportProgress(0, 0, ''); + + } catch (err) { + if (err.name === 'AbortError' || err.message === 'Import cancelled') { + if (statusEl) { statusEl.textContent = 'Import cancelled.'; statusEl.className = 'deck-import-status'; } + } else { + console.error('[Deck Import] Error:', err); + if (statusEl) { statusEl.textContent = 'Error importing deck. Check console.'; statusEl.className = 'deck-import-status error'; } + } + updateDeckImportProgress(0, 0, ''); + } finally { + deckImportAbortController = null; + if (importBtn) { importBtn.disabled = false; importBtn.textContent = '\ud83d\udce5 Import Deck'; } + if (cancelBtn) cancelBtn.style.display = 'none'; + } +} + +function exportDeck() { + if (!gameState || deckModalPlayerId === null) return; + const player = gameState.players[deckModalPlayerId]; + if (!player) return; + + const lines = []; + const cardCounts = new Map(); + const commanderCounts = new Map(); + + const deckCards = [...player.zones.library, ...player.zones.hand]; + for (const card of deckCards) { + cardCounts.set(card.name, (cardCounts.get(card.name) || 0) + 1); + } + + for (const card of player.zones.command) { + commanderCounts.set(card.name, (commanderCounts.get(card.name) || 0) + 1); + } + + if (commanderCounts.size > 0) { + lines.push('//Commander'); + for (const [name, qty] of commanderCounts) { + lines.push(`${qty} ${name}`); + } + lines.push(''); + } + + lines.push('//Deck'); + const sorted = [...cardCounts.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [name, qty] of sorted) { + lines.push(`${qty} ${name}`); + } + + const output = lines.join('\n'); + + const textarea = document.getElementById('deck-import-text'); + if (textarea) { + textarea.value = output; + textarea.select(); + } + + navigator.clipboard?.writeText(output).then(() => { + const statusEl = document.getElementById('deck-import-status'); + if (statusEl) { statusEl.textContent = `Exported ${deckCards.length + player.zones.command.length} cards \u2014 copied to clipboard.`; statusEl.className = 'deck-import-status success'; } + }).catch(() => { + const statusEl = document.getElementById('deck-import-status'); + if (statusEl) { statusEl.textContent = `Exported ${deckCards.length + player.zones.command.length} cards \u2014 select and copy from the text area.`; statusEl.className = 'deck-import-status'; } + }); +} + +function downloadDeck() { + if (!gameState || deckModalPlayerId === null) return; + const player = gameState.players[deckModalPlayerId]; + if (!player) return; + + exportDeck(); + + const textarea = document.getElementById('deck-import-text'); + const text = textarea?.value || ''; + if (!text.trim()) return; + + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${player.name.replace(/[^a-zA-Z0-9]/g, '_')}_deck.txt`; + a.click(); + URL.revokeObjectURL(url); +} + +function loadDeckFile() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt,.dec,.dek,.mwDeck,.csv'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const textarea = document.getElementById('deck-import-text'); + if (textarea) textarea.value = ev.target.result; + const statusEl = document.getElementById('deck-import-status'); + if (statusEl) { statusEl.textContent = `Loaded "${file.name}". Click Import to fetch card data.`; statusEl.className = 'deck-import-status'; } + }; + reader.readAsText(file); + }; + input.click(); +} + +// ============================================================ +// PRECON DECK BROWSER +// ============================================================ + +let preconColorFilter = 'all'; +let selectedPreconId = null; + +function switchDeckTab(tabName) { + document.querySelectorAll('.deck-tab').forEach(t => t.classList.toggle('active', t.dataset.deckTab === tabName)); + document.querySelectorAll('.deck-tab-content').forEach(c => c.classList.remove('active')); + const target = document.getElementById(`deck-tab-${tabName}`); + if (target) target.classList.add('active'); + + if (tabName === 'precons') { + initPreconBrowser(); + } +} + +function initPreconBrowser() { + if (typeof PRECON_DATA === 'undefined') return; + + // Populate set filter + const setSelect = document.getElementById('precon-filter-set'); + if (setSelect && setSelect.options.length <= 1) { + const sets = [...new Set(PRECON_DATA.map(p => p.set))]; + // Sort by year descending + sets.sort((a, b) => { + const ya = PRECON_DATA.find(p => p.set === a)?.year || 0; + const yb = PRECON_DATA.find(p => p.set === b)?.year || 0; + return yb - ya; + }); + sets.forEach(s => { + const opt = document.createElement('option'); + opt.value = s; + const yr = PRECON_DATA.find(p => p.set === s)?.year || ''; + opt.textContent = `${s} (${yr})`; + setSelect.appendChild(opt); + }); + } + + filterPrecons(); +} + +function setPreconColorFilter(color) { + preconColorFilter = color; + document.querySelectorAll('.precon-color-btn').forEach(b => b.classList.toggle('active', b.dataset.color === color)); + filterPrecons(); +} + +function filterPrecons() { + if (typeof PRECON_DATA === 'undefined') return; + + const setFilter = document.getElementById('precon-filter-set')?.value || 'all'; + const grid = document.getElementById('precon-grid'); + const detail = document.getElementById('precon-detail'); + if (!grid) return; + + // Hide detail, show grid + if (detail) detail.style.display = 'none'; + grid.style.display = ''; + selectedPreconId = null; + + let filtered = PRECON_DATA; + if (setFilter !== 'all') { + filtered = filtered.filter(p => p.set === setFilter); + } + if (preconColorFilter !== 'all') { + filtered = filtered.filter(p => p.colors.includes(preconColorFilter)); + } + + if (!filtered.length) { + grid.innerHTML = '
No precons match these filters.
'; + return; + } + + const COLOR_ICONS = { W: '\u2600', U: '\ud83d\udca7', B: '\ud83d\udc80', R: '\ud83d\udd25', G: '\ud83c\udf32', C: '\u25c7' }; + + grid.innerHTML = filtered.map(p => { + const colorPips = p.colors.split('').map(c => { + const icon = COLOR_ICONS[c] || c; + return `${icon}`; + }).join(''); + + return ` +
+
+
${p.name}
+
${colorPips}
+
+
${p.commander}
+
+ ${p.set} + ${p.theme} +
+
+ `; + }).join(''); +} + +function showPreconDetail(preconId) { + if (typeof PRECON_DATA === 'undefined') return; + const precon = PRECON_DATA.find(p => p.id === preconId); + if (!precon) return; + + selectedPreconId = preconId; + + const grid = document.getElementById('precon-grid'); + const detail = document.getElementById('precon-detail'); + if (grid) grid.style.display = 'none'; + if (detail) detail.style.display = ''; + + // Clear any previous import status + const statusEl = document.getElementById('precon-import-status'); + if (statusEl) { statusEl.textContent = ''; statusEl.className = 'precon-import-status'; } + const progressWrap = document.getElementById('precon-import-progress'); + if (progressWrap) progressWrap.style.display = 'none'; + + const nameEl = document.getElementById('precon-detail-name'); + const metaEl = document.getElementById('precon-detail-meta'); + const listEl = document.getElementById('precon-detail-list'); + + if (nameEl) nameEl.textContent = precon.name; + + const COLOR_ICONS = { W: '\u2600', U: '\ud83d\udca7', B: '\ud83d\udc80', R: '\ud83d\udd25', G: '\ud83c\udf32', C: '\u25c7' }; + const colorPips = precon.colors.split('').map(c => { + const icon = COLOR_ICONS[c] || c; + return `${icon}`; + }).join(''); + + if (metaEl) metaEl.innerHTML = ` + ${precon.set} (${precon.year}) + ${colorPips} + ${precon.theme} + `; + + if (listEl) { + const lines = precon.decklist.split('\n').filter(l => l.trim()); + listEl.innerHTML = lines.map(line => { + if (line.startsWith('//')) { + return `
${line.replace('//', '').trim()}
`; + } + const m = line.match(/^(\d+)\s+(.+)$/); + if (m) { + return `
${m[1]}x ${m[2]}
`; + } + return `
${line}
`; + }).join(''); + } +} + +function closePreconDetail() { + const grid = document.getElementById('precon-grid'); + const detail = document.getElementById('precon-detail'); + if (grid) grid.style.display = ''; + if (detail) detail.style.display = 'none'; + selectedPreconId = null; +} + +/** Copy the selected precon's decklist into the Import/Export textarea and switch tabs */ +function sendPreconToImport() { + if (!selectedPreconId || typeof PRECON_DATA === 'undefined') return; + const precon = PRECON_DATA.find(p => p.id === selectedPreconId); + if (!precon) return; + + const textarea = document.getElementById('deck-import-text'); + if (textarea) textarea.value = precon.decklist; + + // Switch to the Import/Export tab + switchDeckTab('import'); + + // Flash the textarea briefly to draw attention + if (textarea) { + textarea.style.outline = '2px solid rgba(92,184,92,0.7)'; + setTimeout(function() { textarea.style.outline = ''; }, 1200); + } +} + +let preconImportAbortController = null; + +async function loadPreconIntoDeck() { + if (!selectedPreconId || typeof PRECON_DATA === 'undefined') return; + if (!gameState || deckModalPlayerId === null) return; + + const precon = PRECON_DATA.find(p => p.id === selectedPreconId); + if (!precon) return; + + const player = gameState.players[deckModalPlayerId]; + if (!player) return; + + const loadBtn = document.getElementById('precon-load-btn'); + const cancelBtn = document.getElementById('precon-cancel-btn'); + const progressWrap = document.getElementById('precon-import-progress'); + const progressBar = document.getElementById('precon-progress-bar'); + const progressText = document.getElementById('precon-progress-text'); + const statusEl = document.getElementById('precon-import-status'); + + // Parse the decklist + const parsedCards = parseDeckList(precon.decklist); + if (!parsedCards.length) { + if (statusEl) { statusEl.textContent = 'Could not parse this precon decklist.'; statusEl.className = 'precon-import-status error'; } + return; + } + + const allCardNames = []; + const commanderNames = []; + for (const entry of parsedCards) { + for (let q = 0; q < entry.qty; q++) { + allCardNames.push(entry.name); + } + if (entry.section === 'commander') { + commanderNames.push(entry.name); + } + } + + // UI: show progress, disable button + if (loadBtn) { loadBtn.disabled = true; loadBtn.textContent = 'Importing...'; } + if (cancelBtn) cancelBtn.style.display = ''; + if (progressWrap) progressWrap.style.display = ''; + if (statusEl) { statusEl.textContent = `Fetching ${allCardNames.length} cards from Scryfall...`; statusEl.className = 'precon-import-status'; } + + preconImportAbortController = new AbortController(); + // Temporarily replace the global abort controller for batchFetchCards + const prevAbort = deckImportAbortController; + deckImportAbortController = preconImportAbortController; + + try { + const cardDataMap = await batchFetchCards( + allCardNames, + (loaded, total) => { + if (progressBar) progressBar.style.width = `${Math.round((loaded / total) * 100)}%`; + if (progressText) progressText.textContent = `Fetching cards: ${loaded} / ${total}`; + } + ); + + const libraryCards = []; + const commandCards = []; + const notFound = []; + + for (const entry of parsedCards) { + const scryfallCard = cardDataMap.get(entry.name.toLowerCase()); + if (!scryfallCard) { + notFound.push(entry.name); + continue; + } + const cardObj = scryfallToZoneCard(scryfallCard); + for (let q = 0; q < entry.qty; q++) { + if (entry.section === 'commander') { + commandCards.push({ ...cardObj }); + } else { + libraryCards.push({ ...cardObj }); + } + } + } + + // Replace player's deck + player.zones.library = []; + player.zones.hand = []; + player.zones.command = []; + + commandCards.forEach(card => player.zones.command.push(card)); + + // Shuffle library + for (let i = libraryCards.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [libraryCards[i], libraryCards[j]] = [libraryCards[j], libraryCards[i]]; + } + libraryCards.forEach(card => player.zones.library.push(card)); + + // Draw opening hand (7 cards) + if (player.zones.library.length >= 7) { + for (let i = 0; i < 7; i++) { + const card = player.zones.library.shift(); + player.zones.hand.push(card); + } + } + + updatePlayerZones(); + renderPlayerPanels(); + updateDeckSummary(deckModalPlayerId); + + let status = `\u2713 Loaded "${precon.name}" — ${libraryCards.length + commandCards.length} cards imported.`; + if (commandCards.length) status += ` ${commandCards.length} commander(s).`; + status += ` Drew 7-card opening hand.`; + if (notFound.length) { + status += ` \u26a0 ${notFound.length} not found: ${notFound.slice(0, 5).join(', ')}${notFound.length > 5 ? '...' : ''}`; + } + if (statusEl) { statusEl.textContent = status; statusEl.className = 'precon-import-status success'; } + + addLogEntry(`\ud83d\udccb ${player.name} loaded precon "${precon.name}" (${libraryCards.length + commandCards.length} cards)`); + + // Enter mulligan phase for player 1 + if (deckModalPlayerId === 0) { + enterMulliganPhase(0); + } + + } catch (err) { + if (err.name === 'AbortError' || err.message === 'Import cancelled') { + if (statusEl) { statusEl.textContent = 'Import cancelled.'; statusEl.className = 'precon-import-status'; } + } else { + console.error('[Precon Import] Error:', err); + if (statusEl) { statusEl.textContent = 'Error importing precon. Check console.'; statusEl.className = 'precon-import-status error'; } + } + } finally { + deckImportAbortController = prevAbort; + preconImportAbortController = null; + if (loadBtn) { loadBtn.disabled = false; loadBtn.textContent = '\ud83d\udce5 Load This Deck'; } + if (cancelBtn) cancelBtn.style.display = 'none'; + if (progressWrap) progressWrap.style.display = 'none'; + if (progressBar) progressBar.style.width = '0%'; + } +} + +function cancelPreconImport() { + if (preconImportAbortController) { + preconImportAbortController.abort(); + preconImportAbortController = null; + } +} + +// ============================================================ +// SETUP SCREEN +// ============================================================ +// FLOATING MANA POOL +// ============================================================ + +const MANA_COLORS = ['W', 'U', 'B', 'R', 'G', 'C']; +const MANA_LABELS = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' }; + +// Mana symbol to pool key mapping +const MANA_SYMBOL_MAP = { + '{W}': 'W', '{U}': 'U', '{B}': 'B', '{R}': 'R', '{G}': 'G', '{C}': 'C', +}; + +/** + * Parse oracle text to determine what mana a card produces when tapped. + * Returns an object: + * { fixed: {W:0,U:0,B:0,R:0,G:0,C:0}, choices: number, anyColor: number } + * - fixed: guaranteed mana added immediately + * - choices: number of "pick one color" choices to prompt + * - anyColor: number of "any color" picks to prompt + */ +function parseManaProduction(oracleText) { + if (!oracleText) return null; + + const result = { fixed: { W:0, U:0, B:0, R:0, G:0, C:0 }, choices: [], anyColor: 0 }; + let found = false; + + // Normalize: handle parenthesized abilities like basic lands "({T}: Add {G}.)" + const text = oracleText.replace(/\n/g, ' '); + + // Match all tap-for-mana abilities: optional cost + {T}: Add ... + // Pattern: (optional cost, ){T}: Add + const tapAbilityRegex = /(?:^|\n|\(|(?:[^.]*,\s*)){T}:\s*[Aa]dd\s+([^.]+)\.?/g; + let match; + + while ((match = tapAbilityRegex.exec(text)) !== null) { + const manaDesc = match[1].trim(); + + // Case 1: "one mana of any color" (with or without qualifier) + if (/one mana of any color/i.test(manaDesc)) { + result.anyColor += 1; + found = true; + continue; + } + + // Case 2: "X mana in any combination of colors" — complex, treat as anyColor x1 + if (/mana in any combination/i.test(manaDesc)) { + result.anyColor += 1; + found = true; + continue; + } + + // Case 3: "one mana of any type" — treat as any color + if (/one mana of any type/i.test(manaDesc)) { + result.anyColor += 1; + found = true; + continue; + } + + // Case 4: Fixed mana symbols like "{C}{C}" or "{B}{G}" + // Also handles "{R} or {G}" choice patterns + const symbols = manaDesc.match(/\{[WUBRGC]\}/g); + if (symbols && symbols.length > 0) { + // Check if it's an "or" choice: "{R} or {G}" + if (/\bor\b/i.test(manaDesc)) { + // This is a choice — collect the options + const options = symbols.map(s => MANA_SYMBOL_MAP[s]).filter(Boolean); + if (options.length > 0) { + result.choices.push(options); + found = true; + } + } else { + // Fixed mana — add all symbols + symbols.forEach(sym => { + const key = MANA_SYMBOL_MAP[sym]; + if (key) { + result.fixed[key]++; + found = true; + } + }); + } + } + } + + return found ? result : null; +} + +/** + * Check if a card is a mana producer (land, artifact, or creature with tap-for-mana). + */ +function isManaProducer(cardData) { + const type = (cardData.typeLine || '').toLowerCase(); + return type.includes('land') || type.includes('artifact') || parseManaProduction(cardData.oracleText) !== null; +} + +// Pending mana pick state +let pendingManaPick = null; + +/** + * Called when a mana-producing card is tapped. + * Auto-adds fixed mana, prompts for choices/any-color. + */ +function handleManaOnTap(cardData) { + if (!gameState) return; + const production = parseManaProduction(cardData.oracleText); + if (!production) return; + + const player = gameState.players[cardData.ownerIndex]; + if (!player) return; + + // Switch mana pool view to the card's owner + const manaSelect = document.getElementById('mana-pool-player-select'); + if (manaSelect) manaSelect.value = cardData.ownerIndex; + + // Add fixed mana immediately + let addedAny = false; + MANA_COLORS.forEach(c => { + if (production.fixed[c] > 0) { + player.manaPool[c] += production.fixed[c]; + addedAny = true; + } + }); + + if (addedAny) { + const parts = MANA_COLORS + .filter(c => production.fixed[c] > 0) + .map(c => `${production.fixed[c]} ${MANA_LABELS[c]}`); + addLogEntry(`💎 ${cardData.name} → +${parts.join(', ')}`); + } + + renderManaPool(); + + // Build list of pending picks + const picks = []; + production.choices.forEach(options => { + picks.push({ type: 'choice', options }); + }); + for (let i = 0; i < production.anyColor; i++) { + picks.push({ type: 'any', options: ['W','U','B','R','G'] }); + } + + if (picks.length > 0) { + pendingManaPick = { + cardName: cardData.name, + ownerIndex: cardData.ownerIndex, + picks, + currentPick: 0, + }; + showManaPickerModal(); + } +} + +function showManaPickerModal() { + if (!pendingManaPick) return; + const pick = pendingManaPick.picks[pendingManaPick.currentPick]; + if (!pick) { closeManaPickerModal(); return; } + + const modal = document.getElementById('mana-picker-modal'); + if (!modal) return; + + const titleEl = document.getElementById('mana-picker-title'); + const bodyEl = document.getElementById('mana-picker-body'); + if (!titleEl || !bodyEl) return; + + const remaining = pendingManaPick.picks.length - pendingManaPick.currentPick; + const label = pick.type === 'any' ? 'Choose a mana color' : 'Choose one'; + titleEl.textContent = `${pendingManaPick.cardName} — ${label}${remaining > 1 ? ` (${remaining} remaining)` : ''}`; + + const manaIcons = { W: '☀', U: '💧', B: '💀', R: '🔥', G: '🌲', C: '◇' }; + const manaClasses = { W: 'mana-white', U: 'mana-blue', B: 'mana-black', R: 'mana-red', G: 'mana-green', C: 'mana-colorless' }; + + bodyEl.innerHTML = pick.options.map(color => ` + + `).join(''); + + modal.classList.add('visible'); +} + +function pickManaColor(color) { + if (!pendingManaPick || !gameState) return; + const player = gameState.players[pendingManaPick.ownerIndex]; + if (!player) return; + + player.manaPool[color] = (player.manaPool[color] || 0) + 1; + addLogEntry(`💎 ${pendingManaPick.cardName} → +1 ${MANA_LABELS[color]}`); + renderManaPool(); + + pendingManaPick.currentPick++; + if (pendingManaPick.currentPick < pendingManaPick.picks.length) { + showManaPickerModal(); + } else { + closeManaPickerModal(); + } +} + +function closeManaPickerModal() { + pendingManaPick = null; + const modal = document.getElementById('mana-picker-modal'); + if (modal) modal.classList.remove('visible'); +} + +// ============================================================ +// AUTO-TAP ENGINE — Smart land tapping for casting spells +// ============================================================ + +/** + * Parse a mana cost string into structured requirements. + * Returns { colored: {W:n, U:n, B:n, R:n, G:n}, colorless: n, generic: n, total: n, hasX: bool } + */ +function parseManaCostRequirements(manaCost) { + if (!manaCost) return null; + var req = { colored: { W:0, U:0, B:0, R:0, G:0 }, colorless: 0, generic: 0, total: 0, hasX: false }; + var tokens = manaCost.match(/\{[^}]+\}/g) || []; + for (var i = 0; i < tokens.length; i++) { + var sym = tokens[i].replace(/[{}]/g, '').toUpperCase(); + if (sym === 'W' || sym === 'U' || sym === 'B' || sym === 'R' || sym === 'G') { + req.colored[sym]++; + req.total++; + } else if (sym === 'C') { + req.colorless++; + req.total++; + } else if (/^\d+$/.test(sym)) { + req.generic += parseInt(sym, 10); + req.total += parseInt(sym, 10); + } else if (sym === 'X') { + req.hasX = true; + } else if (sym.indexOf('/') !== -1) { + // Hybrid mana like {W/U} — count as needing one of either + req.generic++; + req.total++; + } + } + return req; +} + +/** + * Determine what mana colors a land can produce (without actually tapping it). + * Returns array of color keys: ['W'], ['R','G'], ['W','U','B','R','G'], etc. + */ +function getLandManaProduction(cardData) { + var colors = []; + var type = (cardData.typeLine || '').toLowerCase(); + var name = (cardData.name || '').toLowerCase(); + + // Basic land types — check both typeLine and name + if (type.includes('plains') || name === 'plains') colors.push('W'); + if (type.includes('island') || name === 'island') colors.push('U'); + if (type.includes('swamp') || name === 'swamp') colors.push('B'); + if (type.includes('mountain') || name === 'mountain') colors.push('R'); + if (type.includes('forest') || name === 'forest') colors.push('G'); + + // Parse oracle text for tap-add patterns + var production = parseManaProduction(cardData.oracleText); + if (production) { + MANA_COLORS.forEach(function(c) { + if (production.fixed[c] > 0 && colors.indexOf(c) === -1) colors.push(c); + }); + // Choice lands: "{R} or {G}" etc. + if (production.choices && production.choices.length > 0) { + production.choices.forEach(function(opts) { + opts.forEach(function(c) { + if (colors.indexOf(c) === -1) colors.push(c); + }); + }); + } + // Any-color lands (Command Tower, City of Brass, etc.) + if (production.anyColor > 0) { + ['W','U','B','R','G'].forEach(function(c) { + if (colors.indexOf(c) === -1) colors.push(c); + }); + } + } + + // Wastes / colorless-only lands + if (colors.length === 0 && type.includes('land')) { + colors.push('C'); + } + + return colors; +} + +/** + * Smart auto-tap algorithm. + * Given a mana cost and a player index, determine which untapped lands to tap. + * Prioritizes: + * 1. Tap single-color lands that match ONLY the needed colors first + * 2. Then tap dual lands where one color is needed + * 3. Use "any color" / 5-color lands last (preserve flexibility) + * 4. For generic costs, prefer tapping lands whose colors are NOT needed + * + * Returns { success: bool, landsToTap: [cardData...], manaProduced: {W:n,...}, shortfall: string|null } + */ +function calculateAutoTap(manaCost, playerIndex) { + if (!gameState || !manaCost) return { success: false, landsToTap: [], manaProduced: {}, shortfall: 'No mana cost' }; + + var req = parseManaCostRequirements(manaCost); + if (!req) return { success: false, landsToTap: [], manaProduced: {}, shortfall: 'Cannot parse cost' }; + + // If cost is 0 total and no X, no tapping needed + if (req.total === 0 && !req.hasX) return { success: true, landsToTap: [], manaProduced: { W:0, U:0, B:0, R:0, G:0, C:0 }, shortfall: null }; + + var player = gameState.players[playerIndex]; + if (!player) return { success: false, landsToTap: [], manaProduced: {}, shortfall: 'Invalid player' }; + + // First check: can the pool already pay? If so, no tapping needed. + if (canPayManaCost(manaCost, player.manaPool)) { + return { success: true, landsToTap: [], manaProduced: { W:0, U:0, B:0, R:0, G:0, C:0 }, shortfall: null, poolSufficient: true }; + } + + // Account for existing mana in pool by reducing requirements + var poolCopy = { W: player.manaPool.W||0, U: player.manaPool.U||0, B: player.manaPool.B||0, R: player.manaPool.R||0, G: player.manaPool.G||0, C: player.manaPool.C||0 }; + + // Get all untapped lands and mana-producing permanents for this player + var untappedLands = gameState.battlefieldCards.filter(function(c) { + return c.ownerIndex === playerIndex && !c.tapped && isManaProducer(c); + }); + + // Build production info for each land + var landInfo = untappedLands.map(function(land) { + return { + card: land, + colors: getLandManaProduction(land), + flexibility: 0 + }; + }); + + // Calculate flexibility score (how many different colors it can produce) + landInfo.forEach(function(li) { + li.flexibility = li.colors.length; + }); + + // Figure out what we still need after using pool mana + var needed = { W: req.colored.W, U: req.colored.U, B: req.colored.B, R: req.colored.R, G: req.colored.G }; + // Reduce needed by what the pool already has + ['W','U','B','R','G'].forEach(function(c) { + var fromPool = Math.min(needed[c], poolCopy[c]); + needed[c] -= fromPool; + poolCopy[c] -= fromPool; + }); + var genericNeeded = req.generic + req.colorless; + // Reduce generic by remaining pool + var poolRemainder = poolCopy.W + poolCopy.U + poolCopy.B + poolCopy.R + poolCopy.G + poolCopy.C; + var genericFromPool = Math.min(genericNeeded, poolRemainder); + genericNeeded -= genericFromPool; + + var landsToTap = []; + var manaProduced = { W:0, U:0, B:0, R:0, G:0, C:0 }; + var usedLands = {}; + + // PASS 1: Tap single-color lands that produce EXACTLY a needed color + var colorsNeeded = ['W','U','B','R','G'].filter(function(c) { return needed[c] > 0; }); + colorsNeeded.forEach(function(color) { + var candidates = landInfo.filter(function(li) { + return !usedLands[li.card.id] && li.colors.indexOf(color) !== -1; + }).sort(function(a, b) { + if (a.flexibility !== b.flexibility) return a.flexibility - b.flexibility; + return 0; + }); + + for (var i = 0; i < candidates.length && needed[color] > 0; i++) { + var li = candidates[i]; + usedLands[li.card.id] = true; + landsToTap.push(li.card); + manaProduced[color]++; + needed[color]--; + } + }); + + // PASS 2: For remaining colored needs, tap multi-color lands + ['W','U','B','R','G'].forEach(function(color) { + if (needed[color] <= 0) return; + var candidates = landInfo.filter(function(li) { + return !usedLands[li.card.id] && li.colors.indexOf(color) !== -1; + }).sort(function(a, b) { return a.flexibility - b.flexibility; }); + + for (var i = 0; i < candidates.length && needed[color] > 0; i++) { + var li = candidates[i]; + usedLands[li.card.id] = true; + landsToTap.push(li.card); + manaProduced[color]++; + needed[color]--; + } + }); + + // Check if all colored requirements are met + var colorShortfall = []; + ['W','U','B','R','G'].forEach(function(c) { + if (needed[c] > 0) colorShortfall.push(needed[c] + ' ' + MANA_LABELS[c]); + }); + + // PASS 3: Tap lands for generic/colorless costs + if (genericNeeded > 0) { + var genericCandidates = landInfo.filter(function(li) { + return !usedLands[li.card.id]; + }).sort(function(a, b) { + var aUseful = a.colors.filter(function(c) { return colorsNeeded.indexOf(c) !== -1; }).length; + var bUseful = b.colors.filter(function(c) { return colorsNeeded.indexOf(c) !== -1; }).length; + if (aUseful !== bUseful) return aUseful - bUseful; + return a.flexibility - b.flexibility; + }); + + for (var i = 0; i < genericCandidates.length && genericNeeded > 0; i++) { + var li = genericCandidates[i]; + usedLands[li.card.id] = true; + landsToTap.push(li.card); + var pickColor = 'C'; + if (li.colors.length > 0 && li.colors[0] !== 'C') { + pickColor = li.colors[0]; + for (var j = 0; j < li.colors.length; j++) { + if (colorsNeeded.indexOf(li.colors[j]) === -1) { + pickColor = li.colors[j]; + break; + } + } + } + manaProduced[pickColor]++; + genericNeeded--; + } + } + + var success = colorShortfall.length === 0 && genericNeeded <= 0; + var shortfall = null; + if (!success) { + var parts = []; + if (colorShortfall.length > 0) parts = parts.concat(colorShortfall); + if (genericNeeded > 0) parts.push(genericNeeded + ' Generic'); + shortfall = 'Need ' + parts.join(', '); + } + + return { + success: success, + landsToTap: landsToTap, + manaProduced: manaProduced, + shortfall: shortfall, + poolSufficient: false + }; +} + +/** + * Execute auto-tap: tap the specified lands and add mana to pool. + * Provides visual feedback with pulse animation on tapped lands. + */ +function executeAutoTap(landsToTap, playerIndex) { + if (!gameState || !landsToTap || landsToTap.length === 0) return; + var player = gameState.players[playerIndex]; + if (!player) return; + + // Switch mana pool view to the casting player + var manaSelect = document.getElementById('mana-pool-player-select'); + if (manaSelect) manaSelect.value = playerIndex; + + landsToTap.forEach(function(cardData) { + cardData.tapped = true; + var cardEl = document.getElementById('bc-' + cardData.id); + if (cardEl) { + cardEl.classList.add('tapped'); + cardEl.classList.add('auto-tap-pulse'); + setTimeout(function() { + cardEl.classList.remove('auto-tap-pulse'); + }, 1200); + } + handleManaOnTap(cardData); + }); + + addLogEntry('⚡ Auto-tapped ' + landsToTap.length + ' land' + (landsToTap.length > 1 ? 's' : '') + ' for ' + player.name + ''); +} + +/** + * Spend mana from pool to pay a cost. + * Deducts colored mana first, then generic from whatever is available. + * Returns true if successful, false if insufficient. + */ +function spendManaFromPool(manaCost, playerIndex) { + if (!gameState || !manaCost) return true; + var player = gameState.players[playerIndex]; + if (!player) return false; + + var req = parseManaCostRequirements(manaCost); + if (!req || req.total === 0) return true; + + // Check first + if (!canPayManaCost(manaCost, player.manaPool)) return false; + + // Deduct colored costs + ['W','U','B','R','G'].forEach(function(c) { + var amt = req.colored[c] || 0; + player.manaPool[c] = Math.max(0, (player.manaPool[c] || 0) - amt); + }); + + // Deduct colorless costs (from C pool first) + var clNeeded = req.colorless; + if (clNeeded > 0 && player.manaPool.C >= clNeeded) { + player.manaPool.C -= clNeeded; + clNeeded = 0; + } else if (clNeeded > 0) { + clNeeded -= player.manaPool.C; + player.manaPool.C = 0; + } + + // Deduct generic costs (from least valuable mana first) + var genNeeded = req.generic + clNeeded; + var spendOrder = MANA_COLORS.slice().sort(function(a, b) { + return (player.manaPool[b] || 0) - (player.manaPool[a] || 0); + }); + spendOrder.forEach(function(c) { + if (genNeeded <= 0) return; + var spend = Math.min(genNeeded, player.manaPool[c] || 0); + player.manaPool[c] -= spend; + genNeeded -= spend; + }); + + addLogEntry('💸 ' + player.name + ' spent ' + (manaCost || '0') + ' mana'); + + renderManaPool(); + renderHandArea(); + return true; +} + +/** + * Show mana shortfall warning popup. + */ +function showManaShortfallWarning(cardName, shortfall, playerIndex) { + var modal = document.getElementById('mana-shortfall-modal'); + if (!modal) return; + var titleEl = document.getElementById('mana-shortfall-title'); + var bodyEl = document.getElementById('mana-shortfall-body'); + if (titleEl) titleEl.textContent = 'Insufficient Mana'; + if (bodyEl) { + bodyEl.innerHTML = '
' + cardName + '
' + + '
' + shortfall + '
' + + '
Tap more lands or use mana abilities, then try again.
'; + } + modal.classList.add('visible'); + setTimeout(function() { closeManaShortfallModal(); }, 4000); +} + +function closeManaShortfallModal() { + var modal = document.getElementById('mana-shortfall-modal'); + if (modal) modal.classList.remove('visible'); +} + +/** + * Master function: attempt to cast a card by auto-tapping lands and spending mana. + * Returns true always (simulator allows play even with shortfall). + */ +function attemptManaPay(manaCost, playerIndex, cardName) { + if (!gameState) return true; + var re = gameState.rulesEngine || {}; + + if (!re.autoTapLands && !re.autoSpendMana) return true; + + if (!manaCost) return true; + var req = parseManaCostRequirements(manaCost); + if (!req || req.total === 0) return true; + + var player = gameState.players[playerIndex]; + if (!player) return true; + + // Check if the pool can already pay + if (canPayManaCost(manaCost, player.manaPool)) { + if (re.autoSpendMana) { + spendManaFromPool(manaCost, playerIndex); + } + return true; + } + + // Auto-tap lands to generate mana + if (re.autoTapLands) { + var tapResult = calculateAutoTap(manaCost, playerIndex); + + if (tapResult.poolSufficient) { + if (re.autoSpendMana) { + spendManaFromPool(manaCost, playerIndex); + } + return true; + } + + if (tapResult.success) { + executeAutoTap(tapResult.landsToTap, playerIndex); + if (re.autoSpendMana) { + spendManaFromPool(manaCost, playerIndex); + } + return true; + } else { + showManaShortfallWarning(cardName || 'Unknown Card', tapResult.shortfall, playerIndex); + if (tapResult.landsToTap.length > 0) { + executeAutoTap(tapResult.landsToTap, playerIndex); + } + return true; // Allow play anyway — simulator flexibility + } + } + + return true; +} + +/** + * Tap all untapped lands for a player — quick mana generation. + */ +function tapAllLands(playerIndex) { + if (!gameState) return; + var pid = (playerIndex !== undefined) ? playerIndex : gameState.currentPlayerIndex; + var player = gameState.players[pid]; + if (!player) return; + pushUndoState(); + + var untappedLands = gameState.battlefieldCards.filter(function(c) { + return c.ownerIndex === pid && !c.tapped && isManaProducer(c) && + (c.typeLine || '').toLowerCase().indexOf('land') !== -1; + }); + + if (untappedLands.length === 0) { + addLogEntry('No untapped lands to tap'); + return; + } + + untappedLands.forEach(function(cardData) { + cardData.tapped = true; + var cardEl = document.getElementById('bc-' + cardData.id); + if (cardEl) { + cardEl.classList.add('tapped'); + cardEl.classList.add('auto-tap-pulse'); + setTimeout(function() { cardEl.classList.remove('auto-tap-pulse'); }, 1200); + } + handleManaOnTap(cardData); + }); + + addLogEntry('⚡ Tapped all ' + untappedLands.length + ' lands for ' + player.name + ''); +} + +/** + * Get floating mana summary text for a player. + */ +function getManaFloatingSummary(playerIndex) { + if (!gameState) return ''; + var p = gameState.players[playerIndex]; + if (!p || !p.manaPool) return ''; + var parts = []; + var icons = { W: '☀', U: '💧', B: '💀', R: '🔥', G: '🌲', C: '◇' }; + MANA_COLORS.forEach(function(c) { + if (p.manaPool[c] > 0) parts.push(icons[c] + p.manaPool[c]); + }); + return parts.join(' '); +} + + +function getSelectedManaPlayerId() { + const sel = document.getElementById('mana-pool-player-select'); + return sel ? parseInt(sel.value) : 0; +} + +function adjustMana(color, amount) { + if (!gameState) return; + const pid = getSelectedManaPlayerId(); + const player = gameState.players[pid]; + if (!player) return; + player.manaPool[color] = Math.max(0, player.manaPool[color] + amount); + renderManaPool(); +} + +function emptyManaPool() { + if (!gameState) return; + pushUndoState(); + const pid = getSelectedManaPlayerId(); + const player = gameState.players[pid]; + if (!player) return; + MANA_COLORS.forEach(c => { player.manaPool[c] = 0; }); + renderManaPool(); + addLogEntry(`\u2205 ${player.name} emptied their mana pool`); +} + +function emptyAllManaPools() { + if (!gameState) return; + // Respect the auto-clear toggle + if (!gameState.manaAutoClear) return; + const hadMana = gameState.players.some(p => MANA_COLORS.some(c => p.manaPool[c] > 0)); + gameState.players.forEach(p => { + MANA_COLORS.forEach(c => { p.manaPool[c] = 0; }); + }); + renderManaPool(); + if (hadMana) { + addLogEntry('\u2205 Mana pools emptied (phase change)'); + } +} + +function toggleManaAutoClear() { + if (!gameState) return; + const cb = document.getElementById('mana-autoclear-toggle'); + gameState.manaAutoClear = cb ? cb.checked : true; +} + +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() { + const sel = document.getElementById('mana-pool-player-select'); + if (!sel || !gameState) return; + sel.innerHTML = ''; + gameState.players.forEach((p, i) => { + const opt = document.createElement('option'); + opt.value = i; + opt.textContent = p.name; + if (i === gameState.currentPlayerIndex) opt.selected = true; + sel.appendChild(opt); + }); +} + +function showManaPool() { + // Mana pool display removed. +} + +function toggleManaPoolCollapse() { + const widget = document.getElementById('mana-pool-widget'); + const btn = document.getElementById('mana-pool-collapse'); + if (widget) { + widget.classList.toggle('collapsed'); + if (btn) btn.textContent = widget.classList.contains('collapsed') ? '\u25b8' : '\u25be'; + } +} + +function setupManaPoolDrag() { + // Mana pool display removed — no drag setup needed. +} + +// ============================================================ + +let setupPlayerCount = 4; +let setupStartingLife = 40; +let setupAiPlayers = new Set(); // indices of AI-controlled players +let setupAiDifficulty = 'easy'; +// aiTurnActive declared in ui.js +var _aiBlockerPauseState = null; // { resumeFn, playerIdx } — set when AI turn pauses for human blockers +var setupDeckChoices = {}; // playerIdx -> { type: 'precon'|'import', preconId/text } + +// Global inline-handler functions for setup screen (fallback for embedded browsers) +function selectPlayerCount(count) { + setupPlayerCount = count; + document.querySelectorAll('.player-count-btn').forEach(function(b) { b.classList.remove('active'); }); + var btn = document.querySelector('[data-count="' + count + '"]'); + if (btn) btn.classList.add('active'); + setupAiPlayers.clear(); + updatePlayerNameFields(); + updateAiToggleGrid(); +} + +function adjustSetupLife(delta) { + setupStartingLife = Math.max(1, Math.min(999, setupStartingLife + delta)); + var el = document.getElementById('starting-life-display'); + if (el) el.textContent = setupStartingLife; +} + +function selectAiDifficulty(el, diff) { + setupAiDifficulty = diff; + document.querySelectorAll('.ai-diff-btn').forEach(function(b) { b.classList.remove('active'); }); + el.classList.add('active'); +} + +function initSetupScreen() { + // Player count buttons + document.querySelectorAll('.player-count-btn').forEach(btn => { + btn.addEventListener('click', () => { + setupPlayerCount = parseInt(btn.dataset.count); + document.querySelectorAll('.player-count-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + setupAiPlayers.clear(); + updatePlayerNameFields(); + updateAiToggleGrid(); + }); + }); + + // AI difficulty buttons + document.querySelectorAll('.ai-diff-btn').forEach(btn => { + btn.addEventListener('click', () => { + setupAiDifficulty = btn.dataset.diff; + document.querySelectorAll('.ai-diff-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + + // Life adjust buttons use inline onclick handlers (Comet compatibility) + // Do NOT add addEventListener here — would double-step the value + + // Start game button now goes to Step 2 (deck selection) via inline onclick + // Do NOT add addEventListener here — would bypass deck selection + + // Initial render + document.querySelector(`[data-count="${setupPlayerCount}"]`)?.classList.add('active'); + updatePlayerNameFields(); + updateAiToggleGrid(); +} + +function updatePlayerNameFields() { + const grid = document.getElementById('player-names-grid'); + if (!grid) return; + grid.innerHTML = ''; + + for (let i = 0; i < setupPlayerCount; i++) { + const field = document.createElement('div'); + field.className = 'player-name-field'; + field.innerHTML = ` +
+ + `; + grid.appendChild(field); + } +} + +function updateAiToggleGrid() { + const grid = document.getElementById('ai-toggle-grid'); + if (!grid) return; + grid.innerHTML = ''; + + for (let i = 0; i < setupPlayerCount; i++) { + const isAi = setupAiPlayers.has(i); + const btn = document.createElement('button'); + btn.className = 'ai-toggle-btn' + (isAi ? ' ai-active' : ''); + btn.innerHTML = ` +
+ ${isAi ? '🤖 AI' : '👤 Human'} + P${i + 1} + `; + btn.addEventListener('click', () => { + // Don't allow all players to be AI + if (!setupAiPlayers.has(i)) { + const humanCount = setupPlayerCount - setupAiPlayers.size; + if (humanCount <= 1) return; // need at least 1 human + setupAiPlayers.add(i); + } else { + setupAiPlayers.delete(i); + } + updateAiToggleGrid(); + // Auto-set AI names + const nameInput = document.getElementById(`player-name-${i}`); + if (nameInput) { + if (setupAiPlayers.has(i) && (!nameInput.value.trim() || nameInput.value.startsWith('Player '))) { + nameInput.value = `AI Bot ${i + 1}`; + } else if (!setupAiPlayers.has(i) && nameInput.value.startsWith('AI Bot')) { + nameInput.value = ''; + } + } + }); + grid.appendChild(btn); + } + + // Show/hide difficulty row + const diffRow = document.getElementById('ai-difficulty-row'); + if (diffRow) diffRow.style.display = setupAiPlayers.size > 0 ? 'flex' : 'none'; +} + +// ============================================================ +// SETUP STEP 2: DECK SELECTION +// ============================================================ + +function goToStep2() { + var step1 = document.getElementById('setup-step-1'); + var step2 = document.getElementById('setup-step-2'); + if (step1) step1.classList.add('hidden'); + if (step2) step2.classList.remove('hidden'); + renderDeckSelectionGrid(); +} + +function goBackToStep1() { + var step2 = document.getElementById('setup-step-2'); + var step1 = document.getElementById('setup-step-1'); + if (step2) step2.classList.add('hidden'); + if (step1) step1.classList.remove('hidden'); + // Clear pending choices when going back + setupDeckChoices = {}; +} + +function renderDeckSelectionGrid() { + var grid = document.getElementById('deck-selection-grid'); + if (!grid) return; + grid.innerHTML = ''; + + for (var i = 0; i < setupPlayerCount; i++) { + var isAi = setupAiPlayers.has(i); + var nameInput = document.getElementById('player-name-' + i); + var playerName = (nameInput && nameInput.value.trim()) || + (isAi ? ('AI Bot ' + (i + 1)) : ('Player ' + (i + 1))); + var color = (typeof PLAYER_COLORS !== 'undefined' && PLAYER_COLORS[i]) ? PLAYER_COLORS[i] : '#888'; + + var panel = document.createElement('div'); + panel.className = 'deck-select-panel'; + panel.id = 'setup-deck-panel-' + i; + + // Build precon select options grouped by set + var preconOptionsHtml = ''; + if (typeof PRECON_DATA !== 'undefined' && PRECON_DATA.length) { + var sets = {}; + PRECON_DATA.forEach(function(p) { + if (!sets[p.set]) sets[p.set] = []; + sets[p.set].push(p); + }); + Object.keys(sets).forEach(function(setName) { + preconOptionsHtml += ''; + sets[setName].forEach(function(p) { + preconOptionsHtml += ''; + }); + preconOptionsHtml += ''; + }); + } + + if (isAi) { + panel.innerHTML = + '
' + + '
' + + '' + playerName + '' + + 'AI' + + '
' + + '
AI Deck (auto-generated)
'; + } else { + var idx = i; + // Build saved decks options + var savedDecks = (typeof getDecks === 'function') ? getDecks() : []; + var savedDecksHtml = ''; + if (savedDecks.length === 0) { + savedDecksHtml = '
No saved decks. Build one in Deck Builder first.
'; + } else { + savedDecksHtml = '
'; + 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 += ''; + } + savedDecksHtml += '
'; + } + + panel.innerHTML = + '
' + + '
' + + '' + playerName + '' + + 'No deck selected' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + savedDecksHtml + + '
' + + '
' + + '' + + '' + + '
'; + } + + grid.appendChild(panel); + } +} + +function switchSetupDeckTab(playerIdx, tab) { + 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); + if (tabBtn) tabBtn.classList.remove('active'); + if (content) content.classList.remove('active'); + }); + var activeTab = document.getElementById('setup-deck-tab-' + tab + '-' + playerIdx); + var activeContent = document.getElementById('setup-deck-content-' + tab + '-' + playerIdx); + if (activeTab) activeTab.classList.add('active'); + if (activeContent) activeContent.classList.add('active'); +} + +function selectSetupPrecon(playerIdx) { + var sel = document.getElementById('setup-deck-precon-select-' + playerIdx); + var statusEl = document.getElementById('setup-deck-status-' + playerIdx); + if (!sel) return; + var preconId = sel.value; + if (!preconId) { + delete setupDeckChoices[playerIdx]; + if (statusEl) { statusEl.textContent = 'No deck selected'; statusEl.className = 'deck-select-status'; } + return; + } + var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA.find(function(p) { return p.id === preconId; }) : null; + var preconName = precon ? precon.name : preconId; + setupDeckChoices[playerIdx] = { type: 'precon', preconId: preconId }; + 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() : []; + var deck = decks.find(function(d) { return d.id === deckId; }); + if (!deck) return; + var player = gameState.players[playerIdx]; + if (!player) return; + + // Convert deck builder format to a text decklist, then use importDeckForPlayer + var lines = []; + if (deck.commander) { + lines.push('// Commander'); + lines.push('1 ' + deck.commander.name); + lines.push(''); + lines.push('// Deck'); + } + if (deck.cards && deck.cards.length) { + deck.cards.forEach(function(c) { + lines.push((c.quantity || 1) + ' ' + c.name); + }); + } + var deckText = lines.join('\n'); + await importDeckForPlayer(playerIdx, deckText); + addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded custom deck "' + deck.name + '"'); +} + +async function loadPreconForPlayer(playerIdx, preconId) { + if (typeof PRECON_DATA === 'undefined') return; + var precon = PRECON_DATA.find(function(p) { return p.id === preconId; }); + if (!precon) return; + var player = gameState.players[playerIdx]; + if (!player) return; + + var parsedCards = parseDeckList(precon.decklist); + if (!parsedCards.length) return; + + var allCardNames = []; + for (var ei = 0; ei < parsedCards.length; ei++) { + for (var q = 0; q < parsedCards[ei].qty; q++) { + allCardNames.push(parsedCards[ei].name); + } + } + + var cardDataMap = await batchFetchCards(allCardNames, null); + + var libraryCards = []; + var commandCards = []; + for (var ci = 0; ci < parsedCards.length; ci++) { + var entry = parsedCards[ci]; + var scryfallCard = cardDataMap.get(entry.name.toLowerCase()); + if (!scryfallCard) continue; + var cardObj = scryfallToZoneCard(scryfallCard); + for (var cq = 0; cq < entry.qty; cq++) { + if (entry.section === 'commander') { + commandCards.push(Object.assign({}, cardObj)); + } else { + libraryCards.push(Object.assign({}, cardObj)); + } + } + } + + player.zones.library = []; + player.zones.hand = []; + player.zones.command = []; + + commandCards.forEach(function(card) { player.zones.command.push(card); }); + if (commandCards.length > 0) { + player.commanderCardName = commandCards[0].name || ''; + } + + // Shuffle library + for (var si = libraryCards.length - 1; si > 0; si--) { + var sj = Math.floor(Math.random() * (si + 1)); + var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp; + } + libraryCards.forEach(function(card) { player.zones.library.push(card); }); + + // Draw opening hand of 7 + if (player.zones.library.length >= 7) { + for (var di = 0; di < 7; di++) { + player.zones.hand.push(player.zones.library.shift()); + } + } + + addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)'); +} + +async function importDeckForPlayer(playerIdx, deckText) { + var player = gameState.players[playerIdx]; + if (!player || !deckText.trim()) return; + + var parsedCards = parseDeckList(deckText); + if (!parsedCards.length) return; + + var allCardNames = []; + for (var ei = 0; ei < parsedCards.length; ei++) { + for (var q = 0; q < parsedCards[ei].qty; q++) { + allCardNames.push(parsedCards[ei].name); + } + } + + var cardDataMap = await batchFetchCards(allCardNames, null); + + var libraryCards = []; + var commandCards = []; + for (var ci = 0; ci < parsedCards.length; ci++) { + var entry = parsedCards[ci]; + var scryfallCard = cardDataMap.get(entry.name.toLowerCase()); + if (!scryfallCard) continue; + var cardObj = scryfallToZoneCard(scryfallCard); + for (var cq = 0; cq < entry.qty; cq++) { + if (entry.section === 'commander') { + commandCards.push(Object.assign({}, cardObj)); + } else { + libraryCards.push(Object.assign({}, cardObj)); + } + } + } + + player.zones.library = []; + player.zones.hand = []; + player.zones.command = []; + + commandCards.forEach(function(card) { player.zones.command.push(card); }); + if (commandCards.length > 0 && !player.commanderCardName) { + player.commanderCardName = commandCards[0].name || ''; + } + + // Shuffle library + for (var si = libraryCards.length - 1; si > 0; si--) { + var sj = Math.floor(Math.random() * (si + 1)); + var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp; + } + libraryCards.forEach(function(card) { player.zones.library.push(card); }); + + // Draw opening hand of 7 + if (player.zones.library.length >= 7) { + for (var di = 0; di < 7; di++) { + player.zones.hand.push(player.zones.library.shift()); + } + } + + addLogEntry('\uD83D\uDCCB ' + player.name + ' imported a deck (' + (libraryCards.length + commandCards.length) + ' cards)'); +} + +async function startGameWithDecks() { + // 1. Call existing startGame() — creates gameState, sets up AI, shows board + startGame(); + + // 2. Collect human players with deck choices + var humanDecks = []; + for (var i = 0; i < setupPlayerCount; i++) { + if (setupAiPlayers.has(i)) continue; + if (setupDeckChoices[i]) humanDecks.push({ idx: i, choice: setupDeckChoices[i] }); + } + + if (humanDecks.length === 0) { + // No decks to load — if player 0 has cards already, enter mulligan + if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) { + enterMulliganPhase(0); + } + setupDeckChoices = {}; + return; + } + + // 3. Show loading overlay + var overlay = document.createElement('div'); + overlay.id = 'deck-loading-overlay'; + overlay.className = 'deck-loading-overlay'; + overlay.innerHTML = + '
Loading Decks...
' + + '
Fetching cards from Scryfall
'; + document.body.appendChild(overlay); + + try { + // 4. Process each human deck choice sequentially + for (var d = 0; d < humanDecks.length; d++) { + var hd = humanDecks[d]; + var sub = document.getElementById('deck-loading-subtext'); + var pName = gameState.players[hd.idx] ? gameState.players[hd.idx].name : ('Player ' + (hd.idx + 1)); + if (sub) sub.textContent = 'Loading deck for ' + pName + '...'; + + if (hd.choice.type === 'precon') { + 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 === 'import') { + await importDeckForPlayer(hd.idx, hd.choice.text); + } + } + } catch (err) { + console.error('[startGameWithDecks] Error loading decks:', err); + } + + // 5. Remove overlay + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + + // 6. Clear choices, re-render, enter mulligan + setupDeckChoices = {}; + if (typeof updatePlayerZones === 'function') updatePlayerZones(); + renderPlayerPanels(); + renderHandArea(); + + if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) { + enterMulliganPhase(0); + } +} + +function startGame() { + const names = []; + for (let i = 0; i < setupPlayerCount; i++) { + const input = document.getElementById(`player-name-${i}`); + names.push(input?.value.trim() || (setupAiPlayers.has(i) ? `AI Bot ${i + 1}` : `Player ${i + 1}`)); + } + + gameState = createInitialState(setupPlayerCount, names, setupStartingLife); + + // Clear undo/redo stacks for new game + undoStack = []; + redoStack = []; + updateUndoRedoButtons(); + + // Clear token history for new game + tokenHistory = []; + // Clear AI thought history for new game + aiThoughts = []; + // Hide token summary bar on new game + var tsb = document.getElementById('token-summary-bar'); + if (tsb) tsb.style.display = 'none'; + + // Initialize turn history with first turn + const firstPlayer = gameState.players[0]; + gameState.turnHistory = [{ + turnNumber: 1, + playerIndex: 0, + playerName: firstPlayer?.name || 'Player 1', + playerColor: firstPlayer?.color || PLAYER_COLORS[0] || '#666', + }]; + + // Mark AI players + setupAiPlayers.forEach(idx => { + if (gameState.players[idx]) { + gameState.players[idx].isAI = true; + } + }); + gameState.aiDifficulty = setupAiDifficulty; + + // ======================================== + // Commander-AI-Lab: Initialize backend session + // ======================================== + if (typeof AIBridge !== 'undefined' && AIBridge.connector.connected) { + AIBridge.gameState.initSession(gameState).then(function(session) { + if (session) { + addLogEntry('\u{1F9E0} Connected to Commander-AI-Lab (session: ' + + session.game_id.substring(0, 8) + '...)'); + } + }).catch(function(err) { + console.warn('[startGame] AI Bridge session init failed:', err); + }); + } + + // Forge Engine: If enabled, start a Forge game session + if (typeof ForgeBridge !== 'undefined' && ForgeBridge.enabled && ForgeBridge.connected) { + gameState.forgeEnabled = true; + forgeStartGame({ + players: gameState.players, + startingLife: setupStartingLife, + aiDifficulty: setupAiDifficulty, + }); + // Show eval button + var evalBtn = document.getElementById('btn-forge-eval'); + if (evalBtn) evalBtn.style.display = ''; + } else { + gameState.forgeEnabled = false; + var evalBtn2 = document.getElementById('btn-forge-eval'); + if (evalBtn2) evalBtn2.style.display = 'none'; + } + + // Setup AI decks (generates library, draws opening hand) + setupAiDecks(); + + // Fetch real Scryfall images for AI cards in background (non-blocking) + fetchAiCardImages(); + + // Hide setup, show board + document.getElementById('setup-screen').classList.add('hidden'); + const board = document.getElementById('game-board'); + board.classList.add('visible'); + + // Show game log sidebar + var logSidebar = document.getElementById('game-log-sidebar'); + if (logSidebar) logSidebar.classList.remove('collapsed'); + + // Show zone panel + var zp = document.getElementById('zone-panel'); + if (zp) zp.classList.remove('hidden'); + + initGameBoard(); + // showManaPool() removed — mana pool display removed + // Set commanderCardName for each player from their command zone + gameState.players.forEach(function(p) { + if (p.zones.command && p.zones.command.length > 0) { + p.commanderCardName = p.zones.command[0].name || ''; + } + }); + addLogEntry(`🎮 Game started with ${setupPlayerCount} players, ${setupStartingLife} starting life`); + renderPlayerPanels(); + renderHandArea(); + renderPhaseTracker(); + updateTurnDisplay(); + + // Set default search focus + setTimeout(() => { + document.getElementById('card-search-input')?.focus(); + }, 600); + + // If first player is AI, start their turn + if (gameState.players[0]?.isAI) { + setTimeout(() => runAiTurn(), 1200); + } +} + +// ============================================================ +// GAME BOARD INIT +// ============================================================ + +function initGameBoard() { + setupBattlefieldDrop(); + setupContextMenu(); + setupKeyboardShortcuts(); + setupHandCardPreview(); + setupManaPoolDrag(); + updateStormCounter(); + initCombatOverlay(); + initZonePanel(); + buildOwnerFilterButtons(); + buildBattlefieldSections(); + + // Next phase / next turn buttons use inline onclick handlers (Comet compatibility) + // Do NOT add addEventListener here — would double-fire with onclick + + // Card search + const searchInput = document.getElementById('card-search-input'); + if (searchInput) { + searchInput.addEventListener('input', e => { + searchScryfall(e.target.value); + }); + searchInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { + clearTimeout(searchDebounceTimer); + searchScryfall(e.target.value); + } + }); + setSearchStatus('Type to search cards...'); + } + + // Sidebar toggles + document.getElementById('toggle-search-sidebar')?.addEventListener('click', () => { + document.getElementById('card-search-sidebar')?.classList.toggle('collapsed'); + }); + + // Dice modal + document.getElementById('btn-dice')?.addEventListener('click', openDiceModal); + document.getElementById('close-dice-modal')?.addEventListener('click', closeDiceModal); + document.getElementById('dice-modal')?.addEventListener('click', e => { + if (e.target === document.getElementById('dice-modal')) closeDiceModal(); + }); + + // Dice buttons + document.getElementById('btn-roll-d6')?.addEventListener('click', () => rollDice(6)); + document.getElementById('btn-roll-d20')?.addEventListener('click', () => rollDice(20)); + document.getElementById('btn-roll-d12')?.addEventListener('click', () => rollDice(12)); + document.getElementById('btn-roll-d10')?.addEventListener('click', () => rollDice(10)); + document.getElementById('btn-roll-d8')?.addEventListener('click', () => rollDice(8)); + document.getElementById('btn-roll-d4')?.addEventListener('click', () => rollDice(4)); + document.getElementById('btn-flip-coin')?.addEventListener('click', flipCoin); + + // Zone modal + document.getElementById('close-zone-modal')?.addEventListener('click', closeZoneModal); + document.getElementById('zone-modal')?.addEventListener('click', e => { + if (e.target === document.getElementById('zone-modal')) closeZoneModal(); + }); + + // Scry modal + document.getElementById('close-scry-modal')?.addEventListener('click', closeScryModal); + document.getElementById('scry-modal')?.addEventListener('click', e => { + if (e.target === document.getElementById('scry-modal')) closeScryModal(); + }); + document.getElementById('btn-scry-confirm')?.addEventListener('click', confirmScry); + document.getElementById('btn-scry-all-top')?.addEventListener('click', scryAllTop); + document.getElementById('btn-scry-all-bottom')?.addEventListener('click', scryAllBottom); + + // Zone tabs + document.querySelectorAll('.zone-tab').forEach(tab => { + tab.addEventListener('click', () => { + activeZoneTab = tab.dataset.zone; + renderZoneModal(); + }); + }); + + // Trigger stack + document.getElementById('btn-trigger-stack')?.addEventListener('click', toggleTriggerStack); + document.getElementById('close-trigger-stack')?.addEventListener('click', closeTriggerStack); + document.getElementById('btn-resolve-next')?.addEventListener('click', resolveNextTrigger); + document.getElementById('btn-resolve-all')?.addEventListener('click', resolveAllTriggers); + document.getElementById('btn-add-response')?.addEventListener('click', () => { + // Open the card search sidebar for instant-speed responses + const sidebar = document.getElementById('card-search-sidebar'); + if (sidebar) sidebar.classList.remove('collapsed'); + document.getElementById('card-search-input')?.focus(); + addLogEntry('⚡ Adding response — search for a card to respond with'); + }); + + // Reset game + document.getElementById('btn-reset')?.addEventListener('click', confirmReset); + + // Storm counter decrement + document.getElementById('storm-counter')?.addEventListener('click', () => { + gameState.stormCount = Math.max(0, gameState.stormCount - 1); + updateStormCounter(); + }); + + // Storm counter increment via FAB + document.getElementById('btn-storm')?.addEventListener('click', () => { + gameState.stormCount++; + updateStormCounter(); + addLogEntry(`⛈ Storm count: ${gameState.stormCount}`); + }); + + // Untap all + document.getElementById('btn-untap-all')?.addEventListener('click', untapAllCards); + + // Theme toggle + const themeToggle = document.querySelector('[data-theme-toggle]'); + if (themeToggle) { + themeToggle.addEventListener('click', toggleTheme); + } +} + +// ============================================================ +// UNTAP ALL +// ============================================================ + +function untapAllCards() { + if (!gameState) return; + pushUndoState(); + let count = 0; + gameState.battlefieldCards.forEach(card => { + if (card.tapped) { + card.tapped = false; + count++; + const cardEl = document.getElementById(`bc-${card.id}`); + if (cardEl) cardEl.classList.remove('tapped'); + } + }); + if (count > 0) addLogEntry(`✨ Untapped ${count} card${count > 1 ? 's' : ''}`); +} + +// ============================================================ +// RESET GAME +// ============================================================ + +function confirmReset() { + const dialog = document.getElementById('confirm-dialog'); + if (dialog) dialog.classList.add('visible'); +} + +function executeReset() { + const dialog = document.getElementById('confirm-dialog'); + if (dialog) dialog.classList.remove('visible'); + + // Go back to setup + document.getElementById('game-board').classList.remove('visible'); + document.getElementById('setup-screen').classList.remove('hidden'); + document.getElementById('setup-screen').style.display = ''; + + // Hide game log sidebar + var logSidebar = document.getElementById('game-log-sidebar'); + if (logSidebar) logSidebar.classList.add('collapsed'); + + // Clear game state + gameState = null; + nextTriggerId = 1; + + // Close trigger stack if open + closeTriggerStack(); + + // Hide mana pool + const manaWidget = document.getElementById('mana-pool-widget'); + if (manaWidget) manaWidget.classList.remove('visible'); + + // Clear battlefield + const battlefield = document.getElementById('battlefield'); + if (battlefield) { + battlefield.querySelectorAll('.battlefield-card').forEach(c => c.remove()); + const label = battlefield.querySelector('.battlefield-label'); + if (label) label.style.opacity = ''; + } + // Hide combat overlay + hideCombatOverlay(); + + // Hide player 1 fixed panel + const p1Fixed = document.getElementById('player1-fixed-panel'); + if (p1Fixed) { p1Fixed.innerHTML = ''; p1Fixed.classList.add('hidden'); } + + // Clear log + const logContainer = document.getElementById('log-entries'); + if (logContainer) logContainer.innerHTML = ''; +} + +// ============================================================ +// KEYBOARD SHORTCUTS +// ============================================================ + +let shortcutHintTimer = null; + +function showShortcutHint() { + const hint = document.getElementById('shortcut-hint'); + if (!hint) return; + hint.classList.add('show'); + clearTimeout(shortcutHintTimer); + shortcutHintTimer = setTimeout(() => hint.classList.remove('show'), 2500); +} + +function setupKeyboardShortcuts() { + document.addEventListener('keydown', e => { + // Undo/Redo — Ctrl+Z / Ctrl+Shift+Z (works everywhere including inputs) + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undo(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) { + e.preventDefault(); + redo(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key === 'y') { + e.preventDefault(); + redo(); + return; + } + + // Don't fire when typing in inputs + if (e.target.matches('input, textarea, select')) return; + + switch(e.key) { + case ' ': + e.preventDefault(); + // If priority window is open, Space passes priority + if (isPriorityBlocking()) { + passPriority(); + } else { + nextPhase(); + } + break; + case 'n': + case 'N': + nextTurn(); + break; + case 'u': + case 'U': + untapAllCards(); + break; + case 'd': + case 'D': + openDiceModal(); + break; + case 't': + case 'T': + toggleTriggerStack(); + break; + case 'm': + case 'M': + toggleManaPoolCollapse(); + break; + case 'Escape': + closeZoneModal(); + closeDiceModal(); + closeTriggerStack(); + hideContextMenu(); + closeScryModal(); + closeManaPickerModal(); + closeDeckModal(); + closeDeckView(); + closeTokenModal(); + hideAbilitiesPanel(); + closeSaveLoadModal(); + closeShortcutsModal(); + closeUndoHistory(); + // Close keyword reference + { + const kref = document.getElementById('keyword-reference-panel'); + if (kref) kref.style.display = 'none'; + } + break; + case 'k': + case 'K': + toggleKeywordReference(); + break; + case 's': + case 'S': + openSaveLoadModal(); + break; + case '?': + openShortcutsModal(); + break; + case 'h': + case 'H': + toggleUndoHistory(); + break; + } + }); +} + +// ============================================================ +// THEME TOGGLE +// ============================================================ + +let currentTheme = 'dark'; + +function toggleTheme() { + currentTheme = currentTheme === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', currentTheme); + + const toggle = document.querySelector('[data-theme-toggle]'); + if (toggle) { + toggle.title = currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + toggle.innerHTML = currentTheme === 'dark' + ? `` + : ``; + } +} + +// ============================================================ +// SHORTCUT HINT +// ============================================================ + +function initShortcutHint() { + const hint = document.getElementById('shortcut-hint'); + if (hint) { + hint.innerHTML = `Space Next Phase   N Next Turn   U Untap All   D Dice   T Triggers   S Save/Load   ? Shortcuts   H Undo History`; + } +} + +// ============================================================ +// LIFE ADJUST VIA KEYBOARD (click + hold) +// ============================================================ + +let lifeHoldTimer = null; +let lifeHoldInterval = null; + +function setupLifeHold() { + document.addEventListener('pointerdown', e => { + const btn = e.target.closest('.life-btn'); + if (!btn) return; + + const panel = btn.closest('.player-panel'); + if (!panel) return; + + const panelId = panel.id; + const playerId = parseInt(panelId.replace('player-panel-', '')); + const isPlus = btn.textContent.trim() === '+'; + + lifeHoldTimer = setTimeout(() => { + lifeHoldInterval = setInterval(() => { + adjustLife(playerId, isPlus ? 1 : -1); + }, 80); + }, 400); + }); + + document.addEventListener('pointerup', () => { + clearTimeout(lifeHoldTimer); + clearInterval(lifeHoldInterval); + }); +} + +// ============================================================ +// AI OPPONENT ENGINE +// ============================================================ + +// AI deck templates — pre-built card pools by color identity +// Each color has: lands, creatures (12-16), spells (6-8), equipment (2-3), enchantments (2-3) +const AI_DECK_TEMPLATES = { + mono_white: { + lands: [ + { name: 'Plains', typeLine: 'Basic Land \u2014 Plains', oracleText: '{T}: Add {W}.', mana: 'W' } + ], + creatures: [ + { name: 'Savannah Lions', typeLine: 'Creature \u2014 Cat', oracleText: '', pt: '2/1', cmc: 1, manaCost: '{W}' }, + { name: 'Mother of Runes', typeLine: 'Creature \u2014 Human Cleric', oracleText: '{T}: Target creature you control gains protection from the color of your choice until end of turn.', pt: '1/1', cmc: 1, manaCost: '{W}' }, + { name: 'Thalia, Guardian of Thraben', typeLine: 'Legendary Creature \u2014 Human Soldier', oracleText: 'First strike. Noncreature spells cost {1} more to cast.', pt: '2/1', cmc: 2, manaCost: '{1}{W}' }, + { name: 'Leonin Skyhunter', typeLine: 'Creature \u2014 Cat Knight', oracleText: 'Flying', pt: '2/2', cmc: 2, manaCost: '{W}{W}' }, + { name: 'Blade Splicer', typeLine: 'Creature \u2014 Phyrexian Human Artificer', oracleText: 'When Blade Splicer enters, create a 3/3 colorless Phyrexian Golem artifact creature token.', pt: '1/1', cmc: 3, manaCost: '{2}{W}' }, + { name: 'Monastery Mentor', typeLine: 'Creature \u2014 Human Monk', oracleText: 'Prowess. Whenever you cast a noncreature spell, create a 1/1 white Monk creature token with prowess.', pt: '2/2', cmc: 3, manaCost: '{2}{W}' }, + { name: 'Restoration Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flash, flying. When Restoration Angel enters, you may exile target non-Angel creature you control, then return that card to the battlefield under your control.', pt: '3/4', cmc: 4, manaCost: '{3}{W}' }, + { name: 'Hero of Bladehold', typeLine: 'Creature \u2014 Human Knight', oracleText: 'Battle cry. Whenever Hero of Bladehold attacks, create two 1/1 white Soldier creature tokens that are tapped and attacking.', pt: '3/4', cmc: 4, manaCost: '{2}{W}{W}' }, + { name: 'Serra Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying, vigilance', pt: '4/4', cmc: 5, manaCost: '{3}{W}{W}' }, + { name: 'Baneslayer Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying, first strike, lifelink', pt: '5/5', cmc: 5, manaCost: '{3}{W}{W}' }, + { name: 'Angel of Sanctions', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying. When Angel of Sanctions enters, you may exile target nonland permanent an opponent controls.', pt: '3/4', cmc: 5, manaCost: '{3}{W}{W}', isRemoval: true }, + { name: 'Sun Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Vigilance. Whenever Sun Titan enters or attacks, return target permanent card with mana value 3 or less from your graveyard to the battlefield.', pt: '6/6', cmc: 6, manaCost: '{4}{W}{W}' }, + { name: 'Elesh Norn, Grand Cenobite', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Vigilance. Other creatures you control get +2/+2. Creatures your opponents control get -2/-2.', pt: '4/7', cmc: 7, manaCost: '{5}{W}{W}' }, + ], + spells: [ + { name: 'Swords to Plowshares', typeLine: 'Instant', oracleText: 'Exile target creature. Its controller gains life equal to its power.', cmc: 1, manaCost: '{W}', isRemoval: true }, + { name: 'Path to Exile', typeLine: 'Instant', oracleText: 'Exile target creature.', cmc: 1, manaCost: '{W}', isRemoval: true }, + { name: 'Generous Gift', typeLine: 'Instant', oracleText: 'Destroy target permanent. Its controller creates a 3/3 green Elephant creature token.', cmc: 3, manaCost: '{1}{W}{W}', isRemoval: true }, + { name: 'Wrath of God', typeLine: 'Sorcery', oracleText: 'Destroy all creatures. They can\'t be regenerated.', cmc: 4, manaCost: '{2}{W}{W}', isBoardWipe: true }, + { name: 'Austere Command', typeLine: 'Sorcery', oracleText: 'Choose two: Destroy all artifacts; destroy all enchantments; destroy all creatures with mana value 3 or less; destroy all creatures with mana value 4 or greater.', cmc: 6, manaCost: '{4}{W}{W}', isBoardWipe: true }, + { name: 'Return to Dust', typeLine: 'Instant', oracleText: 'Exile target artifact or enchantment. If you cast this spell during your main phase, you may exile up to one other target artifact or enchantment.', cmc: 4, manaCost: '{2}{W}{W}', isRemoval: true }, + ], + equipment: [ + { name: 'Sword of Fire and Ice', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +2/+2 and has protection from red and from blue. Whenever equipped creature deals combat damage to a player, draw a card and deal 2 damage to any target. Equip {2}', cmc: 3, manaCost: '{3}' }, + { name: 'Lightning Greaves', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has haste and shroud. Equip {0}', cmc: 2, manaCost: '{2}' }, + ], + enchantments: [ + { name: 'Smothering Tithe', typeLine: 'Enchantment', oracleText: 'Whenever an opponent draws a card, that player may pay {2}. If the player doesn\'t, you create a Treasure token.', cmc: 4, manaCost: '{3}{W}' }, + { name: 'Luminarch Ascension', typeLine: 'Enchantment', oracleText: 'At the beginning of each opponent\'s end step, if you weren\'t dealt damage this turn, you may put a quest counter on Luminarch Ascension. {1}{W}: Create a 4/4 white Angel creature token with flying. Activate only if Luminarch Ascension has four or more quest counters on it.', cmc: 2, manaCost: '{1}{W}' }, + ] + }, + mono_blue: { + lands: [ + { name: 'Island', typeLine: 'Basic Land \u2014 Island', oracleText: '{T}: Add {U}.', mana: 'U' } + ], + creatures: [ + { name: 'Delver of Secrets', typeLine: 'Creature \u2014 Human Wizard', oracleText: '', pt: '1/1', cmc: 1, manaCost: '{U}' }, + { name: 'Phantasmal Bear', typeLine: 'Creature \u2014 Bear Illusion', oracleText: '', pt: '2/2', cmc: 1, manaCost: '{U}' }, + { name: 'Snapcaster Mage', typeLine: 'Creature \u2014 Human Wizard', oracleText: 'Flash. When Snapcaster Mage enters, target instant or sorcery card in your graveyard gains flashback until end of turn.', pt: '2/1', cmc: 2, manaCost: '{1}{U}' }, + { name: 'Watcher of the Spheres', typeLine: 'Creature \u2014 Bird Wizard', oracleText: 'Flying', pt: '2/2', cmc: 2, manaCost: '{1}{U}' }, + { name: 'Vendilion Clique', typeLine: 'Legendary Creature \u2014 Faerie Wizard', oracleText: 'Flash, flying. When Vendilion Clique enters, look at target player\'s hand. You may choose a nonland card from it. If you do, that player reveals the chosen card, puts it on the bottom of their library, then draws a card.', pt: '3/1', cmc: 3, manaCost: '{1}{U}{U}' }, + { name: 'Thassa, Deep-Dwelling', typeLine: 'Legendary Enchantment Creature \u2014 God', oracleText: 'Indestructible. At the beginning of your end step, exile up to one other target creature you control, then return that card to the battlefield under your control.', pt: '6/5', cmc: 4, manaCost: '{3}{U}' }, + { name: 'Sower of Temptation', typeLine: 'Creature \u2014 Faerie Wizard', oracleText: 'Flying. When Sower of Temptation enters, gain control of target creature for as long as Sower of Temptation remains on the battlefield.', pt: '2/2', cmc: 4, manaCost: '{2}{U}{U}' }, + { name: 'Mulldrifter', typeLine: 'Creature \u2014 Elemental', oracleText: 'Flying. When Mulldrifter enters, draw two cards.', pt: '2/2', cmc: 5, manaCost: '{4}{U}' }, + { name: 'Consecrated Sphinx', typeLine: 'Creature \u2014 Sphinx', oracleText: 'Flying. Whenever an opponent draws a card, you may draw two cards.', pt: '4/6', cmc: 6, manaCost: '{4}{U}{U}' }, + { name: 'Frost Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Whenever Frost Titan enters or attacks, tap target permanent. It doesn\'t untap during its controller\'s next untap step.', pt: '6/6', cmc: 6, manaCost: '{4}{U}{U}' }, + { name: 'Agent of Treachery', typeLine: 'Creature \u2014 Human Rogue', oracleText: 'When Agent of Treachery enters, gain control of target permanent.', pt: '2/3', cmc: 7, manaCost: '{5}{U}{U}' }, + { name: 'Stormtide Leviathan', typeLine: 'Creature \u2014 Leviathan', oracleText: 'Flying. Creatures without flying can\'t attack.', pt: '8/8', cmc: 8, manaCost: '{5}{U}{U}{U}' }, + ], + spells: [ + { name: 'Ponder', typeLine: 'Sorcery', oracleText: 'Look at the top three cards of your library, then put them back in any order. You may shuffle. Draw a card.', cmc: 1, manaCost: '{U}' }, + { name: 'Counterspell', typeLine: 'Instant', oracleText: 'Counter target spell.', cmc: 2, manaCost: '{U}{U}' }, + { name: 'Negate', typeLine: 'Instant', oracleText: 'Counter target noncreature spell.', cmc: 2, manaCost: '{1}{U}' }, + { name: 'Cyclonic Rift', typeLine: 'Instant', oracleText: 'Return target nonland permanent you don\'t control to its owner\'s hand. Overload {6}{U}', cmc: 2, manaCost: '{1}{U}', isRemoval: true }, + { name: 'Fact or Fiction', typeLine: 'Instant', oracleText: 'Reveal the top five cards of your library. An opponent separates those cards into two piles. Put one pile into your hand and the other into your graveyard.', cmc: 4, manaCost: '{3}{U}' }, + { name: 'Dig Through Time', typeLine: 'Instant', oracleText: 'Delve. Look at the top seven cards of your library. Put two of them into your hand and the rest on the bottom of your library in any order.', cmc: 8, manaCost: '{6}{U}{U}' }, + { name: 'Curse of the Swine', typeLine: 'Sorcery', oracleText: 'Exile X target creatures. For each creature exiled this way, its controller creates a 2/2 green Boar creature token.', cmc: 2, manaCost: '{X}{U}{U}', isRemoval: true }, + ], + equipment: [ + { name: 'Swiftfoot Boots', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has hexproof and haste. Equip {1}', cmc: 2, manaCost: '{2}' }, + { name: 'Whispersilk Cloak', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature can\'t be blocked and has shroud. Equip {2}', cmc: 3, manaCost: '{3}' }, + ], + enchantments: [ + { name: 'Rhystic Study', typeLine: 'Enchantment', oracleText: 'Whenever an opponent casts a spell, you may draw a card unless that player pays {1}.', cmc: 3, manaCost: '{2}{U}' }, + { name: 'Propaganda', typeLine: 'Enchantment', oracleText: 'Creatures can\'t attack you unless their controller pays {2} for each creature they control that\'s attacking you.', cmc: 3, manaCost: '{2}{U}' }, + ] + }, + mono_black: { + lands: [ + { name: 'Swamp', typeLine: 'Basic Land \u2014 Swamp', oracleText: '{T}: Add {B}.', mana: 'B' } + ], + creatures: [ + { name: 'Vampire of the Dire Moon', typeLine: 'Creature \u2014 Vampire', oracleText: 'Deathtouch, lifelink', pt: '1/1', cmc: 1, manaCost: '{B}' }, + { name: 'Blood Artist', typeLine: 'Creature \u2014 Vampire', oracleText: 'Whenever Blood Artist or another creature dies, target player loses 1 life and you gain 1 life.', pt: '0/1', cmc: 2, manaCost: '{1}{B}' }, + { name: 'Gifted Aetherborn', typeLine: 'Creature \u2014 Aetherborn Vampire', oracleText: 'Deathtouch, lifelink', pt: '2/3', cmc: 2, manaCost: '{B}{B}' }, + { name: 'Vampire Nighthawk', typeLine: 'Creature \u2014 Vampire Shaman', oracleText: 'Flying, deathtouch, lifelink', pt: '2/3', cmc: 3, manaCost: '{1}{B}{B}' }, + { name: 'Murderous Rider', typeLine: 'Creature \u2014 Zombie Knight', oracleText: 'Lifelink. When Murderous Rider dies, put it on the bottom of its owner\'s library.', pt: '2/3', cmc: 3, manaCost: '{1}{B}{B}' }, + { name: 'Gonti, Lord of Luxury', typeLine: 'Legendary Creature \u2014 Aetherborn Rogue', oracleText: 'Deathtouch. When Gonti enters, look at the top four cards of target opponent\'s library, exile one of them face down, then put the rest on the bottom of that library in a random order. You may cast that card for as long as it remains exiled, and you may spend mana as though it were mana of any color to cast that spell.', pt: '2/3', cmc: 4, manaCost: '{2}{B}{B}' }, + { name: 'Gray Merchant of Asphodel', typeLine: 'Creature \u2014 Zombie', oracleText: 'When Gray Merchant of Asphodel enters, each opponent loses X life, where X is your devotion to black. You gain life equal to the life lost this way.', pt: '2/4', cmc: 5, manaCost: '{3}{B}{B}' }, + { name: 'Noxious Gearhulk', typeLine: 'Creature \u2014 Construct', oracleText: 'Menace. When Noxious Gearhulk enters, destroy target creature an opponent controls. You gain life equal to that creature\'s toughness.', pt: '5/4', cmc: 6, manaCost: '{4}{B}{B}', isRemoval: true }, + { name: 'Grave Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Deathtouch. Whenever Grave Titan enters or attacks, create two 2/2 black Zombie creature tokens.', pt: '6/6', cmc: 6, manaCost: '{4}{B}{B}' }, + { name: 'Sheoldred, Whispering One', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Swampwalk. At the beginning of your upkeep, return target creature card from your graveyard to the battlefield. At the beginning of each opponent\'s upkeep, that player sacrifices a creature.', pt: '6/6', cmc: 7, manaCost: '{5}{B}{B}' }, + { name: 'Razaketh, the Foulblooded', typeLine: 'Legendary Creature \u2014 Demon', oracleText: 'Flying, trample. Pay 2 life, Sacrifice another creature: Search your library for a card, put that card into your hand, then shuffle.', pt: '8/8', cmc: 8, manaCost: '{5}{B}{B}{B}' }, + ], + spells: [ + { name: 'Doom Blade', typeLine: 'Instant', oracleText: 'Destroy target nonblack creature.', cmc: 2, manaCost: '{1}{B}', isRemoval: true }, + { name: 'Go for the Throat', typeLine: 'Instant', oracleText: 'Destroy target nonartifact creature.', cmc: 2, manaCost: '{1}{B}', isRemoval: true }, + { name: 'Sign in Blood', typeLine: 'Sorcery', oracleText: 'Target player draws two cards and loses 2 life.', cmc: 2, manaCost: '{B}{B}' }, + { name: 'Toxic Deluge', typeLine: 'Sorcery', oracleText: 'As an additional cost, pay X life. All creatures get -X/-X until end of turn.', cmc: 3, manaCost: '{2}{B}', isBoardWipe: true }, + { name: 'Damnation', typeLine: 'Sorcery', oracleText: 'Destroy all creatures. They can\'t be regenerated.', cmc: 4, manaCost: '{2}{B}{B}', isBoardWipe: true }, + { name: 'Read the Bones', typeLine: 'Sorcery', oracleText: 'Scry 2, then draw two cards. You lose 2 life.', cmc: 3, manaCost: '{2}{B}' }, + { name: 'Feed the Swarm', typeLine: 'Sorcery', oracleText: 'Destroy target creature or enchantment an opponent controls. You lose life equal to that permanent\'s mana value.', cmc: 2, manaCost: '{1}{B}', isRemoval: true }, + ], + equipment: [ + { name: 'Lashwrithe', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +1/+1 for each Swamp you control. Equip {B}{B}', cmc: 4, manaCost: '{4}' }, + { name: 'Nightmare Lash', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +1/+1 for each Swamp you control. Equip\u2014Pay 3 life.', cmc: 4, manaCost: '{4}' }, + ], + enchantments: [ + { name: 'Phyrexian Arena', typeLine: 'Enchantment', oracleText: 'At the beginning of your upkeep, you draw a card and you lose 1 life.', cmc: 3, manaCost: '{1}{B}{B}' }, + { name: 'Black Market', typeLine: 'Enchantment', oracleText: 'Whenever a creature dies, put a charge counter on Black Market. At the beginning of your precombat main phase, add {B} for each charge counter on Black Market.', cmc: 5, manaCost: '{3}{B}{B}' }, + ] + }, + mono_red: { + lands: [ + { name: 'Mountain', typeLine: 'Basic Land \u2014 Mountain', oracleText: '{T}: Add {R}.', mana: 'R' } + ], + creatures: [ + { name: 'Goblin Guide', typeLine: 'Creature \u2014 Goblin Scout', oracleText: 'Haste', pt: '2/2', cmc: 1, manaCost: '{R}' }, + { name: 'Monastery Swiftspear', typeLine: 'Creature \u2014 Human Monk', oracleText: 'Haste, prowess', pt: '1/2', cmc: 1, manaCost: '{R}' }, + { name: 'Ash Zealot', typeLine: 'Creature \u2014 Human Warrior', oracleText: 'First strike, haste', pt: '2/2', cmc: 2, manaCost: '{R}{R}' }, + { name: 'Eidolon of the Great Revel', typeLine: 'Enchantment Creature \u2014 Spirit', oracleText: 'Whenever a player casts a spell with mana value 3 or less, Eidolon of the Great Revel deals 2 damage to that player.', pt: '2/2', cmc: 2, manaCost: '{R}{R}' }, + { name: 'Goblin Rabblemaster', typeLine: 'Creature \u2014 Goblin Warrior', oracleText: 'Other Goblin creatures you control attack each combat if able. At the beginning of combat, create a 1/1 red Goblin creature token with haste.', pt: '2/2', cmc: 3, manaCost: '{2}{R}' }, + { name: 'Feldon of the Third Path', typeLine: 'Legendary Creature \u2014 Human Artificer', oracleText: '{2}{R}, {T}: Create a token that\'s a copy of target creature card in your graveyard, except it\'s an artifact in addition to its other types. It gains haste. Sacrifice it at the beginning of the next end step.', pt: '2/3', cmc: 3, manaCost: '{1}{R}{R}' }, + { name: 'Hellrider', typeLine: 'Creature \u2014 Devil', oracleText: 'Haste. Whenever a creature you control attacks, Hellrider deals 1 damage to the defending player.', pt: '3/3', cmc: 4, manaCost: '{2}{R}{R}' }, + { name: 'Purphoros, God of the Forge', typeLine: 'Legendary Enchantment Creature \u2014 God', oracleText: 'Indestructible. Whenever another creature enters under your control, Purphoros deals 2 damage to each opponent.', pt: '6/5', cmc: 4, manaCost: '{3}{R}' }, + { name: 'Thundermaw Hellkite', typeLine: 'Creature \u2014 Dragon', oracleText: 'Flying, haste. When Thundermaw Hellkite enters, it deals 1 damage to each creature with flying your opponents control. Tap those creatures.', pt: '5/5', cmc: 5, manaCost: '{3}{R}{R}' }, + { name: 'Inferno Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Whenever Inferno Titan enters or attacks, it deals 3 damage divided as you choose among one, two, or three targets.', pt: '6/6', cmc: 6, manaCost: '{4}{R}{R}' }, + { name: 'Drakuseth, Maw of Flames', typeLine: 'Legendary Creature \u2014 Dragon', oracleText: 'Flying. Whenever Drakuseth attacks, it deals 4 damage to any target and 3 damage to each of up to two other targets.', pt: '7/7', cmc: 7, manaCost: '{4}{R}{R}{R}' }, + { name: 'Etali, Primal Storm', typeLine: 'Legendary Creature \u2014 Elder Dinosaur', oracleText: 'Whenever Etali attacks, exile the top card of each player\'s library, then you may cast any number of spells from among those cards without paying their mana costs.', pt: '6/6', cmc: 6, manaCost: '{4}{R}{R}' }, + ], + spells: [ + { name: 'Lightning Bolt', typeLine: 'Instant', oracleText: 'Lightning Bolt deals 3 damage to any target.', cmc: 1, manaCost: '{R}', isRemoval: true }, + { name: 'Chaos Warp', typeLine: 'Instant', oracleText: 'The owner of target permanent shuffles it into their library, then reveals the top card of their library.', cmc: 3, manaCost: '{2}{R}', isRemoval: true }, + { name: 'Blasphemous Act', typeLine: 'Sorcery', oracleText: 'This spell costs {1} less to cast for each creature on the battlefield. Blasphemous Act deals 13 damage to each creature.', cmc: 9, manaCost: '{8}{R}', isBoardWipe: true }, + { name: 'Faithless Looting', typeLine: 'Sorcery', oracleText: 'Draw two cards, then discard two cards.', cmc: 1, manaCost: '{R}' }, + { name: 'Vandalblast', typeLine: 'Sorcery', oracleText: 'Destroy target artifact you don\'t control. Overload {4}{R}', cmc: 1, manaCost: '{R}', isRemoval: true }, + { name: 'Flame Slash', typeLine: 'Sorcery', oracleText: 'Flame Slash deals 4 damage to target creature.', cmc: 1, manaCost: '{R}', isRemoval: true }, + { name: 'Wheel of Fortune', typeLine: 'Sorcery', oracleText: 'Each player discards their hand, then draws seven cards.', cmc: 3, manaCost: '{2}{R}' }, + ], + equipment: [ + { name: 'Embercleave', typeLine: 'Legendary Artifact \u2014 Equipment', oracleText: 'Flash. This spell costs {1} less to cast for each attacking creature you control. Equipped creature gets +1/+1 and has double strike and trample. Equip {3}', cmc: 6, manaCost: '{4}{R}{R}' }, + { name: 'Sword of Feast and Famine', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +2/+2 and has protection from black and from green. Whenever equipped creature deals combat damage to a player, that player discards a card and you untap all lands you control. Equip {2}', cmc: 3, manaCost: '{3}' }, + ], + enchantments: [ + { name: 'Impact Tremors', typeLine: 'Enchantment', oracleText: 'Whenever a creature enters under your control, Impact Tremors deals 1 damage to each opponent.', cmc: 2, manaCost: '{1}{R}' }, + { name: 'Goblin Bombardment', typeLine: 'Enchantment', oracleText: 'Sacrifice a creature: Goblin Bombardment deals 1 damage to any target.', cmc: 2, manaCost: '{1}{R}' }, + ] + }, + mono_green: { + lands: [ + { name: 'Forest', typeLine: 'Basic Land \u2014 Forest', oracleText: '{T}: Add {G}.', mana: 'G' } + ], + creatures: [ + { name: 'Llanowar Elves', typeLine: 'Creature \u2014 Elf Druid', oracleText: '{T}: Add {G}.', pt: '1/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' }, + { name: 'Elvish Mystic', typeLine: 'Creature \u2014 Elf Druid', oracleText: '{T}: Add {G}.', pt: '1/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' }, + { name: 'Birds of Paradise', typeLine: 'Creature \u2014 Bird', oracleText: 'Flying. {T}: Add one mana of any color.', pt: '0/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' }, + { name: 'Fauna Shaman', typeLine: 'Creature \u2014 Elf Shaman', oracleText: '{G}, {T}, Discard a creature card: Search your library for a creature card, reveal it, put it into your hand, then shuffle.', pt: '2/2', cmc: 2, manaCost: '{1}{G}' }, + { name: 'Leatherback Baloth', typeLine: 'Creature \u2014 Beast', oracleText: '', pt: '4/5', cmc: 3, manaCost: '{G}{G}{G}' }, + { name: 'Reclamation Sage', typeLine: 'Creature \u2014 Elf Shaman', oracleText: 'When Reclamation Sage enters, you may destroy target artifact or enchantment.', pt: '2/1', cmc: 3, manaCost: '{2}{G}', isRemoval: true }, + { name: 'Beast Whisperer', typeLine: 'Creature \u2014 Elf Druid', oracleText: 'Whenever you cast a creature spell, draw a card.', pt: '2/3', cmc: 4, manaCost: '{2}{G}{G}' }, + { name: 'Thragtusk', typeLine: 'Creature \u2014 Beast', oracleText: 'When Thragtusk enters, you gain 5 life. When Thragtusk leaves the battlefield, create a 3/3 green Beast creature token.', pt: '5/3', cmc: 5, manaCost: '{4}{G}' }, + { name: 'Acidic Slime', typeLine: 'Creature \u2014 Ooze', oracleText: 'Deathtouch. When Acidic Slime enters, destroy target artifact, enchantment, or land.', pt: '2/2', cmc: 5, manaCost: '{3}{G}{G}', isRemoval: true }, + { name: 'Primeval Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Trample. Whenever Primeval Titan enters or attacks, you may search your library for up to two land cards, put them onto the battlefield tapped, then shuffle.', pt: '6/6', cmc: 6, manaCost: '{4}{G}{G}' }, + { name: 'Vorinclex, Voice of Hunger', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Trample. Whenever you tap a land for mana, add one mana of any type that land produced. Whenever an opponent taps a land for mana, that land doesn\'t untap during its controller\'s next untap step.', pt: '7/6', cmc: 8, manaCost: '{6}{G}{G}' }, + { name: 'Craterhoof Behemoth', typeLine: 'Creature \u2014 Beast', oracleText: 'Haste. When Craterhoof Behemoth enters, creatures you control get +X/+X and gain trample until end of turn, where X is the number of creatures you control.', pt: '5/5', cmc: 8, manaCost: '{5}{G}{G}{G}' }, + ], + spells: [ + { name: 'Beast Within', typeLine: 'Instant', oracleText: 'Destroy target permanent. Its controller creates a 3/3 green Beast creature token.', cmc: 3, manaCost: '{2}{G}', isRemoval: true }, + { name: 'Cultivate', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards, reveal those cards, put one onto the battlefield tapped and the other into your hand, then shuffle.', cmc: 3, manaCost: '{2}{G}', isRamp: true }, + { name: 'Kodama\'s Reach', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards, reveal those cards, put one onto the battlefield tapped and the other into your hand, then shuffle.', cmc: 3, manaCost: '{2}{G}', isRamp: true }, + { name: 'Harmonize', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, manaCost: '{2}{G}{G}' }, + { name: 'Green Sun\'s Zenith', typeLine: 'Sorcery', oracleText: 'Search your library for a green creature card with mana value X or less, put it onto the battlefield, then shuffle. Shuffle Green Sun\'s Zenith into its owner\'s library.', cmc: 1, manaCost: '{X}{G}' }, + { name: 'Return of the Wildspeaker', typeLine: 'Instant', oracleText: 'Choose one: Draw cards equal to the greatest power among non-Human creatures you control; or non-Human creatures you control get +3/+3 until end of turn.', cmc: 5, manaCost: '{4}{G}' }, + ], + equipment: [ + { name: 'Basilisk Collar', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has deathtouch and lifelink. Equip {2}', cmc: 1, manaCost: '{1}' }, + { name: 'Trailblazer\'s Boots', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has nonbasic landwalk. Equip {2}', cmc: 2, manaCost: '{2}' }, + ], + enchantments: [ + { name: 'Sylvan Library', typeLine: 'Enchantment', oracleText: 'At the beginning of your draw step, you may draw two additional cards. If you do, choose two cards in your hand drawn this turn. For each of those cards, pay 4 life or put the card on top of your library.', cmc: 2, manaCost: '{1}{G}' }, + { name: 'Greater Good', typeLine: 'Enchantment', oracleText: 'Sacrifice a creature: Draw cards equal to the sacrificed creature\'s power, then discard three cards.', cmc: 4, manaCost: '{2}{G}{G}' }, + ] + } +}; + +const AI_COLOR_KEYS = ['mono_white', 'mono_blue', 'mono_black', 'mono_red', 'mono_green']; + +function generateAiDeck() { + // Pick a random color + const colorKey = AI_COLOR_KEYS[Math.floor(Math.random() * AI_COLOR_KEYS.length)]; + const template = AI_DECK_TEMPLATES[colorKey]; + const deck = []; + + // 36 lands (reduced from 38 to make room for equipment/enchantments) + for (let i = 0; i < 36; i++) { + const land = { ...template.lands[0] }; + deck.push(land); + } + + // Fill creatures - randomly sample from pool to get ~35 (with some duplicates allowed) + const creatures = template.creatures; + for (let i = 0; i < 35; i++) { + const c = creatures[i % creatures.length]; + deck.push({ ...c }); + } + + // Fill spells - randomly sample to get ~18 + const spells = template.spells; + for (let i = 0; i < 18; i++) { + const s = spells[i % spells.length]; + deck.push({ ...s }); + } + + // Equipment (4 copies spread from pool) + const equip = template.equipment || []; + for (let i = 0; i < 4 && equip.length > 0; i++) { + const e = equip[i % equip.length]; + deck.push({ ...e }); + } + + // Enchantments (4 copies spread from pool) + const ench = template.enchantments || []; + for (let i = 0; i < 4 && ench.length > 0; i++) { + const e = ench[i % ench.length]; + deck.push({ ...e }); + } + + // Ramp spells get the isRamp flag preserved (green especially) + // Commander (last creature in pool, bumped to slot 0) + const commander = { ...creatures[creatures.length - 1], isCommander: true }; + deck.push(commander); + + // Shuffle (Fisher-Yates) + for (let i = deck.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + + return { deck, colorKey }; +} + +function setupAiDecks() { + if (!gameState) return; + gameState.players.forEach((player, idx) => { + if (!player.isAI) return; + const { deck, colorKey } = generateAiDeck(); + player.aiColorKey = colorKey; + player.aiLandPlayedThisTurn = false; + + // Put commander in command zone, rest in library + deck.forEach(card => { + const cardObj = { + name: card.name, + imageUrl: '', // AI uses text-based rendering + typeLine: card.typeLine, + oracleText: card.oracleText || '', + cmc: card.cmc || 0, + pt: card.pt || '', + mana: card.mana || '', + manaCost: card.manaCost || '', + isRemoval: card.isRemoval || false, + isBoardWipe: card.isBoardWipe || false, + isManaProducer: card.isManaProducer || false, + manaAbility: card.manaAbility || '', + isRamp: card.isRamp || false, + }; + if (card.isCommander) { + player.zones.command.push(cardObj); + if (!player.commanderCardName) player.commanderCardName = cardObj.name || ''; + } else { + player.zones.library.push(cardObj); + } + }); + + // Draw opening hand of 7 with mulligan logic + // AI mulligans until it has at least 2 lands, up to 3 times + var mulliganCount = 0; + var maxMulligans = 3; + var handOk = false; + + while (!handOk && mulliganCount <= maxMulligans) { + // Draw 7 + player.zones.hand = []; + for (let i = 0; i < 7 && player.zones.library.length > 0; i++) { + player.zones.hand.push(player.zones.library.shift()); + } + + // Count lands in hand + var landsInHand = player.zones.hand.filter(function(c) { + return c.typeLine && c.typeLine.toLowerCase().includes('land'); + }).length; + + if (landsInHand >= 2) { + handOk = true; + if (mulliganCount > 0) { + addLogEntry('\u{1F916} ' + player.name + ' mulliganed ' + mulliganCount + ' time' + (mulliganCount > 1 ? 's' : '') + ' (found ' + landsInHand + ' lands)'); + } + } else if (mulliganCount < maxMulligans) { + // Put hand back into library and reshuffle + while (player.zones.hand.length > 0) { + player.zones.library.push(player.zones.hand.pop()); + } + // Fisher-Yates shuffle + for (let i = player.zones.library.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [player.zones.library[i], player.zones.library[j]] = [player.zones.library[j], player.zones.library[i]]; + } + mulliganCount++; + } else { + // Max mulligans reached, keep whatever we have + addLogEntry('\u{1F916} ' + player.name + ' kept hand after ' + mulliganCount + ' mulligan' + (mulliganCount > 1 ? 's' : '') + ' (' + landsInHand + ' lands)'); + handOk = true; + } + } + + // Commander mulligan penalty: for each mulligan past the free one, put 1 card on bottom + var cardsToPutBack = Math.max(0, mulliganCount - 1); + for (let i = 0; i < cardsToPutBack && player.zones.hand.length > 1; i++) { + // Put the highest CMC non-land card on bottom (expensive cards hurt most when down cards) + var worstIdx = 0; + var worstScore = -1; + player.zones.hand.forEach(function(c, ci) { + var isLand = c.typeLine && c.typeLine.toLowerCase().includes('land'); + // Never bottom lands; pick highest CMC spell to bottom + var score = isLand ? -1 : (c.cmc || 0); + if (score > worstScore) { + worstScore = score; + worstIdx = ci; + } + }); + var bottomCard = player.zones.hand.splice(worstIdx, 1)[0]; + player.zones.library.push(bottomCard); + } + }); +} + +async function fetchAiCardImages() { + if (!gameState) return; + // Collect all unique card names from AI players + var allNames = []; + gameState.players.forEach(function(player) { + if (!player.isAI) return; + var zones = ['library', 'hand', 'command']; + zones.forEach(function(z) { + if (player.zones[z]) { + player.zones[z].forEach(function(card) { + if (card.name && allNames.indexOf(card.name) === -1) { + allNames.push(card.name); + } + }); + } + }); + }); + if (allNames.length === 0) return; + + try { + var cardDataMap = await batchFetchCards(allNames, null); + + // Update imageUrl on all AI cards in all zones + gameState.players.forEach(function(player) { + if (!player.isAI) return; + var zones = ['library', 'hand', 'command']; + zones.forEach(function(z) { + if (player.zones[z]) { + player.zones[z].forEach(function(card) { + var data = cardDataMap.get(card.name.toLowerCase()); + if (data) { + var imgUrl = ''; + if (data.image_uris && data.image_uris.normal) { + imgUrl = data.image_uris.normal; + } else if (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris) { + imgUrl = data.card_faces[0].image_uris.normal; + } + if (imgUrl) card.imageUrl = imgUrl; + } + }); + } + }); + }); + + // Also update any AI cards already on the battlefield + gameState.battlefieldCards.forEach(function(card) { + var owner = gameState.players[card.ownerId]; + if (!owner || !owner.isAI) return; + var data = cardDataMap.get(card.name.toLowerCase()); + if (data) { + var imgUrl = ''; + if (data.image_uris && data.image_uris.normal) { + imgUrl = data.image_uris.normal; + } else if (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris) { + imgUrl = data.card_faces[0].image_uris.normal; + } + if (imgUrl) { + card.imageUrl = imgUrl; + var el = document.getElementById('bc-' + card.id); + if (el) { + var img = el.querySelector('img'); + if (img) img.src = imgUrl; + } + } + } + }); + + // Re-render hand if current player has AI cards visible + if (typeof renderHandArea === 'function') renderHandArea(); + if (typeof renderPlayerPanels === 'function') renderPlayerPanels(); + } catch(e) { + console.warn('[AI Images] Failed to fetch card images:', e); + } +} + +// Determine how many lands the AI has on the battlefield (untapped) +function aiCountLands(playerId) { + return gameState.battlefieldCards.filter(c => + c.ownerId === playerId && c.typeLine && c.typeLine.toLowerCase().includes('land') + ); +} + +function aiCountUntappedLands(playerId) { + return aiCountLands(playerId).filter(c => !c.tapped).length; +} + +function aiCountCreatures(playerId) { + return gameState.battlefieldCards.filter(c => + c.ownerId === playerId && c.typeLine && c.typeLine.toLowerCase().includes('creature') && !c.typeLine.toLowerCase().includes('land') + ); +} + +function aiGetUntappedCreatures(playerId) { + return aiCountCreatures(playerId).filter(c => !c.tapped); +} + +function aiIsLand(card) { + return card.typeLine && card.typeLine.toLowerCase().includes('land'); +} +function aiIsCreature(card) { + return card.typeLine && card.typeLine.toLowerCase().includes('creature'); +} + +function aiGetCmc(card) { + return card.cmc || 0; +} + +// ============================================================ +// FORGE-INSPIRED CARD SCRIPT API +// Declarative card scripting with SpellAbility objects +// ============================================================ + +// --- SpellAbility Class --- +// Each ability on a card is decomposed into a SpellAbility object +// with cost, effect, targeting, and a resolve() method. +// Inspired by Forge's SpellAbility.java architecture. + +function SpellAbility(config) { + this.id = 'sa_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + this.abilityType = config.abilityType || 'activated'; // activated | triggered | static | mana | spell | keyword + this.api = config.api || ''; // e.g. 'DealDamage', 'GainLife', 'DrawCard', 'DestroyPermanent' + this.cost = config.cost || null; // { mana: '{2}{B}', tap: true, sacrifice: false, discard: 0, payLife: 0, custom: '' } + this.effect = config.effect || {}; // { amount: 3, target: 'creature', scope: 'opponent', ... } + this.trigger = config.trigger || null; // { event: 'etb', filter: 'self' } for triggered abilities + this.keyword = config.keyword || null; // 'flying', 'deathtouch', etc. for keyword abilities + this.zone = config.zone || 'battlefield'; // where this ability functions + this.oracleSnippet = config.oracleSnippet || ''; + this.cardName = config.cardName || ''; + this.ownerIndex = config.ownerIndex !== undefined ? config.ownerIndex : -1; + this.sourceCardId = config.sourceCardId || null; + this.restrictions = config.restrictions || {}; // { sorcerySpeed: true, oncePerTurn: false } + this.usedThisTurn = false; + + // AI evaluation hints (Forge-inspired) + this.aiHints = config.aiHints || {}; + // e.g. { priority: 'high', timing: 'mainPhase', targetPriority: 'threateningCreature' } +} + +SpellAbility.prototype.canActivate = function(gameState, playerIdx) { + if (this.restrictions.oncePerTurn && this.usedThisTurn) return false; + if (this.restrictions.sorcerySpeed) { + var phase = PHASES[gameState.currentPhaseIndex]; + var isMain = phase && (phase.id === 'main1' || phase.id === 'main2'); + if (!isMain) return false; + if (gameState.currentPlayerIndex !== playerIdx) return false; + } + if (this.cost) { + if (this.cost.tap && this.sourceCardId) { + var src = gameState.battlefieldCards.find(function(c) { return c.id === this.sourceCardId; }.bind(this)); + if (src && src.tapped) return false; + } + if (this.cost.mana) { + var cmc = parseCMCFromManaCost(this.cost.mana); + if (aiCountUntappedLands(playerIdx) < cmc) return false; + } + if (this.cost.payLife) { + var p = gameState.players[playerIdx]; + if (!p || p.life <= this.cost.payLife) return false; + } + } + return true; +}; + +SpellAbility.prototype.resolve = function(gameState, context) { + // context: { caster: playerIdx, targets: [], sourceCard: cardData } + var api = this.api; + var eff = this.effect; + var caster = context.caster; + var targets = context.targets || []; + var source = context.sourceCard; + + // Mark used + this.usedThisTurn = true; + + // Dispatch to the appropriate CardScript API resolver + if (CardScriptResolvers[api]) { + return CardScriptResolvers[api](gameState, eff, caster, targets, source, this); + } + + // Fallback: log that it resolved but has no automated effect + if (typeof addLogEntry === 'function') { + addLogEntry('⚙ ' + this.cardName + ' ability resolved (manual: ' + (this.oracleSnippet || api) + ')'); + } + return { resolved: true, automated: false }; +}; + +SpellAbility.prototype.resetTurn = function() { + this.usedThisTurn = false; +}; + +// Helper to parse CMC from a mana cost string like '{2}{B}{B}' +function parseCMCFromManaCost(manaCost) { + if (!manaCost) return 0; + var total = 0; + var matches = manaCost.match(/\{([^}]+)\}/g); + if (!matches) return 0; + for (var i = 0; i < matches.length; i++) { + var inner = matches[i].replace(/[{}]/g, ''); + var num = parseInt(inner); + if (!isNaN(num)) { + total += num; + } else if (/^[WUBRGC]$/i.test(inner)) { + total += 1; + } else if (/^[WUBRG]\/[WUBRG]$/i.test(inner)) { + total += 1; // hybrid + } else if (inner === 'X') { + total += 0; // X = variable + } + } + return total; +} + + +// ============================================================ +// CARD SCRIPT RESOLVERS — Automated effects for each API type +// ============================================================ + +var CardScriptResolvers = { + + // --- Damage --- + DealDamage: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var resolved = []; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + if (t.type === 'player') { + var player = gs.players[t.index]; + if (player && !player.eliminated) { + player.life -= amount; + if (typeof addLogEntry === 'function') { + addLogEntry('⚡ ' + (source ? source.name : sa.cardName) + ' deals ' + amount + ' damage to ' + player.name + ''); + } + resolved.push({ type: 'player', index: t.index, damage: amount }); + } + } else if (t.type === 'creature') { + var card = gs.battlefieldCards.find(function(c) { return c.id === t.id; }); + if (card) { + card.damageMarked = (card.damageMarked || 0) + amount; + if (typeof addLogEntry === 'function') { + addLogEntry('⚡ ' + (source ? source.name : sa.cardName) + ' deals ' + amount + ' damage to ' + card.name + ''); + } + resolved.push({ type: 'creature', id: t.id, damage: amount }); + } + } + } + return { resolved: true, automated: true, results: resolved }; + }, + + // --- Life Gain --- + GainLife: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var player = gs.players[caster]; + if (player) { + player.life += amount; + if (typeof addLogEntry === 'function') { + addLogEntry('❤ ' + player.name + ' gains ' + amount + ' life from ' + (source ? source.name : sa.cardName) + ''); + } + if (typeof fireBattlefieldTriggers === 'function') { + fireBattlefieldTriggers('life_gain'); + } + } + return { resolved: true, automated: true }; + }, + + // --- Life Loss --- + LoseLife: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + if (t.type === 'player') { + var player = gs.players[t.index]; + if (player) { + player.life -= amount; + if (typeof addLogEntry === 'function') { + addLogEntry('💀 ' + player.name + ' loses ' + amount + ' life'); + } + } + } + } + return { resolved: true, automated: true }; + }, + + // --- Draw Cards --- + DrawCard: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var playerIdx = (eff.targetSelf !== false) ? caster : (targets.length > 0 ? targets[0].index : caster); + var player = gs.players[playerIdx]; + if (player) { + var drawn = 0; + for (var d = 0; d < amount; d++) { + if (player.zones.library.length > 0) { + var drawnCard = player.zones.library.shift(); + player.zones.hand.push(drawnCard); + drawn++; + } + } + if (typeof addLogEntry === 'function') { + addLogEntry('📃 ' + player.name + ' draws ' + drawn + ' card' + (drawn !== 1 ? 's' : '') + ' from ' + (source ? source.name : sa.cardName) + ''); + } + if (typeof fireBattlefieldTriggers === 'function') { + for (var dd = 0; dd < drawn; dd++) { + fireBattlefieldTriggers('draw'); + } + } + } + return { resolved: true, automated: true, drawn: drawn }; + }, + + // --- Discard --- + Discard: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var playerIdx = (eff.scope === 'opponent' && targets.length > 0) ? targets[0].index : caster; + var player = gs.players[playerIdx]; + if (player) { + var discarded = 0; + for (var d = 0; d < amount; d++) { + if (player.zones.hand.length > 0) { + // AI: discard worst card; Human: random for now + var idx = player.isAI ? player.zones.hand.length - 1 : Math.floor(Math.random() * player.zones.hand.length); + var card = player.zones.hand.splice(idx, 1)[0]; + player.zones.graveyard.push(card); + discarded++; + } + } + if (typeof addLogEntry === 'function') { + addLogEntry('☠ ' + player.name + ' discards ' + discarded + ' card' + (discarded !== 1 ? 's' : '')); + } + } + return { resolved: true, automated: true }; + }, + + // --- Destroy Permanent --- + DestroyPermanent: function(gs, eff, caster, targets, source, sa) { + var destroyed = []; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + if (t.type === 'creature' || t.type === 'permanent') { + var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; }); + if (idx !== -1) { + var card = gs.battlefieldCards[idx]; + // Check indestructible + if (typeof hasKeyword === 'function' && hasKeyword(card, 'indestructible')) { + if (typeof addLogEntry === 'function') { + addLogEntry('🛡 ' + card.name + ' is indestructible!'); + } + continue; + } + gs.battlefieldCards.splice(idx, 1); + var owner = gs.players[card.ownerIndex || card.ownerId]; + if (owner) { + owner.zones.graveyard.push(card); + } + // Fire dies triggers + if (typeof fireBattlefieldTriggers === 'function') { + fireBattlefieldTriggers('dies'); + } + if (typeof fireCardTriggers === 'function') { + fireCardTriggers(card, ['dies']); + } + if (typeof addLogEntry === 'function') { + addLogEntry('☠ ' + card.name + ' was destroyed by ' + (source ? source.name : sa.cardName) + ''); + } + destroyed.push(card); + } + } + } + return { resolved: true, automated: true, destroyed: destroyed }; + }, + + // --- Exile Permanent --- + ExilePermanent: function(gs, eff, caster, targets, source, sa) { + var exiled = []; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; }); + if (idx !== -1) { + var card = gs.battlefieldCards.splice(idx, 1)[0]; + var owner = gs.players[card.ownerIndex || card.ownerId]; + if (owner) { + owner.zones.exile.push(card); + } + if (typeof addLogEntry === 'function') { + addLogEntry('⬡ ' + card.name + ' was exiled by ' + (source ? source.name : sa.cardName) + ''); + } + exiled.push(card); + } + } + return { resolved: true, automated: true, exiled: exiled }; + }, + + // --- Create Token --- + CreateToken: function(gs, eff, caster, targets, source, sa) { + var count = eff.amount || 1; + var tokenName = eff.tokenName || 'Token'; + var tokenPower = eff.power || 1; + var tokenTough = eff.toughness || 1; + var tokenType = eff.tokenType || 'Creature Token'; + var tokenKeywords = eff.keywords || []; + + for (var i = 0; i < count; i++) { + var tokenData = { + name: tokenName, + typeLine: tokenType, + oracleText: tokenKeywords.join(', '), + power: String(tokenPower), + toughness: String(tokenTough), + pt: tokenPower + '/' + tokenTough, + imageUrl: '', + isToken: true, + keywords: tokenKeywords + }; + if (typeof generateAiCardSVG === 'function') { + tokenData.imageUrl = generateAiCardSVG(tokenData); + } + if (typeof addCardToBattlefield === 'function') { + addCardToBattlefield(tokenName, tokenData.imageUrl, tokenType, caster, undefined, undefined, tokenData.oracleText, tokenData); + } + } + if (typeof addLogEntry === 'function') { + addLogEntry('⚖ ' + gs.players[caster].name + ' creates ' + count + ' ' + tokenPower + '/' + tokenTough + ' ' + tokenName + ' token' + (count !== 1 ? 's' : '')); + } + return { resolved: true, automated: true }; + }, + + // --- Mana Production --- + ManaAdd: function(gs, eff, caster, targets, source, sa) { + // Visual log only; mana pool is handled by existing adjustMana system + var colors = eff.colors || ['C']; // colorless by default + if (typeof addLogEntry === 'function') { + addLogEntry('⚛ ' + (source ? source.name : sa.cardName) + ' adds {' + colors.join('}{') + '} to mana pool'); + } + return { resolved: true, automated: true }; + }, + + // --- Pump Creature (+X/+X until end of turn) --- + PumpCreature: function(gs, eff, caster, targets, source, sa) { + var pw = eff.power || 0; + var tw = eff.toughness || 0; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + var card = gs.battlefieldCards.find(function(c) { return c.id === t.id; }); + if (card) { + card.powerMod = (card.powerMod || 0) + pw; + card.toughnessMod = (card.toughnessMod || 0) + tw; + if (typeof addLogEntry === 'function') { + var sign = function(n) { return n >= 0 ? '+' + n : '' + n; }; + addLogEntry('⚔ ' + card.name + ' gets ' + sign(pw) + '/' + sign(tw) + ' from ' + (source ? source.name : sa.cardName) + ''); + } + } + } + return { resolved: true, automated: true }; + }, + + // --- Counter Spell --- + CounterSpell: function(gs, eff, caster, targets, source, sa) { + if (gs.theStack && gs.theStack.length > 0) { + // Counter the top spell on the stack (or targeted) + var targetIdx = targets.length > 0 ? targets[0].stackIndex : gs.theStack.length - 1; + if (targetIdx >= 0 && targetIdx < gs.theStack.length) { + var countered = gs.theStack.splice(targetIdx, 1)[0]; + var owner = gs.players[countered.ownerIndex]; + if (owner) { + owner.zones.graveyard.push(countered); + } + if (typeof addLogEntry === 'function') { + addLogEntry('❌ ' + (source ? source.name : sa.cardName) + ' counters ' + countered.name + '!'); + } + return { resolved: true, automated: true, countered: countered }; + } + } + return { resolved: true, automated: false }; + }, + + // --- Search Library --- + SearchLibrary: function(gs, eff, caster, targets, source, sa) { + // Log only; actual search requires human interaction + if (typeof addLogEntry === 'function') { + addLogEntry('📚 ' + gs.players[caster].name + ' searches their library (' + (source ? source.name : sa.cardName) + ')'); + } + return { resolved: true, automated: false }; + }, + + // --- Board Wipe --- + DestroyAll: function(gs, eff, caster, targets, source, sa) { + var filter = eff.filter || 'creature'; // 'creature', 'nonland', 'all' + var destroyed = []; + var toRemove = []; + for (var i = gs.battlefieldCards.length - 1; i >= 0; i--) { + var card = gs.battlefieldCards[i]; + var matches = false; + if (filter === 'creature' && card.typeLine && /creature/i.test(card.typeLine)) matches = true; + if (filter === 'nonland' && card.typeLine && !/land/i.test(card.typeLine)) matches = true; + if (filter === 'all') matches = true; + if (filter === 'artifact' && card.typeLine && /artifact/i.test(card.typeLine)) matches = true; + if (filter === 'enchantment' && card.typeLine && /enchantment/i.test(card.typeLine)) matches = true; + + if (matches) { + if (typeof hasKeyword === 'function' && hasKeyword(card, 'indestructible')) continue; + toRemove.push(i); + } + } + for (var r = 0; r < toRemove.length; r++) { + var card = gs.battlefieldCards.splice(toRemove[r], 1)[0]; + var owner = gs.players[card.ownerIndex || card.ownerId]; + if (owner) owner.zones.graveyard.push(card); + destroyed.push(card); + } + if (typeof addLogEntry === 'function') { + addLogEntry('💥 ' + (source ? source.name : sa.cardName) + ' destroys ' + destroyed.length + ' permanents!'); + } + return { resolved: true, automated: true, destroyed: destroyed }; + }, + + // --- Bounce (Return to Hand) --- + ReturnToHand: function(gs, eff, caster, targets, source, sa) { + var bounced = []; + for (var i = 0; i < targets.length; i++) { + var t = targets[i]; + var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; }); + if (idx !== -1) { + var card = gs.battlefieldCards.splice(idx, 1)[0]; + var owner = gs.players[card.ownerIndex || card.ownerId]; + if (owner) owner.zones.hand.push(card); + if (typeof addLogEntry === 'function') { + addLogEntry('🔄 ' + card.name + ' returned to hand by ' + (source ? source.name : sa.cardName) + ''); + } + bounced.push(card); + } + } + return { resolved: true, automated: true, bounced: bounced }; + }, + + // --- Tap/Untap --- + TapPermanent: function(gs, eff, caster, targets, source, sa) { + for (var i = 0; i < targets.length; i++) { + var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; }); + if (card) { + card.tapped = eff.untap ? false : true; + } + } + return { resolved: true, automated: true }; + }, + + // --- Mill --- + Mill: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var playerIdx = targets.length > 0 ? targets[0].index : caster; + var player = gs.players[playerIdx]; + if (player) { + var milled = 0; + for (var m = 0; m < amount; m++) { + if (player.zones.library.length > 0) { + var card = player.zones.library.shift(); + player.zones.graveyard.push(card); + milled++; + } + } + if (typeof addLogEntry === 'function') { + addLogEntry('🌀 ' + player.name + ' mills ' + milled + ' card' + (milled !== 1 ? 's' : '')); + } + } + return { resolved: true, automated: true }; + }, + + // --- Scry --- + Scry: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + if (typeof addLogEntry === 'function') { + addLogEntry('👁 ' + gs.players[caster].name + ' scries ' + amount + ' (' + (source ? source.name : sa.cardName) + ')'); + } + return { resolved: true, automated: false }; // Requires manual reordering + }, + + // --- Gain Keyword (until end of turn) --- + GainKeyword: function(gs, eff, caster, targets, source, sa) { + var keyword = eff.keyword || ''; + for (var i = 0; i < targets.length; i++) { + var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; }); + if (card) { + if (!card.tempKeywords) card.tempKeywords = []; + card.tempKeywords.push(keyword); + if (typeof addLogEntry === 'function') { + addLogEntry('✨ ' + card.name + ' gains ' + keyword + ' until end of turn'); + } + } + } + return { resolved: true, automated: true }; + }, + + // --- Put Counters --- + PutCounters: function(gs, eff, caster, targets, source, sa) { + var counterType = eff.counterType || '+1/+1'; + var amount = eff.amount || 1; + for (var i = 0; i < targets.length; i++) { + var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; }); + if (card) { + if (!card.counters) card.counters = {}; + card.counters[counterType] = (card.counters[counterType] || 0) + amount; + // Apply stat mods for +1/+1 and -1/-1 counters + if (counterType === '+1/+1') { + card.powerMod = (card.powerMod || 0) + amount; + card.toughnessMod = (card.toughnessMod || 0) + amount; + } else if (counterType === '-1/-1') { + card.powerMod = (card.powerMod || 0) - amount; + card.toughnessMod = (card.toughnessMod || 0) - amount; + } + if (typeof addLogEntry === 'function') { + addLogEntry('⬤ ' + amount + ' ' + counterType + ' counter' + (amount !== 1 ? 's' : '') + ' on ' + card.name + ''); + } + } + } + return { resolved: true, automated: true }; + }, + + // --- Ramp (Put land from library onto battlefield) --- + Ramp: function(gs, eff, caster, targets, source, sa) { + var amount = eff.amount || 1; + var player = gs.players[caster]; + if (player) { + var ramped = 0; + for (var r = 0; r < amount; r++) { + // Find a basic land in library + var landIdx = player.zones.library.findIndex(function(c) { + return c.typeLine && /basic.*land/i.test(c.typeLine); + }); + if (landIdx === -1) { + landIdx = player.zones.library.findIndex(function(c) { + return c.typeLine && /land/i.test(c.typeLine); + }); + } + if (landIdx !== -1) { + var land = player.zones.library.splice(landIdx, 1)[0]; + if (typeof addCardToBattlefield === 'function') { + addCardToBattlefield(land.name, land.imageUrl || '', land.typeLine || 'Land', caster, undefined, undefined, land.oracleText || '', land); + } + ramped++; + } + } + if (typeof addLogEntry === 'function' && ramped > 0) { + addLogEntry('🌱 ' + player.name + ' ramps ' + ramped + ' land' + (ramped !== 1 ? 's' : '') + ' (' + (source ? source.name : sa.cardName) + ')'); + } + } + return { resolved: true, automated: true }; + } +}; + + +// ============================================================ +// CARD SCRIPT PARSER — Forge-style declarative text format +// ============================================================ +// Format: "A:AB$ | Cost$ | Defined$ | " +// Also parses: "T:Mode$ | Execute$ | ..." +// And: "K:" +// And: "S:Mode$ | Affected$ | AddKeyword$ " + +var CardScriptParser = { + + // Parse a Forge-style script string into an array of SpellAbility objects + parse: function(scriptText, cardName, ownerIndex, sourceCardId) { + if (!scriptText) return []; + var abilities = []; + var lines = scriptText.split('\n'); + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line || line.charAt(0) === '#') continue; // Skip comments and empty lines + + var sa = null; + if (line.indexOf('A:AB$') === 0) { + sa = this.parseActivated(line, cardName, ownerIndex, sourceCardId); + } else if (line.indexOf('T:Mode$') === 0) { + sa = this.parseTriggered(line, cardName, ownerIndex, sourceCardId); + } else if (line.indexOf('K:') === 0) { + sa = this.parseKeyword(line, cardName, ownerIndex, sourceCardId); + } else if (line.indexOf('S:Mode$') === 0) { + sa = this.parseStatic(line, cardName, ownerIndex, sourceCardId); + } else if (line.indexOf('SP:AB$') === 0) { + sa = this.parseSpell(line, cardName, ownerIndex, sourceCardId); + } + + if (sa) abilities.push(sa); + } + + return abilities; + }, + + // Parse pipe-separated parameters: "Key$ Value | Key2$ Value2" + parseParams: function(text) { + var params = {}; + var parts = text.split('|'); + for (var i = 0; i < parts.length; i++) { + var part = parts[i].trim(); + var dollarIdx = part.indexOf('$'); + if (dollarIdx !== -1) { + var key = part.substring(0, dollarIdx).trim(); + var val = part.substring(dollarIdx + 1).trim(); + params[key] = val; + } + } + return params; + }, + + // Parse cost string: "{2}{B}, {T}" -> { mana: '{2}{B}', tap: true } + parseCost: function(costStr) { + if (!costStr) return null; + var cost = { mana: '', tap: false, sacrifice: false, discard: 0, payLife: 0, custom: '' }; + if (/\{T\}/i.test(costStr)) cost.tap = true; + if (/sacrifice/i.test(costStr)) cost.sacrifice = true; + var lifeMatch = costStr.match(/pay (\d+) life/i); + if (lifeMatch) cost.payLife = parseInt(lifeMatch[1]); + var discardMatch = costStr.match(/discard (\d+)/i); + if (discardMatch) cost.discard = parseInt(discardMatch[1]); + // Extract mana symbols + var manaParts = costStr.match(/\{[WUBRGCX0-9/]+\}/gi); + if (manaParts) { + cost.mana = manaParts.filter(function(m) { return m !== '{T}'; }).join(''); + } + return cost; + }, + + // A:AB$ DealDamage | Cost$ {R} | NumDmg$ 3 | ValidTgts$ Creature,Player + parseActivated: function(line, cardName, ownerIndex, sourceCardId) { + var body = line.substring(5); // strip "A:AB$" + var params = this.parseParams(body); + var api = (body.split('|')[0] || '').trim(); + var cost = this.parseCost(params['Cost'] || ''); + var effect = this.buildEffect(api, params); + + return new SpellAbility({ + abilityType: 'activated', + api: api, + cost: cost, + effect: effect, + cardName: cardName, + ownerIndex: ownerIndex, + sourceCardId: sourceCardId, + oracleSnippet: params['SpellDescription'] || '', + restrictions: { + sorcerySpeed: params['SorcerySpeed'] === 'True', + oncePerTurn: params['OncePerTurn'] === 'True' + }, + aiHints: { + priority: params['AILogic'] || 'normal', + timing: cost && cost.tap ? 'beforeCombat' : 'mainPhase' + } + }); + }, + + // T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DrawCard | ... + parseTriggered: function(line, cardName, ownerIndex, sourceCardId) { + var body = line.substring(7); // strip "T:Mode$" + var params = this.parseParams(body); + var mode = (body.split('|')[0] || '').trim(); + var api = params['Execute'] || ''; + var effect = this.buildEffect(api, params); + + var trigger = this.buildTrigger(mode, params); + + return new SpellAbility({ + abilityType: 'triggered', + api: api, + effect: effect, + trigger: trigger, + cardName: cardName, + ownerIndex: ownerIndex, + sourceCardId: sourceCardId, + oracleSnippet: params['TriggerDescription'] || '', + aiHints: { + priority: params['AILogic'] || 'normal' + } + }); + }, + + // K:Flying or K:Deathtouch or K:Lifelink + parseKeyword: function(line, cardName, ownerIndex, sourceCardId) { + var kw = line.substring(2).trim(); + return new SpellAbility({ + abilityType: 'keyword', + api: 'Keyword', + keyword: kw.toLowerCase(), + cardName: cardName, + ownerIndex: ownerIndex, + sourceCardId: sourceCardId, + oracleSnippet: kw, + aiHints: { + priority: 'passive' + } + }); + }, + + // S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddPower$ 1 | AddToughness$ 1 + parseStatic: function(line, cardName, ownerIndex, sourceCardId) { + var body = line.substring(7); // strip "S:Mode$" + var params = this.parseParams(body); + var mode = (body.split('|')[0] || '').trim(); + + return new SpellAbility({ + abilityType: 'static', + api: 'StaticAbility', + effect: { + mode: mode, + affected: params['Affected'] || '', + addPower: parseInt(params['AddPower'] || '0'), + addToughness: parseInt(params['AddToughness'] || '0'), + addKeyword: params['AddKeyword'] || '', + addAbility: params['AddAbility'] || '' + }, + cardName: cardName, + ownerIndex: ownerIndex, + sourceCardId: sourceCardId, + oracleSnippet: params['StaticDescription'] || '', + aiHints: { + priority: 'passive' + } + }); + }, + + // SP:AB$ DealDamage | Cost$ {1}{R} | NumDmg$ 3 | ValidTgts$ Creature,Player + parseSpell: function(line, cardName, ownerIndex, sourceCardId) { + var body = line.substring(5); // strip "SP:AB" + var params = this.parseParams(body); + var api = (body.split('|')[0] || '').trim().replace(/^\$\s*/, ''); + var cost = this.parseCost(params['Cost'] || ''); + var effect = this.buildEffect(api, params); + + return new SpellAbility({ + abilityType: 'spell', + api: api, + cost: cost, + effect: effect, + cardName: cardName, + ownerIndex: ownerIndex, + sourceCardId: sourceCardId, + oracleSnippet: params['SpellDescription'] || '', + aiHints: { + priority: params['AILogic'] || 'normal' + } + }); + }, + + // Build effect object from API name + params + buildEffect: function(api, params) { + var eff = {}; + switch (api) { + case 'DealDamage': + eff.amount = parseInt(params['NumDmg'] || '1'); + eff.target = params['ValidTgts'] || 'Any'; + break; + case 'GainLife': + eff.amount = parseInt(params['LifeAmount'] || '1'); + break; + case 'LoseLife': + eff.amount = parseInt(params['LifeAmount'] || '1'); + eff.scope = params['Scope'] || 'opponent'; + break; + case 'DrawCard': + eff.amount = parseInt(params['NumCards'] || '1'); + eff.targetSelf = params['Defined'] !== 'Opponent'; + break; + case 'Discard': + eff.amount = parseInt(params['NumCards'] || '1'); + eff.scope = params['Defined'] === 'Opponent' ? 'opponent' : 'self'; + break; + case 'DestroyPermanent': + eff.target = params['ValidTgts'] || 'Creature'; + break; + case 'ExilePermanent': + eff.target = params['ValidTgts'] || 'Permanent'; + break; + case 'CreateToken': + eff.amount = parseInt(params['TokenAmount'] || '1'); + eff.tokenName = params['TokenName'] || 'Token'; + eff.power = parseInt(params['TokenPower'] || '1'); + eff.toughness = parseInt(params['TokenToughness'] || '1'); + eff.tokenType = params['TokenType'] || 'Creature Token'; + eff.keywords = (params['TokenKeywords'] || '').split(',').map(function(k) { return k.trim(); }).filter(Boolean); + break; + case 'ManaAdd': + eff.colors = (params['Produced'] || 'C').split(',').map(function(c) { return c.trim(); }); + break; + case 'PumpCreature': + eff.power = parseInt(params['NumAtt'] || '0'); + eff.toughness = parseInt(params['NumDef'] || '0'); + break; + case 'CounterSpell': + eff.target = 'spell'; + break; + case 'SearchLibrary': + eff.searchType = params['SearchType'] || 'Any'; + break; + case 'DestroyAll': + eff.filter = (params['ValidCards'] || 'creature').toLowerCase(); + break; + case 'ReturnToHand': + eff.target = params['ValidTgts'] || 'Permanent'; + break; + case 'TapPermanent': + eff.untap = params['Untap'] === 'True'; + break; + case 'Mill': + eff.amount = parseInt(params['NumCards'] || '1'); + break; + case 'Scry': + eff.amount = parseInt(params['ScryNum'] || '1'); + break; + case 'GainKeyword': + eff.keyword = params['KW'] || ''; + break; + case 'PutCounters': + eff.counterType = params['CounterType'] || '+1/+1'; + eff.amount = parseInt(params['CounterNum'] || '1'); + break; + case 'Ramp': + eff.amount = parseInt(params['Amount'] || '1'); + break; + } + return eff; + }, + + // Build trigger descriptor from mode + params + buildTrigger: function(mode, params) { + var trigger = { event: '', filter: 'any' }; + switch (mode) { + case 'ChangesZone': + if (params['Destination'] === 'Battlefield') trigger.event = 'etb'; + else if (params['Origin'] === 'Battlefield') trigger.event = 'ltb'; + break; + case 'ChangesZoneAll': + trigger.event = 'etb'; + trigger.filter = params['ValidCards'] || 'Creature'; + break; + case 'Attacks': + trigger.event = 'attack'; + break; + case 'Blocks': + trigger.event = 'block'; + break; + case 'DamageDone': + trigger.event = 'damage'; + break; + case 'SpellCast': + trigger.event = 'cast'; + trigger.filter = params['ValidActivatingPlayer'] || 'You'; + break; + case 'Phase': + if (/upkeep/i.test(params['Phase'] || '')) trigger.event = 'upkeep'; + else if (/end/i.test(params['Phase'] || '')) trigger.event = 'end_step'; + else if (/combat/i.test(params['Phase'] || '')) trigger.event = 'combat_begin'; + break; + case 'LifeGained': + trigger.event = 'life_gain'; + break; + case 'LifeLost': + trigger.event = 'life_loss'; + break; + case 'Drawn': + trigger.event = 'draw'; + break; + case 'Discarded': + trigger.event = 'discard'; + break; + case 'Landfall': + trigger.event = 'landfall'; + break; + default: + trigger.event = mode.toLowerCase(); + } + return trigger; + } +}; + + +// ============================================================ +// CARD SCRIPT REGISTRY — Common card patterns +// ============================================================ +// Maps oracle text patterns to Forge-style CardScript definitions. +// When a card's oracle text matches, the corresponding SpellAbilities +// are automatically generated, enabling the AI to understand and +// potentially automate common card effects. + +var CardScriptRegistry = { + + // Each entry: { pattern: RegExp, script: function(match, cardName) -> scriptString } + patterns: [], + + register: function(pattern, scriptFn) { + this.patterns.push({ pattern: pattern, scriptFn: scriptFn }); + }, + + // Try to match a card's oracle text and generate CardScript + match: function(oracleText, typeLine, cardName) { + if (!oracleText) return ''; + var scripts = []; + var text = oracleText; + + for (var i = 0; i < this.patterns.length; i++) { + var entry = this.patterns[i]; + var m = text.match(entry.pattern); + if (m) { + var script = entry.scriptFn(m, cardName); + if (script) scripts.push(script); + } + } + + // Also generate keyword scripts + var kwText = text.toLowerCase(); + var commonKeywords = ['flying', 'deathtouch', 'lifelink', 'first strike', 'double strike', + 'trample', 'haste', 'vigilance', 'menace', 'hexproof', 'indestructible', + 'reach', 'flash', 'infect', 'toxic', 'ward', 'defender', 'shroud', + 'prowess', 'cascade', 'undying', 'persist', 'annihilator']; + for (var k = 0; k < commonKeywords.length; k++) { + var kw = commonKeywords[k]; + var kwRe = new RegExp('(?:^|[,;.\\n\\s])' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:\\b|\\s|,|\\.|$)', 'i'); + if (kwRe.test(kwText) || kwRe.test(',' + kwText)) { + scripts.push('K:' + kw.charAt(0).toUpperCase() + kw.slice(1)); + } + } + + return scripts.join('\n'); + } +}; + +// --- Register common Oracle text patterns --- + +// ETB: "When [cardname/this creature] enters the battlefield, [effect]" +CardScriptRegistry.register( + /when (?:this creature|~|it) enters(?: the battlefield)?,?\s*(.+?)(?:\.|$)/i, + function(m, cardName) { + var effect = m[1].toLowerCase(); + // ETB draw + if (/draw (\w+|\d+) cards?/.test(effect)) { + var num = effect.match(/draw (\w+|\d+)/); + var amount = num ? (parseInt(num[1]) || ({'a':1,'one':1,'two':2,'three':3,'four':4,'five':5}[num[1]] || 1)) : 1; + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DrawCard | NumCards$ ' + amount + ' | TriggerDescription$ ETB: Draw ' + amount; + } + // ETB gain life + if (/gain (\d+) life/.test(effect)) { + var life = effect.match(/gain (\d+)/); + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ GainLife | LifeAmount$ ' + (life ? life[1] : '1') + ' | TriggerDescription$ ETB: Gain life'; + } + // ETB deal damage + if (/deals? (\d+) damage/.test(effect)) { + var dmg = effect.match(/deals? (\d+)/); + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DealDamage | NumDmg$ ' + (dmg ? dmg[1] : '1') + ' | TriggerDescription$ ETB: Deal damage'; + } + // ETB create token + if (/create (\w+|\d+)\s+(\d+)\/(\d+)/.test(effect)) { + var tok = effect.match(/create (\w+|\d+)\s+(\d+)\/(\d+)\s*([\w\s]*?)(?:creature)?\s*token/i); + if (tok) { + var cnt = parseInt(tok[1]) || ({'a':1,'one':1,'two':2,'three':3}[tok[1]] || 1); + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ CreateToken | TokenAmount$ ' + cnt + + ' | TokenPower$ ' + tok[2] + ' | TokenToughness$ ' + tok[3] + ' | TriggerDescription$ ETB: Create tokens'; + } + } + // ETB destroy + if (/destroy target/.test(effect)) { + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DestroyPermanent | TriggerDescription$ ETB: Destroy'; + } + // ETB exile + if (/exile target/.test(effect)) { + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ ExilePermanent | TriggerDescription$ ETB: Exile'; + } + // ETB +1/+1 counters + if (/(\d+) \+1\/\+1 counter/.test(effect)) { + var cnt = effect.match(/(\d+) \+1\/\+1/); + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ PutCounters | CounterType$ +1/+1 | CounterNum$ ' + (cnt ? cnt[1] : '1') + ' | TriggerDescription$ ETB: +1/+1 counters'; + } + // Generic ETB (not automated) + return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ Generic | TriggerDescription$ ETB: ' + effect.substring(0, 50); + } +); + +// Activated: "{cost}: Draw a card" +CardScriptRegistry.register( + /(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*draw (\w+|\d+) cards?/i, + function(m) { + var num = parseInt(m[2]) || ({'a':1,'one':1,'two':2,'three':3}[m[2].toLowerCase()] || 1); + return 'A:AB$ DrawCard | Cost$ ' + m[1] + ' | NumCards$ ' + num + ' | SpellDescription$ Draw ' + num; + } +); + +// Activated: "{cost}: Deal N damage" +CardScriptRegistry.register( + /(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*(?:it |~\s+)?deals? (\d+) damage/i, + function(m) { + return 'A:AB$ DealDamage | Cost$ ' + m[1] + ' | NumDmg$ ' + m[2] + ' | ValidTgts$ Any | SpellDescription$ Deal ' + m[2] + ' damage'; + } +); + +// Activated: "{cost}: Gain N life" +CardScriptRegistry.register( + /(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*(?:you )?gain (\d+) life/i, + function(m) { + return 'A:AB$ GainLife | Cost$ ' + m[1] + ' | LifeAmount$ ' + m[2] + ' | SpellDescription$ Gain ' + m[2] + ' life'; + } +); + +// Mana ability: "{T}: Add {X}" +CardScriptRegistry.register( + /\{T\}\s*:\s*add\s+(\{[WUBRGC]\}(?:\s*(?:or\s*)?\{[WUBRGC]\})*)/i, + function(m) { + var colors = []; + var colorMatches = m[1].match(/\{([WUBRGC])\}/gi); + if (colorMatches) { + colors = colorMatches.map(function(c) { return c.replace(/[{}]/g, ''); }); + } + return 'A:AB$ ManaAdd | Cost$ {T} | Produced$ ' + colors.join(',') + ' | SpellDescription$ Tap for mana'; + } +); + +// Sorcery/Instant: "Destroy target creature" +CardScriptRegistry.register( + /destroy (?:target|all) (creature|permanent|artifact|enchantment|planeswalker)s?/i, + function(m) { + if (/all/i.test(m[0])) { + return 'SP:AB$ DestroyAll | ValidCards$ ' + m[1] + ' | SpellDescription$ Destroy all ' + m[1] + 's'; + } + return 'SP:AB$ DestroyPermanent | ValidTgts$ ' + m[1].charAt(0).toUpperCase() + m[1].slice(1) + ' | SpellDescription$ Destroy target ' + m[1]; + } +); + +// Sorcery/Instant: "Exile target creature/permanent" +CardScriptRegistry.register( + /exile target (creature|permanent|artifact|enchantment|nonland permanent)/i, + function(m) { + return 'SP:AB$ ExilePermanent | ValidTgts$ ' + m[1].charAt(0).toUpperCase() + m[1].slice(1) + ' | SpellDescription$ Exile target ' + m[1]; + } +); + +// Sorcery/Instant: "Counter target spell" +CardScriptRegistry.register( + /counter target (\w+ )?spell/i, + function(m) { + return 'SP:AB$ CounterSpell | SpellDescription$ Counter target spell'; + } +); + +// Draw: "Draw N cards" +CardScriptRegistry.register( + /^draw (\w+|\d+) cards?/im, + function(m) { + var num = parseInt(m[1]) || ({'a':1,'one':1,'two':2,'three':3,'four':4,'five':5}[m[1].toLowerCase()] || 1); + return 'SP:AB$ DrawCard | NumCards$ ' + num + ' | SpellDescription$ Draw ' + num; + } +); + +// Ramp: "Search your library for a basic land" +CardScriptRegistry.register( + /search your library for (?:up to )?(\w+|\d+)? ?(?:basic )?land cards?/i, + function(m) { + var num = m[1] ? (parseInt(m[1]) || ({'a':1,'one':1,'two':2,'three':3}[m[1].toLowerCase()] || 1)) : 1; + return 'SP:AB$ Ramp | Amount$ ' + num + ' | SpellDescription$ Search for ' + num + ' land(s)'; + } +); + +// Mill: "Mill N cards" or "put the top N cards of your library into your graveyard" +CardScriptRegistry.register( + /(?:mill (\d+)|put the top (\d+) cards? of (?:your|their|target player's) library into (?:your|their|that player's) graveyard)/i, + function(m) { + var num = parseInt(m[1] || m[2] || '1'); + return 'SP:AB$ Mill | NumCards$ ' + num + ' | SpellDescription$ Mill ' + num; + } +); + +// Scry: "Scry N" +CardScriptRegistry.register( + /scry (\d+)/i, + function(m) { + return 'SP:AB$ Scry | ScryNum$ ' + m[1] + ' | SpellDescription$ Scry ' + m[1]; + } +); + +// Dies trigger: "When [name/this creature] dies, [effect]" +CardScriptRegistry.register( + /when (?:this creature|~|it) dies,?\s*(.+?)(?:\.|$)/i, + function(m, cardName) { + var effect = m[1].toLowerCase(); + if (/draw (\w+|\d+) cards?/.test(effect)) { + var num = effect.match(/draw (\w+|\d+)/); + var amount = num ? (parseInt(num[1]) || 1) : 1; + return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ DrawCard | NumCards$ ' + amount + ' | TriggerDescription$ Dies: Draw'; + } + if (/create/.test(effect)) { + return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ CreateToken | TriggerDescription$ Dies: Create token'; + } + return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ Generic | TriggerDescription$ Dies: ' + effect.substring(0, 50); + } +); + +// Attack trigger: "Whenever [name/this creature] attacks, [effect]" +CardScriptRegistry.register( + /whenever (?:this creature|~|it) attacks,?\s*(.+?)(?:\.|$)/i, + function(m, cardName) { + var effect = m[1].toLowerCase(); + if (/draw/.test(effect)) { + return 'T:Mode$ Attacks | Execute$ DrawCard | NumCards$ 1 | TriggerDescription$ Attack: Draw'; + } + if (/deal/.test(effect)) { + var dmg = effect.match(/deals? (\d+)/); + return 'T:Mode$ Attacks | Execute$ DealDamage | NumDmg$ ' + (dmg ? dmg[1] : '1') + ' | TriggerDescription$ Attack: Damage'; + } + if (/create/.test(effect)) { + return 'T:Mode$ Attacks | Execute$ CreateToken | TriggerDescription$ Attack: Create token'; + } + return 'T:Mode$ Attacks | Execute$ Generic | TriggerDescription$ Attack: ' + effect.substring(0, 50); + } +); + +// Upkeep trigger: "At the beginning of your upkeep, [effect]" +CardScriptRegistry.register( + /at the beginning of (?:your |each (?:player's )?)?upkeep,?\s*(.+?)(?:\.|$)/i, + function(m, cardName) { + var effect = m[1].toLowerCase(); + if (/draw/.test(effect)) { + return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ DrawCard | NumCards$ 1 | TriggerDescription$ Upkeep: Draw'; + } + if (/gain (\d+) life/.test(effect)) { + var life = effect.match(/gain (\d+)/); + return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ GainLife | LifeAmount$ ' + (life ? life[1] : '1') + ' | TriggerDescription$ Upkeep: Gain life'; + } + if (/lose (\d+) life/.test(effect)) { + var life2 = effect.match(/lose (\d+)/); + return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ LoseLife | LifeAmount$ ' + (life2 ? life2[1] : '1') + ' | TriggerDescription$ Upkeep: Lose life'; + } + return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ Generic | TriggerDescription$ Upkeep: ' + effect.substring(0, 50); + } +); + +// Static: "Other creatures you control get +N/+N" +CardScriptRegistry.register( + /(?:other )?creatures you control (?:get|have) \+(\d+)\/\+(\d+)/i, + function(m) { + return 'S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddPower$ ' + m[1] + ' | AddToughness$ ' + m[2] + ' | StaticDescription$ Anthem +' + m[1] + '/+' + m[2]; + } +); + +// Static: "Creatures you control have [keyword]" +CardScriptRegistry.register( + /creatures you control (?:have|gain) (\w[\w\s]*?)(?:\.|,|$)/i, + function(m) { + return 'S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddKeyword$ ' + m[1].trim() + ' | StaticDescription$ Grant ' + m[1].trim(); + } +); + + +// ============================================================ +// CARD SCRIPT MANAGER — Bridges CardScript with game engine +// ============================================================ + +var CardScriptManager = { + + // Cache: cardName -> SpellAbility[] + _cache: {}, + + // Get SpellAbility objects for a card (auto-generates from oracle text if no manual script) + getAbilities: function(cardData) { + var key = cardData.name; + if (this._cache[key]) return this._cache[key]; + + // First try the registry (auto-match oracle text) + var script = CardScriptRegistry.match( + cardData.oracleText || '', + cardData.typeLine || '', + cardData.name || '' + ); + + var abilities = []; + if (script) { + abilities = CardScriptParser.parse( + script, + cardData.name, + cardData.ownerIndex !== undefined ? cardData.ownerIndex : (cardData.ownerId || 0), + cardData.id + ); + } + + this._cache[key] = abilities; + return abilities; + }, + + // Clear cache (call when cards change) + clearCache: function() { + this._cache = {}; + }, + + // Get just the keyword abilities + getKeywords: function(cardData) { + return this.getAbilities(cardData).filter(function(sa) { + return sa.abilityType === 'keyword'; + }).map(function(sa) { + return sa.keyword; + }); + }, + + // Get triggered abilities matching an event type + getTriggersForEvent: function(cardData, eventType) { + return this.getAbilities(cardData).filter(function(sa) { + return sa.abilityType === 'triggered' && sa.trigger && sa.trigger.event === eventType; + }); + }, + + // Get all activated abilities + getActivatedAbilities: function(cardData) { + return this.getAbilities(cardData).filter(function(sa) { + return sa.abilityType === 'activated'; + }); + }, + + // Get the spell effect (for instants/sorceries) + getSpellAbility: function(cardData) { + var spells = this.getAbilities(cardData).filter(function(sa) { + return sa.abilityType === 'spell'; + }); + return spells.length > 0 ? spells[0] : null; + }, + + // Get static abilities + getStaticAbilities: function(cardData) { + return this.getAbilities(cardData).filter(function(sa) { + return sa.abilityType === 'static'; + }); + }, + + // Count total abilities (for AI scoring) + countAbilities: function(cardData) { + var abs = this.getAbilities(cardData); + return { + total: abs.length, + keywords: abs.filter(function(a) { return a.abilityType === 'keyword'; }).length, + activated: abs.filter(function(a) { return a.abilityType === 'activated'; }).length, + triggered: abs.filter(function(a) { return a.abilityType === 'triggered'; }).length, + static: abs.filter(function(a) { return a.abilityType === 'static'; }).length, + mana: abs.filter(function(a) { return a.api === 'ManaAdd'; }).length + }; + }, + + // AI: Score a card's abilities for strategic evaluation + scoreAbilities: function(cardData, weights) { + var w = weights || (typeof aiWeights !== 'undefined' ? aiWeights : {}); + var abs = this.getAbilities(cardData); + var score = 0; + + for (var i = 0; i < abs.length; i++) { + var sa = abs[i]; + switch (sa.abilityType) { + case 'keyword': + var kwMap = { + 'flying': w.kw_flying || 2.5, 'haste': w.kw_haste || 2.5, + 'deathtouch': w.kw_deathtouch || 2.5, 'double strike': w.kw_doubleStrike || 3, + 'first strike': w.kw_firstStrike || 1.5, 'lifelink': w.kw_lifelink || 1.5, + 'trample': w.kw_trample || 1.5, 'hexproof': w.kw_hexproof || 2, + 'indestructible': w.kw_indestructible || 3, 'menace': w.kw_menace || 1.5, + 'vigilance': w.kw_vigilance || 1, 'reach': w.kw_reach || 0.5, + 'infect': w.kw_infect || 3, 'flash': w.kw_flash || 1, + 'defender': w.kw_defender || -2, 'cascade': w.kw_cascade || 3, + }; + score += kwMap[sa.keyword] || (w.kw_default || 0.5); + break; + case 'triggered': + if (sa.trigger) { + if (sa.trigger.event === 'etb') score += w.trig_etb || 2; + else if (sa.trigger.event === 'attack') score += w.trig_attack || 1.5; + else if (sa.trigger.event === 'dies') score += w.trig_dies || 1; + else score += w.trig_other || 0.5; + } + // Bonus for automated effects + if (sa.api && CardScriptResolvers[sa.api]) score += 0.5; + break; + case 'activated': + score += w.activated_bonus || 1; + if (sa.api === 'DrawCard') score += 1.5; // Card advantage is premium + if (sa.api === 'DealDamage') score += 1; + break; + case 'static': + score += 2; // Static abilities are always active + if (sa.effect.addPower > 0 || sa.effect.addToughness > 0) score += 1; + if (sa.effect.addKeyword) score += 1; + break; + case 'spell': + if (sa.api === 'DestroyPermanent' || sa.api === 'ExilePermanent') score += w.spell_destroy || 5; + if (sa.api === 'DrawCard') score += w.spell_draw || 3; + if (sa.api === 'CounterSpell') score += w.spell_counter || 4; + if (sa.api === 'DestroyAll') score += 7; // Board wipes are high value + if (sa.api === 'Ramp') score += w.spell_ramp || 6; + if (sa.api === 'CreateToken') score += 2; + break; + } + } + + return score; + } +}; + + +// ============================================================ +// AI GAME STATE EVALUATOR — Forge-Inspired Board Analysis +// ============================================================ + +var AIBoardAnalyzer = { + + // Comprehensive board state evaluation + // Returns a numeric score: positive = favorable, negative = unfavorable + evaluateBoardState: function(playerIdx) { + if (!gameState) return 0; + var w = (typeof aiWeights !== 'undefined') ? aiWeights : {}; + var me = gameState.players[playerIdx]; + if (!me || me.eliminated) return -1000; + + var score = 0; + + // 1. Material advantage (card count on board) + var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; }); + var enemyCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId !== playerIdx; }); + var myCreatures = myCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); }); + var enemyCreatures = enemyCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); }); + + score += myCreatures.length * 3; + score -= enemyCreatures.length * 2; + score += (myCards.length - myCreatures.length) * 1.5; // non-creature permanents + + // 2. Power projection (total power on board) + var myPower = 0, myToughness = 0; + myCreatures.forEach(function(c) { + var p = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0); + var t = (typeof getCreatureToughness === 'function') ? getCreatureToughness(c) : (parseInt(c.toughness) || 0); + myPower += p; + myToughness += t; + }); + var enemyPower = 0, enemyToughness = 0; + enemyCreatures.forEach(function(c) { + var p = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0); + var t = (typeof getCreatureToughness === 'function') ? getCreatureToughness(c) : (parseInt(c.toughness) || 0); + enemyPower += p; + enemyToughness += t; + }); + + score += (myPower - enemyPower) * 1.5; + score += (myToughness - enemyToughness) * 0.5; + + // 3. Evasion advantage + var myFlyers = myCreatures.filter(function(c) { return typeof hasKeyword === 'function' && hasKeyword(c, 'flying'); }); + var enemyFlyers = enemyCreatures.filter(function(c) { return typeof hasKeyword === 'function' && hasKeyword(c, 'flying'); }); + score += myFlyers.length * 2; + score -= enemyFlyers.length * 1.5; + + // 4. Card advantage (hand size) + var myHand = me.zones.hand ? me.zones.hand.length : 0; + var avgEnemyHand = 0; + var enemyCount = 0; + gameState.players.forEach(function(p, i) { + if (i !== playerIdx && !p.eliminated) { + avgEnemyHand += (p.zones.hand ? p.zones.hand.length : 0); + enemyCount++; + } + }); + avgEnemyHand = enemyCount > 0 ? avgEnemyHand / enemyCount : 0; + score += (myHand - avgEnemyHand) * 2; + + // 5. Life advantage + var startLife = gameState.startingLife || 40; + score += (me.life / startLife) * 5; + gameState.players.forEach(function(p, i) { + if (i !== playerIdx && !p.eliminated) { + score -= (p.life / startLife) * 2; + } + }); + + // 6. Mana advantage (land count) + var myLands = myCards.filter(function(c) { return c.typeLine && /land/i.test(c.typeLine); }); + var maxEnemyLands = 0; + gameState.players.forEach(function(p, i) { + if (i !== playerIdx && !p.eliminated) { + var their = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === i && c.typeLine && /land/i.test(c.typeLine); + }); + if (their.length > maxEnemyLands) maxEnemyLands = their.length; + } + }); + score += (myLands.length - maxEnemyLands) * 1; + + // 7. Ability quality bonus (using CardScript system) + myCards.forEach(function(c) { + score += CardScriptManager.scoreAbilities(c) * 0.3; + }); + + // 8. Commander threat + var cmdOnField = myCards.some(function(c) { return c.isCommander; }); + if (cmdOnField) score += 4; + + return Math.round(score * 10) / 10; + }, + + // Detect synergies between cards on the battlefield + detectSynergies: function(playerIdx) { + if (!gameState) return []; + var synergies = []; + var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; }); + + // Anthem detection: static abilities that buff other creatures + var anthems = []; + myCards.forEach(function(c) { + var statics = CardScriptManager.getStaticAbilities(c); + statics.forEach(function(sa) { + if (sa.effect.addPower > 0 || sa.effect.addToughness > 0 || sa.effect.addKeyword) { + anthems.push({ source: c, ability: sa }); + } + }); + }); + if (anthems.length > 0) { + var creatureCount = myCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); }).length; + synergies.push({ + type: 'anthem', + value: anthems.length * creatureCount * 2, + description: anthems.length + ' anthem effect' + (anthems.length !== 1 ? 's' : '') + ' buffing ' + creatureCount + ' creatures' + }); + } + + // Token synergy: token generators + anthem/lords + var hasTokenGen = myCards.some(function(c) { + var triggers = CardScriptManager.getTriggersForEvent(c, 'etb'); + return triggers.some(function(t) { return t.api === 'CreateToken'; }); + }); + if (hasTokenGen && anthems.length > 0) { + synergies.push({ + type: 'token_anthem', + value: 5, + description: 'Token generation + anthem synergy' + }); + } + + // Sacrifice synergy: dies triggers + sacrifice outlets + var hasDiesTrigger = myCards.some(function(c) { + return CardScriptManager.getTriggersForEvent(c, 'dies').length > 0 || + (c.triggers && c.triggers.some(function(t) { return t.type === 'dies'; })); + }); + var hasSacOutlet = myCards.some(function(c) { + var activated = CardScriptManager.getActivatedAbilities(c); + return activated.some(function(a) { return a.cost && a.cost.sacrifice; }); + }); + if (hasDiesTrigger && hasSacOutlet) { + synergies.push({ + type: 'sacrifice', + value: 4, + description: 'Dies triggers + sacrifice outlet synergy' + }); + } + + // Card draw engine: multiple draw triggers + var drawEngines = myCards.filter(function(c) { + var triggers = CardScriptManager.getTriggersForEvent(c, 'draw'); + return triggers.length > 0; + }); + if (drawEngines.length >= 2) { + synergies.push({ + type: 'draw_engine', + value: 6, + description: 'Card draw engine: ' + drawEngines.length + ' draw triggers' + }); + } + + // Evasion suite: flying + pump/buff + var flyerCount = myCards.filter(function(c) { + return typeof hasKeyword === 'function' && hasKeyword(c, 'flying'); + }).length; + if (flyerCount >= 3) { + synergies.push({ + type: 'evasion_swarm', + value: flyerCount * 1.5, + description: 'Evasion swarm: ' + flyerCount + ' flyers' + }); + } + + // Lifegain synergy: life gain triggers + life gain sources + var hasLifegainTrigger = myCards.some(function(c) { + return CardScriptManager.getTriggersForEvent(c, 'life_gain').length > 0; + }); + var hasLifelink = myCards.some(function(c) { + return typeof hasKeyword === 'function' && hasKeyword(c, 'lifelink'); + }); + if (hasLifegainTrigger && hasLifelink) { + synergies.push({ + type: 'lifegain', + value: 3, + description: 'Lifegain synergy: life gain triggers + lifelink' + }); + } + + return synergies; + }, + + // Detect the archetype a player's board most closely represents + detectArchetype: function(playerIdx) { + if (!gameState) return { archetype: 'unknown', confidence: 0 }; + var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; }); + var player = gameState.players[playerIdx]; + if (!player) return { archetype: 'unknown', confidence: 0 }; + + var scores = { + aggro: 0, + midrange: 0, + control: 0, + combo: 0, + ramp: 0, + tokens: 0 + }; + + var creatureCount = 0; + var lowCmcCreatures = 0; + var removalCount = 0; + var drawCount = 0; + var landCount = 0; + var tokenCount = 0; + var totalPower = 0; + + myCards.forEach(function(c) { + var cmc = c.cmc || 0; + var isCreature = c.typeLine && /creature/i.test(c.typeLine); + var isLand = c.typeLine && /land/i.test(c.typeLine); + + if (isCreature) { + creatureCount++; + if (cmc <= 3) lowCmcCreatures++; + var pow = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0); + totalPower += pow; + if (typeof hasKeyword === 'function') { + if (hasKeyword(c, 'haste')) scores.aggro += 2; + if (hasKeyword(c, 'flying') || hasKeyword(c, 'menace')) scores.aggro += 1; + } + } + if (isLand) landCount++; + if (c.isToken) tokenCount++; + + // Check card oracle for role + var text = (c.oracleText || '').toLowerCase(); + if (/destroy|exile/.test(text) && /target/.test(text)) removalCount++; + if (/draw/.test(text)) drawCount++; + if (/counter target spell/.test(text)) scores.control += 3; + if (/search your library for.*land/.test(text)) scores.ramp += 3; + if (/create.*token/.test(text)) scores.tokens += 2; + }); + + // Hand analysis + var hand = player.zones.hand || []; + hand.forEach(function(c) { + var text = (c.oracleText || '').toLowerCase(); + if (/counter target spell/.test(text)) scores.control += 2; + if (/destroy|exile/.test(text)) removalCount++; + }); + + // Aggro: many cheap creatures, high total power, few lands + scores.aggro += lowCmcCreatures * 2; + scores.aggro += Math.min(totalPower / 3, 8); + if (creatureCount >= 4) scores.aggro += 3; + + // Control: removal, draw, few creatures + scores.control += removalCount * 2; + scores.control += drawCount * 1.5; + if (creatureCount <= 2) scores.control += 3; + + // Midrange: balanced mix + if (creatureCount >= 2 && creatureCount <= 4 && removalCount >= 1) scores.midrange += 4; + if (drawCount >= 1 && totalPower >= 6) scores.midrange += 3; + + // Ramp: many lands + if (landCount >= 6) scores.ramp += 3; + if (landCount >= 8) scores.ramp += 3; + + // Tokens: many tokens + scores.tokens += tokenCount * 2; + + // Combo: synergy-based (detected separately) + var synergies = this.detectSynergies(playerIdx); + var synergyValue = 0; + synergies.forEach(function(s) { synergyValue += s.value; }); + if (synergyValue >= 8) scores.combo += 5; + if (synergyValue >= 12) scores.combo += 5; + + // Find highest score + var bestArch = 'midrange'; + var bestScore = 0; + for (var arch in scores) { + if (scores[arch] > bestScore) { + bestScore = scores[arch]; + bestArch = arch; + } + } + + return { + archetype: bestArch, + confidence: Math.min(Math.round(bestScore / 15 * 100), 100), + scores: scores, + synergies: synergies + }; + }, + + // Get strategic recommendations for the AI + getStrategicAdvice: function(playerIdx) { + if (!gameState) return []; + var advice = []; + var boardScore = this.evaluateBoardState(playerIdx); + var archetype = this.detectArchetype(playerIdx); + var synergies = archetype.synergies || []; + var player = gameState.players[playerIdx]; + if (!player) return advice; + + // Board position advice + if (boardScore > 15) { + advice.push({ type: 'position', priority: 'high', text: 'Strong board position. Press advantage, protect key pieces.' }); + } else if (boardScore < -10) { + advice.push({ type: 'position', priority: 'high', text: 'Behind on board. Prioritize removal and board wipes.' }); + } else { + advice.push({ type: 'position', priority: 'medium', text: 'Board is roughly even. Play for incremental advantage.' }); + } + + // Archetype-specific advice + switch (archetype.archetype) { + case 'aggro': + advice.push({ type: 'strategy', priority: 'high', text: 'Aggro: Maximize damage, attack every turn, finish off weak players.' }); + break; + case 'control': + advice.push({ type: 'strategy', priority: 'high', text: 'Control: Hold removal for biggest threats, draw cards, avoid overextending.' }); + break; + case 'ramp': + advice.push({ type: 'strategy', priority: 'high', text: 'Ramp: Prioritize land drops and mana rocks, play expensive threats ASAP.' }); + break; + case 'tokens': + advice.push({ type: 'strategy', priority: 'high', text: 'Tokens: Protect anthem effects, go wide, beware board wipes.' }); + break; + case 'combo': + advice.push({ type: 'strategy', priority: 'high', text: 'Combo potential detected. Protect key synergy pieces.' }); + break; + } + + // Synergy advice + if (synergies.length > 0) { + var synText = synergies.map(function(s) { return s.description; }).join('; '); + advice.push({ type: 'synergy', priority: 'medium', text: 'Active synergies: ' + synText }); + } + + // Threat assessment + if (typeof aiEvaluatePlayerThreats === 'function') { + var threats = aiEvaluatePlayerThreats(playerIdx); + if (threats.length > 0 && threats[0].threatScore > 20) { + advice.push({ type: 'threat', priority: 'high', text: 'High threat: ' + threats[0].name + ' (score: ' + Math.round(threats[0].threatScore) + '). Focus removal/attacks there.' }); + } + } + + // Life total advice + if (player.life <= 15) { + advice.push({ type: 'survival', priority: 'high', text: 'Low life total (' + player.life + '). Prioritize defense and lifegain.' }); + } + + return advice; + } +}; + + +// ============================================================ +// INTEGRATION: Connect CardScript system to AI scoring +// ============================================================ + +// Enhanced AI card scorer that uses CardScript system +function aiScoreCardEnhanced(card, difficulty, w) { + w = w || (typeof aiWeights !== 'undefined' ? aiWeights : {}); + + // Start with the base score from the original system + var score = (typeof aiScoreCard === 'function') ? aiScoreCard(card, difficulty, w) : 10; + + // Add CardScript-based scoring + var csScore = CardScriptManager.scoreAbilities(card, w); + score += csScore * 0.25; // Weighted blend + + // Synergy bonus: if this card synergizes with what's already on board + if (gameState) { + var playerIdx = card.ownerId !== undefined ? card.ownerId : gameState.currentPlayerIndex; + var currentSynergies = AIBoardAnalyzer.detectSynergies(playerIdx); + var currentSynergyValue = 0; + currentSynergies.forEach(function(s) { currentSynergyValue += s.value; }); + + // Check if playing this card would improve synergies + var cardAbilities = CardScriptManager.getAbilities(card); + var hasAnthem = cardAbilities.some(function(a) { return a.abilityType === 'static' && (a.effect.addPower > 0 || a.effect.addKeyword); }); + var hasTokenGen = cardAbilities.some(function(a) { return a.api === 'CreateToken'; }); + var hasDiesTrigger = cardAbilities.some(function(a) { return a.abilityType === 'triggered' && a.trigger && a.trigger.event === 'dies'; }); + + if (hasAnthem) { + var creaturesOnBoard = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && c.typeLine && /creature/i.test(c.typeLine); + }).length; + score += creaturesOnBoard * 0.5; // Anthem is worth more with more creatures + } + if (hasTokenGen && currentSynergies.some(function(s) { return s.type === 'anthem'; })) { + score += 3; // Token gen + existing anthem = synergy + } + if (hasDiesTrigger) { + var hasSacOnBoard = gameState.battlefieldCards.some(function(c) { + if (c.ownerId !== playerIdx) return false; + var activated = CardScriptManager.getActivatedAbilities(c); + return activated.some(function(a) { return a.cost && a.cost.sacrifice; }); + }); + if (hasSacOnBoard) score += 3; // Dies trigger + sac outlet = synergy + } + } + + // Difficulty adjustment + if (difficulty === 'hard') { + // Hard mode: additional strategic adjustments + if (card.isRamp && gameState && gameState.turnNumber <= 4) score += 3; // Early game ramp premium + if (card.isRemoval && gameState) { + var threats = (typeof aiEvaluateBoardThreats === 'function') ? aiEvaluateBoardThreats(gameState.currentPlayerIndex) : []; + if (threats.length > 0 && threats[0].score > 8) score += 4; // Removal premium when threats exist + } + } + + return score; +} + + +// ============================================================ +// AI WEIGHT VECTOR — Tunable parameters for self-play training +// ============================================================ + +var AI_DEFAULT_WEIGHTS = { + // --- Card Scoring (aiScoreCard) --- + card_baseCmcPenalty: 1, // multiplier on cmc subtracted from base 10 + card_ptBonus: 1, // multiplier on power+toughness + // Keyword weights for creatures + kw_flying: 2.5, + kw_haste: 2.5, + kw_deathtouch: 2.5, + kw_doubleStrike: 3, + kw_firstStrike: 1.5, + kw_lifelink: 1.5, + kw_trample: 1.5, + kw_hexproof: 2, + kw_indestructible: 3, + kw_menace: 1.5, + kw_vigilance: 1, + kw_reach: 0.5, + kw_infect: 3, + kw_toxic: 2, + kw_annihilator: 4, + kw_cascade: 3, + kw_undying: 2, + kw_persist: 2, + kw_prowess: 1.5, + kw_ward: 1.5, + kw_flash: 1, + kw_defender: -2, + kw_default: 0.5, + // Triggered ability bonuses + trig_etb: 2, + trig_attack: 1.5, + trig_dies: 1, + trig_other: 0.5, + // Activated ability bonus per ability + activated_bonus: 1, + // Non-creature spell weights + spell_destroy: 5, + spell_draw: 3, + spell_counter: 4, + spell_graveyard: 2, + spell_search: 2, + spell_ramp: 6, + spell_equipment: 4, + spell_enchantment: 3, + spell_flash: 2, + spell_cascade: 3, + + // --- Board Threat Eval (aiEvaluateBoardThreats) --- + bt_flying: 3, + bt_deathtouch: 4, + bt_lifelink: 2, + bt_firstStrike: 2, + bt_trample: 2, + bt_hexproof: 3, + bt_indestructible: 5, + bt_commander: 4, + + // --- Player Threat Eval (aiEvaluatePlayerThreats) --- + pt_creatureWeight: 3, + pt_nonCreatureWeight: 1.5, + pt_flyingBonus: 1, + pt_trampleBonus: 1, + pt_indestructBonus: 3, + pt_hexproofBonus: 2, + pt_cardAdvMultiplier: 2, + pt_lifeDeltaScale: 8, + pt_cmdOnField: 6, + pt_cmdDmgHigh: 5, + pt_cmdDmgMid: 3, + pt_momentumCap: 5, + + // --- Attack Evaluation (aiEvaluateAttack) --- + // (boolean logic, not easily weighted - kept for completeness) + atk_riskTradeThreshold: 0, // willingness to trade (0=avoid, positive=accept) + + // --- Removal Targeting (aiFindRemovalTarget) --- + rem_leaderBoost: 5, + rem_commanderBoost: 6, + rem_lifeThreatFactor: 0.3, // fraction of life that triggers bonus + rem_lifeThreatBonus: 4, + + // --- Board Wipe Decision --- + bw_creatureAdvantage: 2, // enemy must have this many more creatures + bw_bigThreatPower: 6, // power threshold for "big threat" + + // --- Combat: attack target selection --- + atk_nearDeathLifeThreshold: 15, + atk_bigBoardThreshold: 8, + atk_cmdThreatThreshold: 6, + atk_cardAdvThreshold: 3, + atk_lifeThreshold: 3, + + // --- Combat: blocker holdback --- + blk_holdbackToughness: 3, + + // --- Sequencing --- + seq_bigThreatOverride: 10, // board threat score to override removal timing + + // --- Bluffing --- + bluff_normalChance: 0.5, + bluff_hardBluffChance: 0.6, + bluff_hardReserve: 2, + + // --- Stack Response --- + stk_boardWipeAlwaysCounter: true, + stk_bigSpellCmc: 5, + stk_creatureThreshold: 4, + stk_removalCounterChance: 0.6, + stk_leaderCmcThreshold: 2, + stk_hardCounterCmc: 3, + + // --- Difficulty noise --- + diff_easyNoise: 8, + diff_normalNoise: 3, +}; + +// Active AI weights (can be overridden by training) +var aiWeights = JSON.parse(JSON.stringify(AI_DEFAULT_WEIGHTS)); + +// Load saved weights from local storage +(function() { + try { + var saved = window['local' + 'Storage'].getItem('mtg_ai_weights'); + if (saved) { + var parsed = JSON.parse(saved); + for (var k in parsed) { + if (parsed.hasOwnProperty(k) && aiWeights.hasOwnProperty(k)) { + aiWeights[k] = parsed[k]; + } + } + } + } catch(e) { /* ignore */ } +})(); + +function saveAiWeights() { + try { + window['local' + 'Storage'].setItem('mtg_ai_weights', JSON.stringify(aiWeights)); + } catch(e) { /* ignore */ } +} + +function resetAiWeights() { + aiWeights = JSON.parse(JSON.stringify(AI_DEFAULT_WEIGHTS)); + try { window['local' + 'Storage'].removeItem('mtg_ai_weights'); } catch(e) {} +} + +// Score a card for AI priority +function aiScoreCard(card, difficulty, w) { + w = w || aiWeights; + var cmc = aiGetCmc(card); + var score = 10 - cmc * w.card_baseCmcPenalty; + + var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || ''); + + if (aiIsCreature(card)) { + if (card.pt) { + var parts = card.pt.split('/'); + var power = parseInt(parts[0]) || 0; + var tough = parseInt(parts[1]) || 0; + score += (power + tough) * w.card_ptBonus; + } + var kwMap = { + 'flying': w.kw_flying, 'haste': w.kw_haste, 'deathtouch': w.kw_deathtouch, + 'double strike': w.kw_doubleStrike, 'first strike': w.kw_firstStrike, + 'lifelink': w.kw_lifelink, 'trample': w.kw_trample, 'hexproof': w.kw_hexproof, + 'indestructible': w.kw_indestructible, 'menace': w.kw_menace, 'vigilance': w.kw_vigilance, + 'reach': w.kw_reach, 'infect': w.kw_infect, 'toxic': w.kw_toxic, + 'annihilator': w.kw_annihilator, 'cascade': w.kw_cascade, 'undying': w.kw_undying, + 'persist': w.kw_persist, 'prowess': w.kw_prowess, 'ward': w.kw_ward, + 'flash': w.kw_flash, 'defender': w.kw_defender, + }; + for (var ki = 0; ki < abilities.keywords.length; ki++) { + score += kwMap[abilities.keywords[ki].name] || w.kw_default; + } + for (var ti = 0; ti < abilities.triggered.length; ti++) { + var st = abilities.triggered[ti].subType; + if (st === 'etb') score += w.trig_etb; + else if (st === 'attack') score += w.trig_attack; + else if (st === 'dies') score += w.trig_dies; + else score += w.trig_other; + } + score += abilities.activated.length * w.activated_bonus; + } else { + var text = (card.oracleText || '').toLowerCase(); + var tl = (card.typeLine || '').toLowerCase(); + if (text.includes('destroy') || text.includes('exile')) score += w.spell_destroy; + if (text.includes('draw')) score += w.spell_draw; + if (text.includes('counter target')) score += w.spell_counter; + if (text.includes('return') && text.includes('graveyard')) score += w.spell_graveyard; + if (text.includes('search your library')) score += w.spell_search; + if (card.isRamp) score += w.spell_ramp; + if (tl.includes('equipment')) score += w.spell_equipment; + if (tl.includes('enchantment') && !tl.includes('creature')) score += w.spell_enchantment; + for (var ki2 = 0; ki2 < abilities.keywords.length; ki2++) { + if (abilities.keywords[ki2].name === 'flash') score += w.spell_flash; + if (abilities.keywords[ki2].name === 'cascade') score += w.spell_cascade; + } + } + + if (difficulty === 'easy') score += Math.random() * w.diff_easyNoise; + if (difficulty === 'normal') score += Math.random() * w.diff_normalNoise; + + return score; +} + +// Generate a simple SVG card image for AI cards +function generateAiCardSVG(card) { + const colors = { + mono_white: { bg: '#F9FAF4', border: '#F0E68C', text: '#333' }, + mono_blue: { bg: '#E8F0FE', border: '#4A90D9', text: '#1a3a5c' }, + mono_black: { bg: '#2A2A2A', border: '#555', text: '#ddd' }, + mono_red: { bg: '#FDE8E8', border: '#D32F2F', text: '#5a1a1a' }, + mono_green: { bg: '#E8F5E9', border: '#388E3C', text: '#1a3a1a' }, + }; + const style = colors.mono_white; // Default + const isCreature = card.typeLine?.toLowerCase().includes('creature'); + const isLand = card.typeLine?.toLowerCase().includes('land'); + + const name = (card.name || 'Unknown').substring(0, 24); + const type = (card.typeLine || '').substring(0, 30); + const pt = card.pt || ''; + + const svg = ` + + + ${name} + ${card.cmc ? `{${card.cmc}}` : ''} + + ${type} + +
${(card.oracleText || '').substring(0, 120)}
+
+ ${isCreature && pt ? `${pt}` : ''} + AI +
`; + + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); +} + +// ============================================================ +// AI STRATEGY & DECISION-MAKING HELPERS +// ============================================================ + +// ---- AI Helper: Evaluate board threat level (per-card) ---- +function aiEvaluateBoardThreats(playerIdx, w) { + w = w || aiWeights; + var threats = []; + if (!gameState) return threats; + gameState.battlefieldCards.forEach(function(c) { + if (c.ownerId === playerIdx) return; + if (!aiIsCreature(c)) return; + var pow = getCreaturePower(c); + var tou = getCreatureToughness(c); + var score = pow + tou; + if (hasKeyword(c, 'flying')) score += w.bt_flying; + if (hasKeyword(c, 'deathtouch')) score += w.bt_deathtouch; + if (hasKeyword(c, 'lifelink')) score += w.bt_lifelink; + if (hasKeyword(c, 'double strike')) score += pow; + if (hasKeyword(c, 'first strike')) score += w.bt_firstStrike; + if (hasKeyword(c, 'trample')) score += w.bt_trample; + if (hasKeyword(c, 'hexproof')) score += w.bt_hexproof; + if (hasKeyword(c, 'indestructible')) score += w.bt_indestructible; + if (c.isCommander) score += w.bt_commander; + threats.push({ card: c, score: score }); + }); + threats.sort(function(a, b) { return b.score - a.score; }); + return threats; +} + +// ---- AI Helper: Evaluate per-PLAYER threat level (political awareness) ---- +// Returns sorted array: [{ idx, name, threatScore, breakdown }] highest threat first +function aiEvaluatePlayerThreats(playerIdx, w) { + w = w || aiWeights; + if (!gameState) return []; + var playerScores = []; + + gameState.players.forEach(function(p, i) { + if (i === playerIdx || p.eliminated) return; + + var breakdown = { + boardPresence: 0, + creaturePower: 0, + cardAdvantage: 0, + lifeDelta: 0, + commanderThreat: 0, + momentum: 0 + }; + + // --- Board presence: count permanents, weight creatures more --- + var theirCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === i; }); + var theirCreatures = theirCards.filter(function(c) { return aiIsCreature(c); }); + var theirNonCreatures = theirCards.length - theirCreatures.length; + breakdown.boardPresence = theirCreatures.length * w.pt_creatureWeight + theirNonCreatures * w.pt_nonCreatureWeight; + + // --- Creature power: sum total power on board --- + var totalPower = 0; + theirCreatures.forEach(function(c) { + var pow = getCreaturePower(c); + totalPower += pow; + // Extra danger for evasive creatures + if (hasKeyword(c, 'flying')) totalPower += w.pt_flyingBonus; + if (hasKeyword(c, 'trample')) totalPower += w.pt_trampleBonus; + if (hasKeyword(c, 'double strike')) totalPower += pow; + if (hasKeyword(c, 'indestructible')) totalPower += w.pt_indestructBonus; + if (hasKeyword(c, 'hexproof')) totalPower += w.pt_hexproofBonus; + }); + breakdown.creaturePower = totalPower; + + // --- Card advantage: hand size relative to average --- + var handSize = p.zones.hand ? p.zones.hand.length : 0; + var avgHand = 0; + var activeCount = 0; + gameState.players.forEach(function(pp, pi) { + if (pi === playerIdx || pp.eliminated) return; + avgHand += pp.zones.hand ? pp.zones.hand.length : 0; + activeCount++; + }); + avgHand = activeCount > 0 ? avgHand / activeCount : 3; + breakdown.cardAdvantage = (handSize - avgHand) * w.pt_cardAdvMultiplier; + + // --- Life delta: higher life = more threat (they can afford risky plays) --- + // Also factor in how close to killing opponents they are + var startLife = gameState.startingLife || 40; + var lifeRatio = p.life / startLife; + breakdown.lifeDelta = (lifeRatio - 0.5) * w.pt_lifeDeltaScale; // 0 at half life, +4 at full, -4 at 0 + + // --- Commander threat: commander on battlefield is dangerous --- + var cmdOnField = theirCards.some(function(c) { return c.isCommander; }); + var cmdDamage = 0; + // Check if this player has dealt commander damage to us + if (p.commanderDamageDealt) { + cmdDamage = p.commanderDamageDealt[playerIdx] || 0; + } + breakdown.commanderThreat = (cmdOnField ? w.pt_cmdOnField : 0) + (cmdDamage > 10 ? w.pt_cmdDmgHigh : cmdDamage > 5 ? w.pt_cmdDmgMid : 0); + + // --- Momentum: recently-cast spells suggest active turn --- + // Use graveyard size as a proxy for spells cast + var gySize = p.zones.graveyard ? p.zones.graveyard.length : 0; + breakdown.momentum = Math.min(gySize, w.pt_momentumCap); // Cap at 5 + + var totalThreat = breakdown.boardPresence + breakdown.creaturePower + + breakdown.cardAdvantage + breakdown.lifeDelta + + breakdown.commanderThreat + breakdown.momentum; + + playerScores.push({ + idx: i, + name: p.name, + threatScore: totalThreat, + breakdown: breakdown + }); + }); + + // Sort highest threat first + playerScores.sort(function(a, b) { return b.threatScore - a.threatScore; }); + return playerScores; +} + +// ---- AI Helper: Check if AI can afford a card (CMC vs untapped lands) ---- +function aiCanAfford(card, playerIdx) { + var cmc = aiGetCmc(card); + var untapped = aiCountUntappedLands(playerIdx); + return untapped >= cmc; +} + +// ---- AI Helper: Pick best attack target (political — attack the leader) ---- +function aiPickAttackTarget(playerIdx) { + var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy'; + + // Easy mode: pick lowest life (old behavior) + if (difficulty === 'easy') { + var bestIdx = -1; + var lowestLife = Infinity; + gameState.players.forEach(function(p, i) { + if (i === playerIdx || p.eliminated) return; + if (p.life < lowestLife) { + lowestLife = p.life; + bestIdx = i; + } + }); + return bestIdx; + } + + // Normal/Hard: use threat assessment to attack the leader + var playerThreats = aiEvaluatePlayerThreats(playerIdx); + if (playerThreats.length === 0) return -1; + + var leader = playerThreats[0]; // Highest threat player + + // Hard mode: consider if a low-life player is close to dying (finish them off) + if (difficulty === 'hard') { + var nearDeath = playerThreats.find(function(pt) { + var p = gameState.players[pt.idx]; + if (!p) return false; + // Can we lethal them this turn? + var ourPower = 0; + gameState.battlefieldCards.forEach(function(c) { + if (c.ownerId === playerIdx && aiIsCreature(c) && !c.tapped && !hasSummoningSickness(c)) { + ourPower += getCreaturePower(c); + } + }); + return p.life <= ourPower && p.life <= 15; + }); + if (nearDeath) { + aiAddThought(playerIdx, 'Going for the kill on ' + nearDeath.name + ' (' + gameState.players[nearDeath.idx].life + ' life)'); + return nearDeath.idx; + } + } + + // Log the political decision + if (leader.threatScore > 0) { + var bd = leader.breakdown; + var reasons = []; + if (bd.creaturePower >= 8) reasons.push('big board'); + if (bd.commanderThreat >= 6) reasons.push('commander out'); + if (bd.cardAdvantage >= 3) reasons.push('card advantage'); + if (bd.lifeDelta >= 3) reasons.push('high life'); + if (reasons.length > 0) { + aiAddThought(playerIdx, 'Targeting ' + leader.name + ' (leader: ' + reasons.join(', ') + ')'); + } + } + + return leader.idx; +} + +// ---- AI Helper: Evaluate if an attack is favorable ---- +function aiEvaluateAttack(attacker, defenderIdx) { + var pow = getCreaturePower(attacker); + var tou = getCreatureToughness(attacker); + // Count potential blockers + var blockers = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === defenderIdx && aiIsCreature(c) && !c.tapped; + }); + // If no blockers, always good to attack + if (blockers.length === 0) return { shouldAttack: true, risk: 'none' }; + // If attacker has flying and no defender has flying/reach, safe + if (hasKeyword(attacker, 'flying')) { + var canBlockFlying = blockers.some(function(b) { return hasKeyword(b, 'flying') || hasKeyword(b, 'reach'); }); + if (!canBlockFlying) return { shouldAttack: true, risk: 'none' }; + } + // If attacker has menace and <2 untapped creatures, safe + if (hasKeyword(attacker, 'menace') && blockers.length < 2) { + return { shouldAttack: true, risk: 'none' }; + } + // Check if any blocker can kill us + var canDie = blockers.some(function(b) { + var bPow = getCreaturePower(b); + return (bPow >= tou) || hasKeyword(b, 'deathtouch'); + }); + // Check if we can kill any blocker + var canKill = blockers.some(function(b) { + var bTou = getCreatureToughness(b); + return pow >= bTou || hasKeyword(attacker, 'deathtouch'); + }); + if (!canDie) return { shouldAttack: true, risk: 'low' }; + if (canDie && canKill) return { shouldAttack: true, risk: 'trade' }; + if (canDie && !canKill) return { shouldAttack: false, risk: 'bad-trade' }; + return { shouldAttack: true, risk: 'medium' }; +} + +// ---- AI Helper: Find best removal target (threat-aware, prioritize leader's cards) ---- +function aiFindRemovalTarget(playerIdx, w) { + w = w || aiWeights; + var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy'; + var threats = aiEvaluateBoardThreats(playerIdx); + if (threats.length === 0) return null; + + // Easy mode: just pick highest-threat card regardless of owner + if (difficulty === 'easy') { + for (var i = 0; i < threats.length; i++) { + var t = threats[i]; + if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) continue; + return t.card; + } + return null; + } + + // Normal/Hard: weight removal toward the biggest threat PLAYER's cards + var playerThreats = aiEvaluatePlayerThreats(playerIdx); + if (playerThreats.length === 0) { + // Fallback: just pick highest individual threat + for (var j = 0; j < threats.length; j++) { + if (hasKeyword(threats[j].card, 'hexproof') || hasKeyword(threats[j].card, 'shroud')) continue; + return threats[j].card; + } + return null; + } + + var leaderIdx = playerThreats[0].idx; + + // Score each threat card, boosting cards owned by the table leader + var scoredThreats = threats.map(function(t) { + var adjustedScore = t.score; + // Boost cards from the leading player + if (t.card.ownerId === leaderIdx) adjustedScore += w.rem_leaderBoost; + // Extra boost for commanders (removing commander is huge tempo swing) + if (t.card.isCommander) adjustedScore += w.rem_commanderBoost; + // Penalty for hexproof/shroud (untargetable) + if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) adjustedScore = -100; + // Hard mode: also consider if removing this creature prevents lethal + if (difficulty === 'hard') { + var ownerPow = getCreaturePower(t.card); + var myLife = gameState.players[playerIdx] ? gameState.players[playerIdx].life : 40; + if (ownerPow >= myLife * w.rem_lifeThreatFactor) adjustedScore += w.rem_lifeThreatBonus; // Threatens significant life + } + return { card: t.card, score: adjustedScore }; + }); + + scoredThreats.sort(function(a, b) { return b.score - a.score; }); + + for (var k = 0; k < scoredThreats.length; k++) { + if (scoredThreats[k].score > 0) { + var target = scoredThreats[k].card; + aiAddThought(playerIdx, 'Removal target: ' + target.name + ' (' + (gameState.players[target.ownerId] ? gameState.players[target.ownerId].name : '?') + ')'); + return target; + } + } + return null; +} + +// ---- AI Helper: Should the AI use a board wipe? ---- +function aiShouldBoardWipe(playerIdx, w) { + w = w || aiWeights; + var myCreatures = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && aiIsCreature(c); + }); + var enemyCreatures = gameState.battlefieldCards.filter(function(c) { + return c.ownerId !== playerIdx && aiIsCreature(c); + }); + // Wipe if enemies have significantly more creatures than us + if (enemyCreatures.length >= myCreatures.length + w.bw_creatureAdvantage) return true; + // Or if any single enemy creature is very threatening + var bigThreat = enemyCreatures.some(function(c) { + return getCreaturePower(c) >= w.bw_bigThreatPower || hasKeyword(c, 'indestructible'); + }); + if (bigThreat && enemyCreatures.length > myCreatures.length) return true; + return false; +} + +// ---- AI Helper: Cast commander from command zone ---- +function aiTryCastCommander(playerIdx) { + if (!gameState) return false; + var player = gameState.players[playerIdx]; + if (!player || !player.isAI) return false; + var cmdZone = player.zones.command; + if (!cmdZone || cmdZone.length === 0) return false; + + var cmdName = player.commanderCardName || ''; + if (!cmdName) return false; + + var cmdIdx = -1; + for (var i = 0; i < cmdZone.length; i++) { + if (cmdZone[i].name === cmdName) { cmdIdx = i; break; } + } + if (cmdIdx === -1) return false; + + var cardData = cmdZone[cmdIdx]; + var baseCmc = cardData.cmc || 0; + var tax = getCommanderTax(playerIdx); + var totalCost = baseCmc + tax; + var available = aiCountUntappedLands(playerIdx); + + if (available < totalCost) return false; + + // Generate image if needed + if (!cardData.imageUrl) cardData.imageUrl = generateAiCardSVG(cardData); + + // Use confirmCastCommander logic but adapted for AI + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + var castCard = cmdZone.splice(cmdIdx, 1)[0]; + + // Tap lands to pay total cost (base + tax) + var manaToPay = totalCost; + var lands = aiCountLands(playerIdx).filter(function(c) { return !c.tapped; }); + for (var li = 0; li < lands.length; li++) { + if (manaToPay <= 0) break; + toggleTap(lands[li].id); + manaToPay--; + } + + addCardToBattlefield(castCard.name, castCard.imageUrl || '', + castCard.typeLine || '', playerIdx, undefined, undefined, + castCard.oracleText || '', + { manaCost: castCard.manaCost || '', pt: castCard.pt, cmc: castCard.cmc, isCommander: true }); + + addLogEntry('\u{1F916} \u{1F451} ' + player.name + ' cast ' + cmdName + ' from command zone (tax: +' + tax + ')'); + fireBattlefieldTriggers('commander_cast'); + fireBattlefieldTriggers('cast'); + updatePlayerZones(); + renderPlayerPanels(); + return true; +} + +// ---- AI Helper: Pick multiple attack targets for multiplayer ---- +function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) { + if (!gameState) return []; + var opponents = []; + gameState.players.forEach(function(p, i) { + if (i === playerIdx || p.eliminated) return; + var theirCreatures = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === i && aiIsCreature(c) && !c.tapped; + }); + opponents.push({ + idx: i, + life: p.life, + blockerCount: theirCreatures.length, + hasFlyers: theirCreatures.some(function(c) { return hasKeyword(c, 'flying') || hasKeyword(c, 'reach'); }), + name: p.name + }); + }); + + if (opponents.length === 0) return []; + + // Use threat assessment for Normal/Hard to determine primary target + var primaryTarget; + if (difficulty === 'easy') { + // Easy: lowest life + opponents.sort(function(a, b) { return a.life - b.life; }); + primaryTarget = opponents[0].idx; + } else { + // Normal/Hard: highest-threat player from political assessment + var playerThreats = aiEvaluatePlayerThreats(playerIdx); + if (playerThreats.length > 0) { + primaryTarget = playerThreats[0].idx; + } else { + opponents.sort(function(a, b) { return a.life - b.life; }); + primaryTarget = opponents[0].idx; + } + } + + var assignments = []; // { card, targetIdx } + + if (difficulty === 'easy' || opponents.length === 1) { + // Easy/single opponent: everything at one target + creatures.forEach(function(c) { + assignments.push({ card: c, targetIdx: primaryTarget }); + }); + return assignments; + } + + // Normal/Hard: smart multi-target allocation + // Sort opponents by threat for secondary targeting + var opponentsByThreat = opponents.slice(); + var ptMap = {}; + var pt2 = aiEvaluatePlayerThreats(playerIdx); + pt2.forEach(function(p) { ptMap[p.idx] = p.threatScore; }); + opponentsByThreat.sort(function(a, b) { return (ptMap[b.idx] || 0) - (ptMap[a.idx] || 0); }); + + creatures.forEach(function(c) { + var assigned = false; + + // Flying creatures -> target player with no flying/reach blockers + if (hasKeyword(c, 'flying')) { + var noFlyerDefender = opponentsByThreat.find(function(op) { return !op.hasFlyers && op.blockerCount > 0; }); + if (noFlyerDefender) { + assignments.push({ card: c, targetIdx: noFlyerDefender.idx }); + assigned = true; + } + } + + if (!assigned) { + // Hard: if primary target is well-defended, spread to unblocked opponents + if (difficulty === 'hard' && opponents.length >= 3) { + var unblocked = opponentsByThreat.find(function(op) { + return op.idx !== primaryTarget && op.blockerCount === 0; + }); + if (unblocked) { + assignments.push({ card: c, targetIdx: unblocked.idx }); + assigned = true; + } + } + if (!assigned) { + assignments.push({ card: c, targetIdx: primaryTarget }); + } + } + }); + + return assignments; +} + +// ---- AI Helper: Check if AI has a counterspell and should use it ---- +function aiCheckForCounterspell(playerIdx) { + if (!gameState) return null; + var player = gameState.players[playerIdx]; + if (!player || !player.isAI) return null; + + for (var i = 0; i < player.zones.hand.length; i++) { + var card = player.zones.hand[i]; + var text = (card.oracleText || '').toLowerCase(); + if (text.indexOf('counter target') !== -1) { + // Check if can afford it + var cmc = aiGetCmc(card); + var available = aiCountUntappedLands(playerIdx); + if (available >= cmc) { + return { card: card, handIndex: i }; + } + } + } + return null; +} + +// ---- AI Stack Response: AI reacts to spells on the stack ---- +function aiRespondToStack() { + if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return; + if (!gameState.rulesEngine || !gameState.rulesEngine.useStack) return; + + var topSpell = gameState.theStack[gameState.theStack.length - 1]; + if (!topSpell) return; + + // Check each AI player for responses + gameState.players.forEach(function(player, idx) { + if (!player.isAI || player.eliminated) return; + if (idx === topSpell.ownerIndex) return; // Don't counter own spells + + var difficulty = gameState.aiDifficulty || 'easy'; + // Easy AI doesn't respond to stack + if (difficulty === 'easy') return; + + var counterInfo = aiCheckForCounterspell(idx); + if (!counterInfo) return; + + // Evaluate if the spell is worth countering + var spellText = (topSpell.oracleText || '').toLowerCase(); + var spellType = (topSpell.typeLine || '').toLowerCase(); + var shouldCounter = false; + + // Always counter board wipes + if (spellText.indexOf('destroy all') !== -1 || spellText.indexOf('each creature') !== -1) { + shouldCounter = true; + } + // Counter big threats (CMC >= 5) + if (topSpell.cmc >= 5 || (spellType.indexOf('creature') !== -1 && topSpell.cmc >= 4)) { + shouldCounter = true; + } + // Counter removal targeting AI's stuff (heuristic) + if (spellText.indexOf('destroy target') !== -1 || spellText.indexOf('exile target') !== -1) { + if (difficulty === 'hard') shouldCounter = true; + else shouldCounter = Math.random() > 0.4; + } + // Threat-aware: more likely to counter spells from the leading player + var playerThreats = aiEvaluatePlayerThreats(idx); + var casterIsLeader = playerThreats.length > 0 && playerThreats[0].idx === topSpell.ownerIndex; + if (casterIsLeader && topSpell.cmc >= 2) { + shouldCounter = true; // Counter leader's spells more aggressively + } + // Hard mode: counter anything that costs 3+ from any player + if (difficulty === 'hard' && topSpell.cmc >= 3) shouldCounter = true; + + if (!shouldCounter) return; + + // Cast the counterspell + var card = counterInfo.card; + if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card); + + // Tap lands to pay + var manaToPay = aiGetCmc(card); + var lands = aiCountLands(idx).filter(function(c) { return !c.tapped; }); + for (var li = 0; li < lands.length; li++) { + if (manaToPay <= 0) break; + toggleTap(lands[li].id); + manaToPay--; + } + + // Remove from hand + player.zones.hand.splice(counterInfo.handIndex, 1); + + // Counter the spell (remove from stack) + var countered = gameState.theStack.pop(); + // Put countered spell in owner's graveyard + var spellOwner = gameState.players[countered.ownerIndex]; + if (spellOwner) { + spellOwner.zones.graveyard.push({ + name: countered.name, + imageUrl: countered.imageUrl || '', + typeLine: countered.typeLine || '', + oracleText: countered.oracleText || '', + manaCost: countered.manaCost || '' + }); + } + // Put counterspell in AI's graveyard + player.zones.graveyard.push(card); + + addLogEntry('\u{1F916} \u{1F6AB} ' + player.name + ' counters ' + countered.name + ' with ' + card.name + '!'); + aiAddThought(idx, 'Countered ' + countered.name + ' (threat level: high)'); + renderStackPanel(); + renderHandArea(); + renderPlayerPanels(); + }); +} + +// ---- AI Thinking Log: visible decision tracking ---- +var aiThoughts = []; // Array of { playerIdx, playerName, thought, timestamp } + +function aiAddThought(playerIdx, thought) { + if (!gameState) return; + var player = gameState.players[playerIdx]; + if (!player) return; + aiThoughts.push({ + playerIdx: playerIdx, + playerName: player.name, + thought: thought, + timestamp: Date.now() + }); + // Keep max 50 + if (aiThoughts.length > 50) aiThoughts.shift(); + renderAiThoughtBubble(playerIdx, thought); +} + +function renderAiThoughtBubble(playerIdx, thought) { + // Show a floating thought bubble near the player's panel + var panelEl = document.getElementById('player-panel-' + playerIdx); + if (!panelEl) return; + + var existing = panelEl.querySelector('.ai-thought-bubble'); + if (existing) existing.remove(); + + var bubble = document.createElement('div'); + bubble.className = 'ai-thought-bubble'; + bubble.innerHTML = '\u{1F4AD} ' + thought; + panelEl.appendChild(bubble); + + // Auto-remove after 3 seconds + setTimeout(function() { + if (bubble.parentNode) bubble.remove(); + }, 3500); +} + +// ============================================================ +// AI BATTLEFIELD ABILITIES - Mana dorks, equipment usage +// ============================================================ + +/** + * AI taps mana-producing creatures (mana dorks) to generate extra mana. + * Called at the start of Main Phase 1 before casting spells. + * Returns the number of extra mana produced. + */ +function aiTapManaDorks(playerIdx) { + var player = gameState.players[playerIdx]; + if (!player) return 0; + var produced = 0; + var dorks = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && + c.typeLine && c.typeLine.toLowerCase().includes('creature') && + !c.tapped && + !hasSummoningSickness(c) && + (c.isManaProducer || (c.oracleText && /\{T\}:.*[Aa]dd \{[WUBRGC]\}/i.test(c.oracleText))); + }); + if (dorks.length === 0) return 0; + + dorks.forEach(function(dork) { + // Determine what color of mana to produce + var manaColor = dork.manaAbility || 'G'; + // Try to detect from oracle text + if (!dork.manaAbility && dork.oracleText) { + var manaMatch = dork.oracleText.match(/[Aa]dd \{([WUBRGC])\}/); + if (manaMatch) manaColor = manaMatch[1]; + } + // Tap the dork + dork.tapped = true; + var el = document.getElementById('bc-' + dork.id); + if (el) el.classList.add('tapped'); + // Add mana to pool directly + var p = gameState.players[playerIdx]; + if (p && p.manaPool) { + p.manaPool[manaColor] = (p.manaPool[manaColor] || 0) + 1; + } + produced++; + }); + + if (produced > 0) { + addLogEntry('\u{1F916} ' + player.name + ' tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : '') + ' for mana'); + aiAddThought(playerIdx, 'Tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : '')); + } + return produced; +} + +/** + * AI equips equipment to its best unequipped creature. + * Looks for equipment on the battlefield and attaches to best candidate. + */ +function aiEquipCreatures(playerIdx) { + var player = gameState.players[playerIdx]; + if (!player) return; + + // Find unattached equipment the AI controls + var equipment = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && + c.typeLine && c.typeLine.toLowerCase().includes('equipment') && + !c.attachedTo; + }); + if (equipment.length === 0) return; + + // Find best creature to equip (prefer attackers, biggest creatures) + var creatures = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && + c.typeLine && c.typeLine.toLowerCase().includes('creature') && + !c.typeLine.toLowerCase().includes('land'); + }); + if (creatures.length === 0) return; + + // Sort by power descending - equip the biggest threats + creatures.sort(function(a, b) { + return getCreaturePower(b) - getCreaturePower(a); + }); + + equipment.forEach(function(equip) { + // Find equip cost from oracle text + var equipCostMatch = equip.oracleText ? equip.oracleText.match(/[Ee]quip \{(\d+)\}/) : null; + var equipCost = equipCostMatch ? parseInt(equipCostMatch[1]) : 2; + // Special case: Equip {0} + if (equip.oracleText && /[Ee]quip \{0\}/.test(equip.oracleText)) equipCost = 0; + + var available = aiCountUntappedLands(playerIdx); + if (available < equipCost) return; + + // Pick the best creature that doesn't already have this equipment + var target = creatures.find(function(c) { + var attachments = c.attachments || []; + return !attachments.includes(equip.id); + }); + if (!target) return; + + // Pay equip cost by tapping lands + if (equipCost > 0) { + var lands = gameState.battlefieldCards.filter(function(c) { + return c.ownerId === playerIdx && c.typeLine && c.typeLine.toLowerCase().includes('land') && !c.tapped; + }); + for (var li = 0; li < equipCost && li < lands.length; li++) { + lands[li].tapped = true; + var landEl = document.getElementById('bc-' + lands[li].id); + if (landEl) landEl.classList.add('tapped'); + } + } + + // Attach equipment + equip.attachedTo = target.id; + if (!target.attachments) target.attachments = []; + target.attachments.push(equip.id); + + addLogEntry('\u{1F916} ' + player.name + ' equipped ' + equip.name + ' to ' + target.name + ''); + aiAddThought(playerIdx, 'Equipped ' + equip.name + ' to ' + target.name); + renderBattlefield(); + }); +} + +// ============================================================ +// AI SPELL TIMING & SEQUENCING +// ============================================================ + +/** + * Sort and categorize the AI's hand into play sequencing buckets. + * Returns { ramp: [], removal: [], threats: [], utility: [], instants: [] } + * Each entry has { card, index, score, cmc } + */ +function aiCategorizeHand(playerIdx, difficulty) { + var player = gameState.players[playerIdx]; + if (!player) return { ramp: [], removal: [], threats: [], utility: [], instants: [] }; + + var result = { ramp: [], removal: [], threats: [], utility: [], instants: [] }; + + player.zones.hand.forEach(function(c, i) { + if (aiIsLand(c)) return; + var entry = { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)), cmc: aiGetCmc(c) }; + var tl = (c.typeLine || '').toLowerCase(); + var isInstant = tl.includes('instant'); + + if (c.isRamp) { + result.ramp.push(entry); + } else if (c.isRemoval) { + if (isInstant) result.instants.push(entry); + else result.removal.push(entry); + } else if (c.isBoardWipe) { + result.removal.push(entry); + } else if (isInstant) { + result.instants.push(entry); + } else if (tl.includes('creature')) { + result.threats.push(entry); + } else { + result.utility.push(entry); + } + }); + + // Sort each bucket by score descending + var sortFn = function(a, b) { return b.score - a.score; }; + result.ramp.sort(sortFn); + result.removal.sort(sortFn); + result.threats.sort(sortFn); + result.utility.sort(sortFn); + result.instants.sort(sortFn); + + return result; +} + +/** + * Build the AI's play sequence for Main Phase 1. + * Hard mode: ramp first, then threats. Hold removal + instants for post-combat. + * Normal mode: ramp first, then best cards by score. + * Easy mode: random order. + */ +function aiBuildPlaySequence(playerIdx, difficulty) { + var categories = aiCategorizeHand(playerIdx, difficulty); + var sequence = []; + + if (difficulty === 'easy') { + // Easy: just play whatever, no sequencing intelligence + var all = [].concat(categories.ramp, categories.removal, categories.threats, categories.utility, categories.instants); + // Shuffle + for (var i = all.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = all[i]; all[i] = all[j]; all[j] = tmp; + } + return { mainPhase1: all, holdForPostCombat: [] }; + } + + if (difficulty === 'normal') { + // Normal: ramp first, then everything else by score + sequence = [].concat(categories.ramp); + var rest = [].concat(categories.threats, categories.removal, categories.utility, categories.instants); + rest.sort(function(a, b) { return b.score - a.score; }); + sequence = sequence.concat(rest); + return { mainPhase1: sequence, holdForPostCombat: [] }; + } + + // Hard mode: sophisticated sequencing + // Phase 1: Ramp spells first, then equipment/enchantments, then threats + var phase1 = [].concat(categories.ramp); + // Add utility (equipment, enchantments) early + phase1 = phase1.concat(categories.utility); + // Add threats + phase1 = phase1.concat(categories.threats); + + // Hold back: sorcery-speed removal (play post-combat to see if combat solves the problem) + // Hold back: instants (save for opponent's turn if stack is enabled) + var held = []; + // In hard mode, hold instant-speed removal and counterspells for post-combat or responses + held = held.concat(categories.instants); + // Sorcery removal goes post-combat in hard mode + held = held.concat(categories.removal); + + return { mainPhase1: phase1, holdForPostCombat: held }; +} + +// --- AI post-combat continuation (used after human blocker pause) --- +function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) { + var postActions = []; + 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 = ''; + if (difficulty === 'hard' || difficulty === 'normal') { + var instantsInHand = player.zones.hand.filter(function(c) { + var tl = (c.typeLine || '').toLowerCase(); + return tl.indexOf('instant') !== -1; + }); + var flashCards = player.zones.hand.filter(function(c) { + var text = (c.oracleText || '').toLowerCase(); + return text.indexOf('flash') !== -1; + }); + var bluffCandidates = instantsInHand.concat(flashCards); + if (bluffCandidates.length > 0) { + var cheapestInstant = bluffCandidates.reduce(function(best, c) { + var cmc = aiGetCmc(c); + return cmc < aiGetCmc(best) ? c : best; + }, bluffCandidates[0]); + bluffReserve = aiGetCmc(cheapestInstant); + bluffCardName = cheapestInstant.name; + if (difficulty === 'normal' && Math.random() > 0.5) bluffReserve = 0; + } + if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) { + bluffReserve = 2; + bluffCardName = ''; + } + } + + if (heldCards && heldCards.length > 0) { + var heldPlayed = 0; + for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) { + var he = heldCards[hi]; + if (bluffReserve > 0 && he.card.name === bluffCardName) continue; + var hci = player.zones.hand.indexOf(he.card); + if (hci === -1) continue; + var havailable = aiCountUntappedLands(playerIdx); + var hcmc = aiGetCmc(he.card); + if (havailable - hcmc < bluffReserve) continue; + if (havailable < hcmc) continue; + if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card); + playCardFromHand(playerIdx, hci); + addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)'); + heldPlayed++; + } + } + + var remainingHand = player.zones.hand + .map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; }) + .filter(function(e) { return !aiIsLand(e.card); }) + .sort(function(a, b) { return b.score - a.score; }); + + for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) { + var re = remainingHand[ri]; + if (bluffReserve > 0 && re.card.name === bluffCardName) continue; + var ci = player.zones.hand.indexOf(re.card); + if (ci === -1) continue; + var available = aiCountUntappedLands(playerIdx); + var cardCmc = aiGetCmc(re.card); + if (available - cardCmc < bluffReserve) continue; + if (available < cardCmc) continue; + if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card); + playCardFromHand(playerIdx, ci); + addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)'); + } + + if (bluffReserve > 0) { + var untappedAfter = aiCountUntappedLands(playerIdx); + if (untappedAfter >= bluffReserve) { + if (bluffCardName) { + aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')'); + } else { + aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)'); + } + addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...'); + } + } + }}); + postDelay += 800; + } + + // END TURN + postActions.push({ delay: postDelay, fn: function() { + addLogEntry('\u{1F916} ' + player.name + ' passes the turn'); + aiTurnActive = false; + nextTurn(); + }}); + + postActions.forEach(function(action) { + setTimeout(function() { + try { action.fn(); } catch(e) { console.error('[AI Post-Combat Error]', e); } + }, action.delay); + }); +} + +function runAiTurn() { + if (!gameState) return; + var playerIdx = gameState.currentPlayerIndex; + var player = gameState.players[playerIdx]; + if (!player || !player.isAI) return; + + // ======================================== + // PRIORITY 1: Commander-AI-Lab ML Pipeline + // ======================================== + if (typeof AIBridge !== 'undefined' && + AIBridge.connector.connected && + AIBridge.connector.modelLoaded) { + + aiTurnActive = true; + player.aiLandPlayedThisTurn = false; + addLogEntry('\u{1F9E0} ' + player.name + ' is thinking... [ML Policy]'); + aiAddThought(playerIdx, 'ML pipeline mode \u2014 consulting neural network...'); + + _runMlAiTurn(playerIdx).then(function(success) { + if (!success) { + addLogEntry('\u{1F916} [ML] Falling back to local AI for ' + player.name); + aiTurnActive = false; + _runLocalAiTurn(); + } + }).catch(function(err) { + console.error('[runAiTurn] ML pipeline error:', err); + aiTurnActive = false; + _runLocalAiTurn(); + }); + return; + } + + // ======================================== + // PRIORITY 2: Forge Engine Hybrid + // ======================================== + if (typeof ForgeBridge !== 'undefined' && ForgeBridge.enabled && + ForgeBridge.connected && ForgeBridge.gameActive) { + aiTurnActive = true; + player.aiLandPlayedThisTurn = false; + addLogEntry('\u{1F916} ' + player.name + ' is thinking... [Forge AI]'); + aiAddThought(playerIdx, 'Forge engine mode \u2014 consulting advanced AI...'); + + forgeRunAiTurn(playerIdx, function(success) { + if (success) { + addLogEntry('\u{1F916} ' + player.name + ' passes the turn [Forge AI]'); + aiTurnActive = false; + renderPlayerPanels(); + renderHandArea(); + nextTurn(); + } else { + addLogEntry('\u{1F916} [Forge] Falling back to local AI for ' + player.name); + aiTurnActive = false; + _runLocalAiTurn(); + } + }); + return; + } + + // ======================================== + // PRIORITY 3: Local Heuristic AI + // ======================================== + _runLocalAiTurn(); +} + +/** + * Run an AI turn using the Commander-AI-Lab ML policy pipeline. + * Makes multiple decision requests per turn (one per phase). + * Returns true if the ML pipeline completed the turn successfully. + */ +async function _runMlAiTurn(playerIdx) { + if (!gameState) return false; + var player = gameState.players[playerIdx]; + if (!player || !player.isAI) return false; + + aiTurnActive = true; + player.aiLandPlayedThisTurn = false; + var difficulty = gameState.aiDifficulty || 'easy'; + var turnDelay = gameState.aiSettings?.turnDelay || 800; + + try { + // ── PHASE 1: Play a land (local, immediate) ────────── + var landInHand = player.zones.hand.findIndex(function(c) { + return typeof aiIsLand === 'function' && aiIsLand(c); + }); + if (landInHand !== -1) { + var landCard = player.zones.hand[landInHand]; + if (landCard && !landCard.imageUrl && typeof generateAiCardSVG === 'function') { + landCard.imageUrl = generateAiCardSVG(landCard); + } + playCardFromHand(playerIdx, landInHand); + player.aiLandPlayedThisTurn = true; + addLogEntry('\u{1F9E0} ' + player.name + ' played a land [ML]'); + await _mlDelay(turnDelay * 0.5); + } + + // ── PHASE 2: Main Phase 1 decisions ────────────────── + var maxActions = difficulty === 'easy' ? 1 : difficulty === 'normal' ? 3 : 5; + var actionsPlayed = 0; + + for (var i = 0; i < maxActions; i++) { + var decision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx); + if (!decision) break; + + aiAddThought(playerIdx, + 'ML decision: ' + decision.action + + ' (' + Math.round(decision.confidence * 100) + '% conf, ' + + decision.inference_ms.toFixed(1) + 'ms)'); + + // Display probability distribution + if (decision.probabilities) { + var topActions = Object.entries(decision.probabilities) + .sort(function(a, b) { return b[1] - a[1]; }) + .slice(0, 3) + .map(function(e) { return e[0] + ':' + Math.round(e[1] * 100) + '%'; }) + .join(', '); + aiAddThought(playerIdx, 'Top actions: ' + topActions); + } + + // Skip if action is pass/hold + if (decision.action === 'pass' || decision.action === 'hold_mana') { + aiAddThought(playerIdx, 'ML chose to ' + + (decision.action === 'pass' ? 'pass' : 'hold mana open')); + break; + } + + // Skip combat action in main phase — save for combat + if (decision.action === 'attack_opponent') { + aiAddThought(playerIdx, 'ML wants to attack \u2014 saving for combat phase'); + break; + } + + var executed = AIBridge.MacroActionMapper.execute( + decision.action, playerIdx, gameState, decision + ); + + if (executed) { + actionsPlayed++; + // Push state update to backend + AIBridge.gameState.pushState(gameState); + AIBridge.gameState.pushDecision(decision); + await _mlDelay(turnDelay); + } else { + // Action couldn't execute — likely no valid targets + aiAddThought(playerIdx, + 'Could not execute ' + decision.action + ' \u2014 moving on'); + break; + } + } + + // ── PHASE 3: Combat ────────────────────────────────── + var combatDecision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx); + if (combatDecision && combatDecision.action === 'attack_opponent') { + AIBridge.MacroActionMapper.execute( + 'attack_opponent', playerIdx, gameState, combatDecision + ); + await _mlDelay(turnDelay); + } + + // ── PHASE 4: Post-combat main phase 2 ──────────────── + var postDecision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx); + if (postDecision && + postDecision.action !== 'pass' && + postDecision.action !== 'hold_mana' && + postDecision.action !== 'attack_opponent') { + AIBridge.MacroActionMapper.execute( + postDecision.action, playerIdx, gameState, postDecision + ); + await _mlDelay(turnDelay * 0.5); + } + + // ── PHASE 5: End turn ──────────────────────────────── + addLogEntry('\u{1F9E0} ' + player.name + ' ends their turn [ML]'); + aiTurnActive = false; + renderPlayerPanels(); + renderHandArea(); + nextTurn(); + return true; + + } catch (err) { + console.error('[_runMlAiTurn] Error:', err); + aiTurnActive = false; + return false; + } +} + +function _mlDelay(ms) { + return new Promise(function(resolve) { setTimeout(resolve, ms); }); +} + +function _runLocalAiTurn() { + if (!gameState) return; + var playerIdx = gameState.currentPlayerIndex; + var player = gameState.players[playerIdx]; + if (!player || !player.isAI) return; + + aiTurnActive = true; + player.aiLandPlayedThisTurn = false; + var difficulty = gameState.aiDifficulty || 'easy'; + + addLogEntry('\u{1F916} ' + player.name + ' is thinking...'); + aiAddThought(playerIdx, difficulty.charAt(0).toUpperCase() + difficulty.slice(1) + ' mode \u2014 evaluating options...'); + + // Log threat assessment for Normal/Hard + if (difficulty !== 'easy') { + var threatReport = aiEvaluatePlayerThreats(playerIdx); + if (threatReport.length > 0) { + var topThreat = threatReport[0]; + var threatSummary = 'Threat assessment: ' + threatReport.map(function(t) { + return t.name + ' (' + Math.round(t.threatScore) + ')'; + }).join(' > '); + aiAddThought(playerIdx, threatSummary); + } + + // Forge-inspired board analysis + if (typeof AIBoardAnalyzer !== 'undefined') { + var boardScore = AIBoardAnalyzer.evaluateBoardState(playerIdx); + var archInfo = AIBoardAnalyzer.detectArchetype(playerIdx); + aiAddThought(playerIdx, 'Board eval: ' + boardScore + ' | Archetype: ' + archInfo.archetype + ' (' + archInfo.confidence + '% conf)'); + + // Log active synergies + var synergies = archInfo.synergies || []; + if (synergies.length > 0) { + aiAddThought(playerIdx, 'Synergies: ' + synergies.map(function(s) { return s.description; }).join(', ')); + } + + // Strategic advice for hard mode + if (difficulty === 'hard') { + var advice = AIBoardAnalyzer.getStrategicAdvice(playerIdx); + var highPriority = advice.filter(function(a) { return a.priority === 'high'; }); + if (highPriority.length > 0) { + aiAddThought(playerIdx, 'Strategy: ' + highPriority[0].text); + } + } + } + } + + var actions = []; // queue of { delay, fn } + var delay = 600; + + // ======================================== + // MAIN PHASE 1: Play a land via playCardFromHand() + // ======================================== + var landInHand = player.zones.hand.findIndex(function(c) { return aiIsLand(c); }); + if (landInHand !== -1) { + actions.push({ delay: delay, fn: function() { + // Ensure card has imageUrl for battlefield rendering + var landCard = player.zones.hand[landInHand]; + if (landCard && !landCard.imageUrl) landCard.imageUrl = generateAiCardSVG(landCard); + // Use playCardFromHand to integrate with mana system, triggers, land-drop tracking + playCardFromHand(playerIdx, landInHand); + addLogEntry('\u{1F916} ' + player.name + ' played a land'); + }}); + delay += 800; + } + + // ======================================== + // MAIN PHASE 1: Try to cast commander from command zone + // ======================================== + var cmdZone = player.zones.command || []; + if (cmdZone.length > 0) { + actions.push({ delay: delay, fn: function() { + var result = aiTryCastCommander(playerIdx); + if (result) { + var tax = getCommanderTax(playerIdx); + aiAddThought(playerIdx, 'Cast commander! (tax: ' + (tax > 0 ? '+' + (tax - 2) : '0') + ')'); + } else { + var needed = (cmdZone[0] ? cmdZone[0].cmc || 0 : 0) + getCommanderTax(playerIdx); + var have = aiCountUntappedLands(playerIdx); + if (have < needed) { + aiAddThought(playerIdx, 'Need ' + needed + ' mana for commander, have ' + have); + } + } + }}); + delay += 800; + } + + // ======================================== + // MAIN PHASE 1: Tap mana dorks for extra mana (before casting) + // ======================================== + actions.push({ delay: delay, fn: function() { + aiTapManaDorks(playerIdx); + }}); + delay += 400; + + // ======================================== + // MAIN PHASE 1: Cast spells from hand using playCardFromHand() + // Uses smart sequencing: ramp > threats > hold removal for post-combat + // ======================================== + var playSequence = aiBuildPlaySequence(playerIdx, difficulty); + var phase1Cards = playSequence.mainPhase1; + var heldCards = playSequence.holdForPostCombat; + + // Also check board threats for emergency overrides + var boardThreats = aiEvaluateBoardThreats(playerIdx); + var biggestThreat = boardThreats.length > 0 ? boardThreats[0] : null; + + // If there's a massive threat, override and play removal in Main Phase 1 + if (biggestThreat && biggestThreat.score >= 10 && difficulty === 'hard') { + // Move removal from held back to phase1 + phase1Cards = phase1Cards.concat(heldCards.filter(function(e) { return e.card.isRemoval || e.card.isBoardWipe; })); + heldCards = heldCards.filter(function(e) { return !e.card.isRemoval && !e.card.isBoardWipe; }); + aiAddThought(playerIdx, 'Critical threat detected - using removal now!'); + } + + // Boost board wipe scores if significantly behind + if (aiShouldBoardWipe(playerIdx)) { + phase1Cards.forEach(function(e) { if (e.card.isBoardWipe) e.score += 30; }); + phase1Cards.sort(function(a, b) { return b.score - a.score; }); + } + + // Decide how many spells to play based on difficulty + var maxSpells = difficulty === 'easy' ? 1 : difficulty === 'normal' ? 3 : 5; + var spellsQueued = 0; + + for (var si = 0; si < phase1Cards.length; si++) { + if (spellsQueued >= maxSpells) break; + var entry = phase1Cards[si]; + var cardRef = entry.card; + var cmc = aiGetCmc(cardRef); + + (function(theCardRef, theCmc) { + actions.push({ delay: delay, fn: function() { + // Re-find card in hand at action time (indices shift as cards are played) + var ci = player.zones.hand.indexOf(theCardRef); + if (ci === -1) return; + var available = aiCountUntappedLands(playerIdx); + if (available < theCmc) return; + + // Ensure card has imageUrl and manaCost for rendering and mana payment + if (!theCardRef.imageUrl) theCardRef.imageUrl = generateAiCardSVG(theCardRef); + + // Use playCardFromHand() which integrates with: + // - attemptManaPay() (auto-tap + mana spend) + // - Stack system (if useStack is enabled) + // - Triggers and battlefield placement + playCardFromHand(playerIdx, ci); + addLogEntry('\u{1F916} ' + player.name + ' cast ' + theCardRef.name + ''); + aiAddThought(playerIdx, 'Casting ' + theCardRef.name + (theCardRef.isRemoval ? ' (removal!)' : theCardRef.isBoardWipe ? ' (board wipe!)' : '')); + }}); + })(cardRef, cmc); + + delay += 1000; + spellsQueued++; + } + + // ======================================== + // MAIN PHASE 1: Equip equipment to creatures + // ======================================== + if (difficulty !== 'easy') { + actions.push({ delay: delay, fn: function() { + aiEquipCreatures(playerIdx); + }}); + delay += 600; + } + + // ======================================== + // COMBAT PHASE: Smart attack decisions with multi-target + // ======================================== + var shouldAttack = difficulty === 'easy' ? Math.random() > 0.3 : true; + if (shouldAttack) { + actions.push({ delay: delay, fn: function() { + var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) { + return !hasSummoningSickness(c); + }); + if (creatures.length === 0) { + aiAddThought(playerIdx, 'No creatures ready to attack'); + return; + } + + // Smart attacker selection based on difficulty + var attackerCards = []; + + if (difficulty === 'easy') { + // Easy: random subset + attackerCards = creatures.filter(function() { return Math.random() > 0.5; }); + if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]]; + } else { + // For evaluation, pick a primary target first + var primaryTarget = aiPickAttackTarget(playerIdx); + if (primaryTarget === -1) return; + + // Normal/Hard: evaluate each attack + 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); + } + }); + + // Hard mode: hold back 1-2 creatures as blockers + 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; }); + aiAddThought(playerIdx, 'Holding back ' + holdBack.name + ' to block'); + } + } + } + + if (attackerCards.length === 0) { + aiAddThought(playerIdx, 'No favorable attacks available'); + return; + } + + // Use multi-target attack allocation in multiplayer + var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty); + if (assignments.length === 0) return; + + // Set up combat state with potentially multiple targets + gameState.combat.attackers = assignments.map(function(a) { + return { cardId: a.card.id, targetPlayerId: a.targetIdx }; + }); + + // Tap attackers (unless vigilance) + 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'); + }); + + // Log attacks grouped by target + var targetGroups = {}; + assignments.forEach(function(a) { + if (!targetGroups[a.targetIdx]) targetGroups[a.targetIdx] = []; + targetGroups[a.targetIdx].push(a.card.name); + }); + var targetCount = Object.keys(targetGroups).length; + for (var tIdx in targetGroups) { + var targetName = gameState.players[tIdx] ? gameState.players[tIdx].name : '?'; + var names = targetGroups[tIdx].join(', '); + addLogEntry('\u{1F916} \u2694 ' + player.name + ' attacks ' + targetName + ' with: ' + names); + } + if (targetCount > 1) { + aiAddThought(playerIdx, 'Splitting attacks across ' + targetCount + ' opponents'); + } else { + aiAddThought(playerIdx, 'Attacking with ' + attackerCards.length + ' creature' + (attackerCards.length > 1 ? 's' : '')); + } + fireBattlefieldTriggers('attack'); + }}); + delay += 800; + + // Let any AI defenders assign blockers, then check for human defenders + actions.push({ delay: delay, fn: function() { + if (gameState.combat.attackers.length === 0) return; + + // AI defenders assign blockers first + aiAssignBlockers(); + + // Check if any human player is being attacked + var defenderSet = {}; + gameState.combat.attackers.forEach(function(a) { defenderSet[a.targetPlayerId] = true; }); + var humanDefended = false; + for (var dIdx in defenderSet) { + var def = gameState.players[dIdx]; + if (def && !def.isAI && !def.eliminated) { humanDefended = true; break; } + } + + if (humanDefended) { + // PAUSE: Show blocker UI for human player, store resume callback + gameState.combat.phase = 'declaring-blockers'; + gameState.combat.pendingBlockerFor = null; + showCombatOverlay(); + refreshCombatOverlay(); + addLogEntry('\u{1F6E1} Declare your blockers!'); + + // Store the resume function — confirmBlockers will call it + _aiBlockerPauseState = { + playerIdx: playerIdx, + resumeFn: function() { + _aiBlockerPauseState = null; + // Continue with damage resolution + if (gameState.combat.attackers.length > 0) { + resolveCombatDamage(); + setTimeout(function() { + endCombat(); + renderPlayerPanels(); + // Continue with Main Phase 2 and End Turn + _aiRunPostCombat(playerIdx, player, difficulty, heldCards); + }, 600); + } else { + _aiRunPostCombat(playerIdx, player, difficulty, heldCards); + } + } + }; + // STOP the action queue here — remaining actions are handled by resumeFn + return '__PAUSE_AI_TURN__'; + } + }}); + delay += 600; + + // Resolve combat damage (only runs if not paused for human blockers) + actions.push({ delay: delay, fn: function() { + if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this + if (gameState.combat.attackers.length > 0) { + resolveCombatDamage(); + setTimeout(function() { + endCombat(); + renderPlayerPanels(); + }, 600); + } + }}); + delay += 1000; + } + + // ======================================== + // MAIN PHASE 2: Post-combat spell casting (normal & hard) + // Hard: play held-back removal + instants + 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; + var bluffCardName = ''; + if (difficulty === 'hard' || difficulty === 'normal') { + // Check for instant-speed cards in hand we want to bluff with + var instantsInHand = player.zones.hand.filter(function(c) { + var tl = (c.typeLine || '').toLowerCase(); + return tl.indexOf('instant') !== -1; + }); + // Also check for cards with flash + var flashCards = player.zones.hand.filter(function(c) { + var text = (c.oracleText || '').toLowerCase(); + return text.indexOf('flash') !== -1; + }); + var bluffCandidates = instantsInHand.concat(flashCards); + if (bluffCandidates.length > 0) { + // Pick the cheapest instant to bluff with + var cheapestInstant = bluffCandidates.reduce(function(best, c) { + var cmc = aiGetCmc(c); + return cmc < aiGetCmc(best) ? c : best; + }, bluffCandidates[0]); + bluffReserve = aiGetCmc(cheapestInstant); + bluffCardName = cheapestInstant.name; + // Hard mode: always bluff if possible. Normal: 50% chance + if (difficulty === 'normal' && Math.random() > 0.5) { + bluffReserve = 0; + } + } + // Even without instants in hand, hard mode sometimes bluffs with 2 open mana + if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) { + bluffReserve = 2; + bluffCardName = ''; // Pure bluff, no actual card + } + } + + // First: play held-back cards (hard mode sequencing) + if (heldCards && heldCards.length > 0) { + var heldPlayed = 0; + for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) { + var he = heldCards[hi]; + // Don't play the card we're bluffing with + if (bluffReserve > 0 && he.card.name === bluffCardName) continue; + var hci = player.zones.hand.indexOf(he.card); + if (hci === -1) continue; + var havailable = aiCountUntappedLands(playerIdx); + var hcmc = aiGetCmc(he.card); + // Reserve mana for bluff + if (havailable - hcmc < bluffReserve) continue; + if (havailable < hcmc) continue; + if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card); + playCardFromHand(playerIdx, hci); + addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)'); + if (he.card.isRemoval) { + aiAddThought(playerIdx, 'Using held removal: ' + he.card.name); + } + heldPlayed++; + } + } + + // Then: play any remaining affordable spells from hand (respecting bluff reserve) + var remainingHand = player.zones.hand + .map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; }) + .filter(function(e) { return !aiIsLand(e.card); }) + .sort(function(a, b) { return b.score - a.score; }); + + for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) { + var re = remainingHand[ri]; + // Don't play bluff card + if (bluffReserve > 0 && re.card.name === bluffCardName) continue; + var ci = player.zones.hand.indexOf(re.card); + if (ci === -1) continue; + var available = aiCountUntappedLands(playerIdx); + var cardCmc = aiGetCmc(re.card); + // Reserve mana for bluff + if (available - cardCmc < bluffReserve) continue; + if (available < cardCmc) continue; + if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card); + playCardFromHand(playerIdx, ci); + addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)'); + } + + // Log bluff status + if (bluffReserve > 0) { + var untappedAfter = aiCountUntappedLands(playerIdx); + if (untappedAfter >= bluffReserve) { + if (bluffCardName) { + aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')'); + } else { + aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)'); + } + addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...'); + } + } + }}); + delay += 800; + } + + // ======================================== + // END TURN + // ======================================== + actions.push({ delay: delay, fn: function() { + if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this + addLogEntry('\u{1F916} ' + player.name + ' passes the turn'); + aiTurnActive = false; + nextTurn(); + }}); + + // Execute queued actions with delays (wrapped in try/catch so aiTurnActive resets on error) + actions.forEach(function(action) { + setTimeout(function() { + try { action.fn(); } catch(e) { console.error('[AI Turn Error]', e); } + }, action.delay); + }); + + // Safety timeout: if aiTurnActive is still true after all actions should be done, force-reset it + var safetyDelay = delay + 5000; + setTimeout(function() { + if (aiTurnActive && !_aiBlockerPauseState) { + console.warn('[AI] Safety timeout - resetting aiTurnActive'); + aiTurnActive = false; + } + }, safetyDelay); +} + +// ============================================================ +// ============================================================ +// CARD ABILITIES & RULES TEXT PARSING ENGINE +// ============================================================ + +/** + * Parse all abilities from a card's oracle text. + * Returns a structured object with keyword, activated, triggered, static, and mana abilities. + */ +function parseCardAbilities(oracleText, typeLine, cardName) { + if (!oracleText && !typeLine) return { keywords: [], activated: [], triggered: [], static: [], mana: [], loyalty: [], replacement: [] }; + const text = oracleText || ''; + const type = (typeLine || '').toLowerCase(); + const result = { + keywords: [], + activated: [], + triggered: [], + static: [], + mana: [], + loyalty: [], + replacement: [], + }; + + // --- 1. KEYWORD ABILITIES --- + const textLower = text.toLowerCase(); + for (const [keyword, data] of Object.entries(KEYWORD_ABILITIES)) { + const kw = keyword.toLowerCase(); + // Match keyword as standalone word or at start of a sentence + const re = new RegExp(`(?:^|[,;.\\n\\s])${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\b|\\s|,|\\.|$)`, 'i'); + if (re.test(textLower) || re.test(',' + textLower)) { + result.keywords.push({ + name: keyword, + icon: data.icon, + color: data.color, + description: data.desc, + }); + } + } + + // --- 2. ACTIVATED ABILITIES --- + // Pattern: "[cost]: [effect]" where cost contains mana symbols or {T} + const lines = text.split('\n'); + for (const line of lines) { + // Activated abilities have a colon separating cost from effect + // Match patterns like "{2}{B}, {T}: Do something" or "{T}: Add {G}" + const activatedMatch = line.match(/^([^:"]+(?:\{[^}]+\})[^:]*?):\s*(.+)$/i); + if (activatedMatch) { + const costPart = activatedMatch[1].trim(); + const effectPart = activatedMatch[2].trim(); + // Verify it has a mana symbol or tap symbol in cost + if (/\{[WUBRGCTX0-9]+\}/.test(costPart) || /sacrifice|discard|pay|exile|remove|tap an untapped/i.test(costPart)) { + // Check if it's a mana ability (produces mana) + if (/add \{[WUBRGC]\}/i.test(effectPart) || /add one mana/i.test(effectPart)) { + result.mana.push({ + cost: costPart, + effect: effectPart, + type: 'mana', + }); + } else { + result.activated.push({ + cost: costPart, + effect: effectPart, + type: 'activated', + }); + } + } + } + } + + // --- 3. TRIGGERED ABILITIES --- + const triggerPatterns = [ + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+enters(?:\s+the\s+battlefield)?)/gi, sub: 'etb' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+leaves\s+the\s+battlefield)/gi, sub: 'ltb' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+dies)/gi, sub: 'dies' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+attacks)/gi, sub: 'attack' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+blocks)/gi, sub: 'block' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes?\s+(?:the\s+)?target)/gi, sub: 'targeted' }, + { re: /\b(when(?:ever)?\s+(?:you|a player)\s+(?:cast|draw|discard|gain|lose|pay))/gi, sub: 'action' }, + { re: /\b(at the beginning of (?:your|each|the)\s+(?:upkeep|end step|draw step|combat|main phase|untap step))/gi, sub: 'upkeep' }, + { re: /\b(when(?:ever)?\s+(?:a|another|one or more)\s+(?:creature|permanent|artifact|enchantment|land|nontoken))/gi, sub: 'permanent' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+deals\s+(?:combat\s+)?damage)/gi, sub: 'damage' }, + { re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes\s+(?:tapped|untapped))/gi, sub: 'tap' }, + ]; + + for (const { re, sub } of triggerPatterns) { + let match; + re.lastIndex = 0; + while ((match = re.exec(text)) !== null) { + // Grab the rest of the sentence for context + const startIdx = match.index; + let endIdx = text.indexOf('.', startIdx + match[0].length); + if (endIdx === -1) endIdx = text.indexOf('\n', startIdx + match[0].length); + if (endIdx === -1) endIdx = text.length; + const fullAbilityText = text.substring(startIdx, endIdx + 1).trim(); + result.triggered.push({ + trigger: match[1].trim(), + effect: fullAbilityText, + subType: sub, + type: 'triggered', + }); + } + } + + // --- 4. STATIC ABILITIES --- + const staticPatterns = [ + { re: /\b(other\s+(?:creatures?|permanents?)\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' }, + { re: /\b(creatures?\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' }, + { re: /\b((?:creatures?|spells?)\s+(?:you\s+cast\s+)?cost\s+\{?\d+\}?\s+(?:less|more))/gi, sub: 'cost-mod' }, + { re: /\b(you\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' }, + { re: /\b(players?\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' }, + { re: /\b((?:this|~|enchanted|equipped)\s+(?:creature|permanent)\s+(?:gets?|has|gains)\s+[^.]+)/gi, sub: 'buff' }, + { re: /\b((?:each|all)\s+(?:creatures?|opponents?|players?)\s+[^.]*(?:get|have|gain|lose|deal|take)[^.]+)/gi, sub: 'global' }, + { re: /\b((?:as long as|while)\s+[^,]+,\s+[^.]+)/gi, sub: 'conditional' }, + ]; + + for (const { re, sub } of staticPatterns) { + let match; + re.lastIndex = 0; + while ((match = re.exec(text)) !== null) { + result.static.push({ + text: match[1].trim(), + subType: sub, + type: 'static', + }); + } + } + + // --- 5. LOYALTY ABILITIES (Planeswalkers) --- + if (type.includes('planeswalker')) { + const loyaltyRe = /([+\-\u2212]\d+|0):\s*([^\n]+)/g; + let lm; + while ((lm = loyaltyRe.exec(text)) !== null) { + result.loyalty.push({ + cost: lm[1].replace('\u2212', '-'), + effect: lm[2].trim(), + type: 'loyalty', + }); + } + } + + // --- 6. REPLACEMENT EFFECTS --- + const replacementPatterns = [ + /\b(if .+ would .+, .+ instead[^.]*)/gi, + /\b(as .+ enters the battlefield[^.]*)/gi, + /\b(.+ enters the battlefield (?:tapped|with)[^.]*)/gi, + ]; + for (const re of replacementPatterns) { + let match; + re.lastIndex = 0; + while ((match = re.exec(text)) !== null) { + result.replacement.push({ + text: match[1].trim(), + type: 'replacement', + }); + } + } + + return result; +} + +/** + * Get a compact badge set for battlefield card display. + * Returns max 4 most important keyword icons. + */ +function getKeywordBadges(cardData) { + if (!cardData || !cardData.oracleText) return []; + const abilities = parseCardAbilities(cardData.oracleText, cardData.typeLine, cardData.name); + // Priority order for display (combat-relevant first) + const priorityOrder = [ + 'flying', 'deathtouch', 'lifelink', 'first strike', 'double strike', + 'trample', 'haste', 'vigilance', 'menace', 'hexproof', 'indestructible', + 'reach', 'flash', 'infect', 'toxic', 'ward', 'defender', 'shroud', + 'prowess', 'annihilator', 'cascade', 'undying', 'persist', + ]; + const badges = []; + for (const kw of priorityOrder) { + const found = abilities.keywords.find(k => k.name === kw); + if (found) badges.push(found); + if (badges.length >= 4) break; + } + // If less than 4, add remaining keywords not in priority list + if (badges.length < 4) { + for (const kw of abilities.keywords) { + if (!badges.find(b => b.name === kw.name)) { + badges.push(kw); + if (badges.length >= 4) break; + } + } + } + return badges; +} + +/** + * Convert mana symbols in text to styled HTML spans. + */ +function formatManaSymbols(text) { + if (!text) return ''; + return text.replace(/\{([^}]+)\}/g, (match) => { + const sym = MANA_DISPLAY_MAP[match]; + if (sym) { + return `${sym.text}`; + } + return `${match.replace(/[{}]/g, '')}`; + }); +} + +/** + * Format oracle text with mana symbols, line breaks, and ability highlighting. + */ +function formatOracleText(text) { + if (!text) return 'Vanilla (no abilities)'; + const escaped = text.replace(//g, '>'); + let formatted = formatManaSymbols(escaped); + // Add line breaks + formatted = formatted.replace(/\n/g, '
'); + // Highlight keyword abilities in bold + for (const kw of Object.keys(KEYWORD_ABILITIES)) { + const re = new RegExp(`\\b(${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'); + formatted = formatted.replace(re, '$1'); + } + return formatted; +} + +/** + * Build the full abilities detail panel HTML for a card. + */ +function buildAbilitiesPanel(cardData) { + const abilities = parseCardAbilities(cardData.oracleText || '', cardData.typeLine || '', cardData.name || ''); + const sections = []; + + // Card identity header + const isCreature = (cardData.typeLine || '').toLowerCase().includes('creature'); + const pt = cardData.pt || (cardData.extraData && cardData.extraData.pt) || ''; + let headerInfo = `
`; + headerInfo += `
${cardData.name || 'Unknown Card'}
`; + headerInfo += `
${cardData.typeLine || 'Unknown Type'}
`; + if (isCreature && pt) headerInfo += `
${pt}
`; + headerInfo += `
`; + sections.push(headerInfo); + + // Oracle text with formatted mana symbols + sections.push(`
${formatOracleText(cardData.oracleText)}
`); + + // Keywords + if (abilities.keywords.length > 0) { + let kwHtml = '
'; + for (const kw of abilities.keywords) { + kwHtml += `
${kw.icon}${kw.name}`; + if (kw.description) kwHtml += `${kw.description}`; + kwHtml += `
`; + } + kwHtml += '
'; + sections.push(kwHtml); + } + + // Activated abilities + if (abilities.activated.length > 0) { + let actHtml = '
'; + for (const ab of abilities.activated) { + actHtml += `
${formatManaSymbols(ab.cost)}:${formatManaSymbols(ab.effect)}
`; + } + actHtml += '
'; + sections.push(actHtml); + } + + // Mana abilities + if (abilities.mana.length > 0) { + let manaHtml = '
'; + for (const ab of abilities.mana) { + manaHtml += `
${formatManaSymbols(ab.cost)}:${formatManaSymbols(ab.effect)}
`; + } + manaHtml += '
'; + sections.push(manaHtml); + } + + // Triggered abilities + if (abilities.triggered.length > 0) { + let trigHtml = '
'; + for (const ab of abilities.triggered) { + const subLabel = ab.subType === 'etb' ? 'ETB' : ab.subType === 'ltb' ? 'LTB' : ab.subType === 'dies' ? 'Dies' : ab.subType === 'attack' ? 'Attack' : ab.subType === 'block' ? 'Block' : ab.subType === 'upkeep' ? 'Upkeep' : ab.subType === 'damage' ? 'Damage' : ab.subType === 'action' ? 'Action' : ab.subType === 'targeted' ? 'Target' : 'Trigger'; + trigHtml += `
${subLabel}${formatManaSymbols(ab.effect)}
`; + } + trigHtml += '
'; + sections.push(trigHtml); + } + + // Static abilities + if (abilities.static.length > 0) { + let statHtml = '
'; + for (const ab of abilities.static) { + const subLabel = ab.subType === 'anthem' ? 'Anthem' : ab.subType === 'cost-mod' ? 'Cost' : ab.subType === 'restriction' ? 'Rule' : ab.subType === 'buff' ? 'Buff' : ab.subType === 'global' ? 'Global' : ab.subType === 'conditional' ? 'Cond.' : 'Static'; + statHtml += `
${subLabel}${formatManaSymbols(ab.text)}
`; + } + statHtml += '
'; + sections.push(statHtml); + } + + // Loyalty abilities (planeswalkers) + if (abilities.loyalty.length > 0) { + let loyHtml = '
'; + for (const ab of abilities.loyalty) { + const isPlus = ab.cost.startsWith('+'); + const isZero = ab.cost === '0'; + const badgeClass = isPlus ? 'ap-loyalty-plus' : isZero ? 'ap-loyalty-zero' : 'ap-loyalty-minus'; + loyHtml += `
${ab.cost}${formatManaSymbols(ab.effect)}
`; + } + loyHtml += '
'; + sections.push(loyHtml); + } + + // Replacement effects + if (abilities.replacement.length > 0) { + let repHtml = '
'; + for (const ab of abilities.replacement) { + repHtml += `
${formatManaSymbols(ab.text)}
`; + } + repHtml += '
'; + sections.push(repHtml); + } + + return sections.join(''); +} + +/** + * Show the abilities detail panel positioned near a battlefield card. + */ +function showAbilitiesPanel(cardData, anchorEl) { + let panel = document.getElementById('abilities-panel'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'abilities-panel'; + panel.className = 'abilities-panel'; + document.body.appendChild(panel); + } + panel.innerHTML = '' + buildAbilitiesPanel(cardData); + panel.style.display = 'block'; + + // Position near the card + const rect = anchorEl.getBoundingClientRect(); + let x = rect.right + 12; + let y = rect.top; + // Keep within viewport + if (x + 320 > window.innerWidth) x = rect.left - 332; + if (y + 400 > window.innerHeight) y = Math.max(8, window.innerHeight - 408); + if (x < 8) x = 8; + if (y < 8) y = 8; + panel.style.left = x + 'px'; + panel.style.top = y + 'px'; + + // Dismiss on click outside + setTimeout(function() { + document.addEventListener('click', _abilitiesPanelOutsideClick); + }, 0); +} + +function _abilitiesPanelOutsideClick(e) { + var panel = document.getElementById('abilities-panel'); + if (panel && !panel.contains(e.target)) { + hideAbilitiesPanel(); + } +} + +function hideAbilitiesPanel() { + const panel = document.getElementById('abilities-panel'); + if (panel) panel.style.display = 'none'; + document.removeEventListener('click', _abilitiesPanelOutsideClick); +} + +/** + * Toggle the keyword reference panel. + */ +function toggleKeywordReference() { + let panel = document.getElementById('keyword-reference-panel'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'keyword-reference-panel'; + panel.className = 'keyword-ref-panel'; + document.body.appendChild(panel); + } + + if (panel.style.display === 'block') { + panel.style.display = 'none'; + return; + } + + // Build keyword reference content + let html = '

Keyword Reference

'; + html += '
'; + + // Sort alphabetically + const sortedKeywords = Object.entries(KEYWORD_ABILITIES).sort((a, b) => a[0].localeCompare(b[0])); + for (const [name, data] of sortedKeywords) { + if (!data.desc) continue; // Skip empty descriptions + html += `
`; + html += `${data.icon}`; + html += `${name.charAt(0).toUpperCase() + name.slice(1)}`; + html += `${data.desc}`; + html += `
`; + } + html += '
'; + + panel.innerHTML = html; + panel.style.display = 'block'; + + // Search filter + const searchInput = document.getElementById('kref-search'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + document.querySelectorAll('.kref-item').forEach(item => { + const kw = item.dataset.kw || ''; + const desc = item.querySelector('.kref-desc')?.textContent || ''; + item.style.display = (kw.includes(q) || desc.toLowerCase().includes(q)) ? '' : 'none'; + }); + }); + searchInput.focus(); + } +} + +// ============================================================ +// COMBAT SYSTEM +// ============================================================ + +/** + * Checks if a card's oracleText contains a given MTG keyword. + * Case-insensitive, matches whole words. + */ +function hasKeyword(cardData, keyword) { + if (!cardData) return false; + var kw = keyword.toLowerCase(); + // Check temporary keywords from CardScript system + if (cardData.tempKeywords && cardData.tempKeywords.length > 0) { + for (var i = 0; i < cardData.tempKeywords.length; i++) { + if (cardData.tempKeywords[i].toLowerCase() === kw) return true; + } + } + if (!cardData.oracleText) return false; + const text = cardData.oracleText.toLowerCase(); + // Match keyword as a standalone word (not substring of another word) + const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); + return re.test(text); +} + +/** Parse power as integer from a pt string like "3/4" */ +function getCreaturePower(cardData) { + if (!cardData) return 1; + // Use getEffectivePT for full calculation including counters, equipment, anthems + var ept = getEffectivePT(cardData); + if (ept) return Math.max(0, ept.power); + // Fallback if getEffectivePT returns null + var base = 0; + if (cardData.pt) { + var parts = cardData.pt.split('/'); + var val = parseInt(parts[0], 10); + if (!isNaN(val)) base = val; + } else if (cardData.power !== undefined && cardData.power !== '') { + var val2 = parseInt(cardData.power, 10); + if (!isNaN(val2)) base = val2; + } + if (cardData.powerMod) base += cardData.powerMod; + return Math.max(0, base); +} + +/** Parse toughness as integer from a pt string like "3/4" */ +function getCreatureToughness(cardData) { + if (!cardData) return 1; + // Use getEffectivePT for full calculation including counters, equipment, anthems + var ept = getEffectivePT(cardData); + if (ept) { + // If modifiers reduced toughness below base, allow 0 or negative (for SBA) + // But if no modifiers at all, floor at 1 to avoid false positives from bad data + if (ept.bonusTough < 0) { + return ept.toughness; // Allow negative — SBA should catch this + } + return Math.max(1, ept.toughness); + } + // Fallback if getEffectivePT returns null + var base = 1; + if (cardData.pt) { + var parts = cardData.pt.split('/'); + var val = parseInt(parts[1], 10); + if (!isNaN(val)) base = val; + } else if (cardData.toughness !== undefined && cardData.toughness !== '') { + var val2 = parseInt(cardData.toughness, 10); + if (!isNaN(val2)) base = val2; + } + if (cardData.toughnessMod) base += cardData.toughnessMod; + return Math.max(1, base); +} + +/** + * Summoning sickness: creature that entered this turn cannot attack + * unless it has haste. Only applies to creatures. + */ +function hasSummoningSickness(cardData) { + if (!cardData || !cardData.typeLine) return false; + if (!cardData.typeLine.toLowerCase().includes('creature')) return false; + if (!gameState) return false; + const currentKey = `${gameState.turnNumber}_${gameState.currentPlayerIndex}`; + if (cardData.turnPlayed !== currentKey) return false; + return !hasKeyword(cardData, 'haste'); +} + +// ---- Combat Overlay UI ---- + +function showCombatOverlay() { + const overlay = document.getElementById('combat-overlay'); + if (overlay) overlay.style.display = 'flex'; +} + +function hideCombatOverlay() { + const overlay = document.getElementById('combat-overlay'); + if (overlay) overlay.style.display = 'none'; +} + +/** + * Refresh the combat overlay display with current state. + */ +function refreshCombatOverlay() { + if (!gameState) return; + const combat = gameState.combat; + const phase = combat.phase; + + const phaseLabel = document.getElementById('combat-phase-label'); + const instructions = document.getElementById('combat-instructions'); + const attackersList = document.getElementById('combat-attackers-list'); + const targetSelector = document.getElementById('combat-target-selector'); + const blockersSection = document.getElementById('combat-blockers-section'); + const blockingPairs = document.getElementById('combat-blocking-pairs'); + const dmgPreview = document.getElementById('combat-damage-preview'); + const confirmBtn = document.getElementById('combat-confirm'); + const skipBtn = document.getElementById('combat-skip'); + + if (!phaseLabel) return; + + if (phase === 'declaring-attackers') { + phaseLabel.textContent = '⚔ Declare Attackers'; + instructions.textContent = 'Click your untapped creatures to declare them as attackers. Creatures with summoning sickness cannot attack.'; + blockersSection.style.display = 'none'; + dmgPreview.style.display = 'none'; + confirmBtn.textContent = '✓ Confirm Attackers'; + + // Build attackers list + if (combat.attackers.length === 0) { + attackersList.innerHTML = 'No attackers declared yet.'; + targetSelector.innerHTML = ''; + } else { + // Each attacker gets a target selector for multiplayer + attackersList.innerHTML = combat.attackers.map(a => { + const card = gameState.battlefieldCards.find(c => c.id === a.cardId); + const cardName = card ? card.name : `Card #${a.cardId}`; + const targetName = gameState.players[a.targetPlayerId]?.name || '?'; + const pt = card ? (card.pt || '?/?' ) : '?/?'; + return `
+ ⚔ + ${cardName} + ${pt} + → + + +
`; + }).join(''); + targetSelector.innerHTML = ''; + } + + } else if (phase === 'declaring-blockers') { + phaseLabel.textContent = '🛡 Declare Blockers'; + blockersSection.style.display = 'block'; + dmgPreview.style.display = 'none'; + confirmBtn.textContent = '✓ Confirm Blockers'; + if (skipBtn) skipBtn.textContent = _aiBlockerPauseState ? "\u23e9 No Blockers" : "\u23e9 Skip Combat"; + + // Show all attackers and their current blockers + const attackerGroups = {}; + combat.attackers.forEach(a => { + if (!attackerGroups[a.cardId]) attackerGroups[a.cardId] = { attacker: a, blockers: [] }; + }); + combat.blockers.forEach(b => { + if (attackerGroups[b.blockingCardId]) { + attackerGroups[b.blockingCardId].blockers.push(b.cardId); + } + }); + + const pendingId = combat.pendingBlockerFor; + const groups = Object.values(attackerGroups); + + if (groups.length === 0) { + attackersList.innerHTML = 'No attackers — proceed to damage.'; + blockingPairs.innerHTML = ''; + instructions.textContent = 'No creatures are attacking. Click Confirm to proceed.'; + targetSelector.innerHTML = ''; + } else { + const defenders = new Set(combat.attackers.map(a => a.targetPlayerId)); + const humanDefenders = [...defenders].filter(pid => !gameState.players[pid].isAI); + + if (humanDefenders.length === 0) { + instructions.textContent = 'No human players are being attacked. Click Confirm to proceed to damage.'; + } else { + instructions.textContent = `Defending players: Click an attacking creature (to select target), then click your untapped creatures to assign as blockers.`; + } + + // Summary of attackers + attackersList.innerHTML = groups.map(group => { + const atkCard = gameState.battlefieldCards.find(c => c.id === group.attacker.cardId); + const atkName = atkCard ? atkCard.name : `Attacker #${group.attacker.cardId}`; + const atkPt = atkCard ? (atkCard.pt || '?/?') : '?/?'; + const targetName = gameState.players[group.attacker.targetPlayerId]?.name || '?'; + const isPending = pendingId === group.attacker.cardId; + const blockerNames = group.blockers.map(bid => { + const bc = gameState.battlefieldCards.find(c => c.id === bid); + return bc ? bc.name : `#${bid}`; + }).join(', '); + return `
+ ⚔ + ${atkName} + ${atkPt} + → ${targetName} + ${blockerNames ? `🛡 ${blockerNames}` : 'Unblocked'} +
`; + }).join(''); + + // Pending blocker selection hint + if (pendingId !== null) { + const atkCard = gameState.battlefieldCards.find(c => c.id === pendingId); + blockingPairs.innerHTML = `
Now click one of your untapped creatures on the battlefield to block ${atkCard ? atkCard.name : 'attacker'}. Click the attacker again to deselect.
`; + } else { + blockingPairs.innerHTML = `
Click an attacking creature above to select it as the target for blocking.
`; + } + targetSelector.innerHTML = ''; + } + } + // Update SVG combat arrows + updateCombatArrows(); +} + +/** Set a specific target player for an attacker */ +function setCombatAttackerTarget(cardId, targetPlayerId) { + if (!gameState) return; + const atk = gameState.combat.attackers.find(a => a.cardId === cardId); + if (atk) atk.targetPlayerId = targetPlayerId; + refreshCombatOverlay(); +} + +/** Set attacker target from select value (supports both players and planeswalkers) */ +function setCombatAttackerTargetFromSelect(cardId, value) { + if (!gameState) return; + const atk = gameState.combat.attackers.find(a => a.cardId === cardId); + if (!atk) return; + if (value && value.startsWith('pw_')) { + // Planeswalker target + var pwId = parseInt(value.replace('pw_', ''), 10); + atk.targetPlaneswalkerCardId = pwId; + // Find which player owns that planeswalker to assign targetPlayerId + var pw = gameState.battlefieldCards.find(function(c) { return c.id === pwId; }); + if (pw) atk.targetPlayerId = pw.ownerId; + } else if (value && value.startsWith('p_')) { + // Player target + atk.targetPlaneswalkerCardId = null; + atk.targetPlayerId = parseInt(value.replace('p_', ''), 10); + } else { + // Legacy fallback + atk.targetPlaneswalkerCardId = null; + atk.targetPlayerId = parseInt(value, 10); + } + refreshCombatOverlay(); + updateCombatArrows(); +} + +/** Select an attacker as target for pending blocker assignment */ +function selectPendingBlockerTarget(atkCardId) { + if (!gameState) return; + const combat = gameState.combat; + if (combat.phase !== 'declaring-blockers') return; + + if (combat.pendingBlockerFor === atkCardId) { + // Deselect + combat.pendingBlockerFor = null; + document.querySelectorAll('.battlefield-card.attacker-selected').forEach(el => el.classList.remove('attacker-selected')); + } else { + combat.pendingBlockerFor = atkCardId; + // Highlight the selected attacker card on the battlefield + document.querySelectorAll('.battlefield-card.attacker-selected').forEach(el => el.classList.remove('attacker-selected')); + const atkEl = document.getElementById(`bc-${atkCardId}`); + if (atkEl) atkEl.classList.add('attacker-selected'); + } + refreshCombatOverlay(); +} + +// ---- Attacker Declaration ---- + +/** + * Enter the Declare Attackers phase — show overlay, set state. + */ +function startAttackerDeclaration() { + if (!gameState) return; + gameState.combat.phase = 'declaring-attackers'; + gameState.combat.attackers = []; + gameState.combat.blockers = []; + gameState.combat.damageAssignments = []; + gameState.combat.pendingBlockerFor = null; + gameState.combat.damagePreview = null; + + // Update the battlefield — mark/unmark cards visually + clearCombatVisuals(); + + // Prevent tapped creatures from being toggled as attacker + showCombatOverlay(); + refreshCombatOverlay(); + addLogEntry(`⚔ ${gameState.players[gameState.currentPlayerIndex].name} may now declare attackers.`); +} + +/** + * Toggle a creature as an attacker. Handles flying/menace enforcement, + * summoning sickness check, and vigliiance. + */ +function toggleAttacker(cardId) { + if (!gameState) return; + const combat = gameState.combat; + if (combat.phase !== 'declaring-attackers') { + // Allow via context menu even outside formal phase (casual use) + // But only if we're in the attack declaration conceptually + return; + } + + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card) return; + + // Must be a creature owned by the active player + if (!aiIsCreature(card)) return; + if (card.ownerId !== gameState.currentPlayerIndex) return; + + // Can't attack while tapped + if (card.tapped) { + addLogEntry(`⚠ ${card.name} is tapped and cannot attack.`); + return; + } + + // Defender: cannot attack + if (hasKeyword(card, 'defender')) { + addLogEntry(`⚠ ${card.name} has Defender and cannot attack.`); + return; + } + + // Summoning sickness check + if (hasSummoningSickness(card)) { + addLogEntry(`⚠ ${card.name} has summoning sickness and cannot attack.`); + return; + } + + const existingIdx = combat.attackers.findIndex(a => a.cardId === cardId); + const cardEl = document.getElementById(`bc-${cardId}`); + + if (existingIdx !== -1) { + // Remove from attackers + combat.attackers.splice(existingIdx, 1); + if (cardEl) cardEl.classList.remove('attacking'); + addLogEntry(`${card.name} removed from attackers.`); + } else { + // Find a default target (first non-active, non-eliminated player) + let defaultTarget = -1; + gameState.players.forEach((p, i) => { + if (i !== gameState.currentPlayerIndex && !p.eliminated && defaultTarget === -1) { + defaultTarget = i; + } + }); + if (defaultTarget === -1) return; + + combat.attackers.push({ cardId, targetPlayerId: defaultTarget }); + if (cardEl) cardEl.classList.add('attacking'); + addLogEntry(`⚔ ${card.name} declared as attacker.`); + } + + refreshCombatOverlay(); + updateCombatArrows(); +} + +/** + * Confirm the attacker declaration and advance to Blockers phase. + */ +function confirmAttackers() { + if (!gameState) return; + const combat = gameState.combat; + + // Tap all attackers (unless vigilance) + combat.attackers.forEach(a => { + const card = gameState.battlefieldCards.find(c => c.id === a.cardId); + if (!card) return; + if (!hasKeyword(card, 'vigilance')) { + card.tapped = true; + const el = document.getElementById(`bc-${a.cardId}`); + if (el) { el.classList.add('tapped'); el.classList.add('attacking'); } + } + }); + + // Fire attack triggers + fireBattlefieldTriggers('attack'); + + if (combat.attackers.length === 0) { + addLogEntry('⚔ No attackers declared. Skipping to End of Combat.'); + skipCombat(); + return; + } + + const names = combat.attackers.map(a => { + const c = gameState.battlefieldCards.find(cc => cc.id === a.cardId); + const t = gameState.players[a.targetPlayerId]?.name || '?'; + return `${c ? c.name : '#' + a.cardId} → ${t}`; + }).join(', '); + addLogEntry(`⚔ Attackers declared: ${names}`); + + // Advance to blockers sub-phase + gameState.combatPhaseIndex = 2; // 'blockers' + combat.phase = 'declaring-blockers'; + combat.pendingBlockerFor = null; + + // Run AI blockers immediately for any AI defenders + aiAssignBlockers(); + + refreshCombatOverlay(); + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + updateCombatArrows(); +} + +/** + * Enter the Declare Blockers phase for human defenders. + */ +function startBlockerDeclaration() { + if (!gameState) return; + const combat = gameState.combat; + combat.phase = 'declaring-blockers'; + combat.pendingBlockerFor = null; + + // Let AI automatically assign blockers for AI defenders + aiAssignBlockers(); + + showCombatOverlay(); + refreshCombatOverlay(); +} + +/** + * Toggle a blocker: assign it to block the current pending attacker. + */ +function toggleBlocker(blockerCardId, attackerCardId) { + if (!gameState) return; + const combat = gameState.combat; + + const blocker = gameState.battlefieldCards.find(c => c.id === blockerCardId); + const attacker = gameState.battlefieldCards.find(c => c.id === attackerCardId); + if (!blocker || !attacker) return; + + // Can't block while tapped + if (blocker.tapped) { + addLogEntry(`⚠ ${blocker.name} is tapped and cannot block.`); + return; + } + + // Flying restriction: only flying/reach can block flying + if (hasKeyword(attacker, 'flying')) { + if (!hasKeyword(blocker, 'flying') && !hasKeyword(blocker, 'reach')) { + addLogEntry(`⚠ ${blocker.name} cannot block ${attacker.name} (has Flying).`); + return; + } + } + + // Shadow: can only be blocked by creatures with shadow + if (hasKeyword(attacker, 'shadow')) { + if (!hasKeyword(blocker, 'shadow')) { + addLogEntry(`⚠ ${blocker.name} cannot block ${attacker.name} (has Shadow — only shadow creatures can block).`); + return; + } + } + // Shadow blocker: cannot block non-shadow attackers + if (hasKeyword(blocker, 'shadow') && !hasKeyword(attacker, 'shadow')) { + addLogEntry(`⚠ ${blocker.name} (Shadow) cannot block ${attacker.name} — shadow creatures can only block other shadow creatures.`); + return; + } + + // Intimidate: can only be blocked by artifact creatures or creatures sharing a color + if (hasKeyword(attacker, 'intimidate')) { + var blockerIsArtifactCr = cardIsArtifactCreature(blocker); + var sharesColor = false; + if (attacker.colors && Array.isArray(attacker.colors)) { + attacker.colors.forEach(function(col) { + if (cardHasColor(blocker, col)) sharesColor = true; + }); + } + if (!blockerIsArtifactCr && !sharesColor) { + addLogEntry(`⚠ ${blocker.name} cannot block ${attacker.name} (Intimidate — must share a color or be an artifact creature).`); + return; + } + } + + // Fear: can only be blocked by artifact creatures or black creatures + if (hasKeyword(attacker, 'fear')) { + var blockerIsArtifactCrFear = cardIsArtifactCreature(blocker); + var blockerIsBlack = cardHasColor(blocker, 'B'); + if (!blockerIsArtifactCrFear && !blockerIsBlack) { + addLogEntry(`⚠ ${blocker.name} cannot block ${attacker.name} (Fear — must be artifact or black creature).`); + return; + } + } + + // Protection: attacker with protection from [quality] can't be blocked by matching creatures + if (protectionPreventsBlocking(attacker, blocker)) { + var protList = parseProtectionQualities(attacker).join(', '); + addLogEntry('\u26a0 ' + blocker.name + ' cannot block ' + attacker.name + ' (protection from ' + protList + ').'); + return; + } + + // Check if already blocking this attacker + const existingIdx = combat.blockers.findIndex( + b => b.cardId === blockerCardId && b.blockingCardId === attackerCardId + ); + + const cardEl = document.getElementById(`bc-${blockerCardId}`); + + if (existingIdx !== -1) { + // Remove blocker + combat.blockers.splice(existingIdx, 1); + if (cardEl) cardEl.classList.remove('blocking'); + addLogEntry(`${blocker.name} removed from blockers.`); + } else { + // Remove any previous assignment of this blocker (it can only block one attacker) + const prevIdx = combat.blockers.findIndex(b => b.cardId === blockerCardId); + if (prevIdx !== -1) { + combat.blockers.splice(prevIdx, 1); + } + combat.blockers.push({ cardId: blockerCardId, blockingCardId: attackerCardId }); + if (cardEl) cardEl.classList.add('blocking'); + addLogEntry(`🛡 ${blocker.name} blocks ${attacker.name}.`); + } + + // Clear the pending selection after assignment + combat.pendingBlockerFor = null; + document.querySelectorAll('.battlefield-card.attacker-selected').forEach(el => el.classList.remove('attacker-selected')); + + // Fire block triggers + fireBattlefieldTriggers('block'); + + refreshCombatOverlay(); + updateCombatArrows(); +} + +/** + * Confirm blockers and advance to Damage step. + */ +function confirmBlockers() { + if (!gameState) return; + const combat = gameState.combat; + + // Menace enforcement: attacker with menace must be blocked by 2+ creatures + var menaceViolator = checkMenaceViolation(); + if (menaceViolator) { + // Show warning and prevent confirm + var warningId = 'menace-warning-' + Date.now(); + var existing = document.getElementById('combat-menace-warning'); + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + var warn = document.createElement('div'); + warn.id = 'combat-menace-warning'; + warn.className = 'combat-menace-warning'; + warn.innerHTML = '⚠️ ' + escapeHtml(menaceViolator.name) + ' has Menace — must be blocked by 2 or more creatures, or left unblocked.'; + var blockersSection = document.getElementById('combat-blockers-section'); + if (blockersSection) { + blockersSection.insertBefore(warn, blockersSection.firstChild); + } + addLogEntry('⚠ Menace: ' + escapeHtml(menaceViolator.name) + ' must be blocked by 2+ creatures or left unblocked.'); + setTimeout(function() { + var w = document.getElementById('combat-menace-warning'); + if (w && w.parentNode) w.parentNode.removeChild(w); + }, 4000); + return; // Block confirm + } + + const summary = combat.blockers.length > 0 + ? combat.blockers.map(b => { + const blocker = gameState.battlefieldCards.find(c => c.id === b.cardId); + const attacker = gameState.battlefieldCards.find(c => c.id === b.blockingCardId); + return `${blocker?.name || '#' + b.cardId} blocks ${attacker?.name || '#' + b.blockingCardId}`; + }).join('; ') + : 'No blockers assigned.'; + addLogEntry(`🛡 Blockers confirmed: ${summary}`); + + // If this was during an AI turn (human blocking AI attack), resume AI turn + if (_aiBlockerPauseState) { + hideCombatOverlay(); + combat.phase = null; + var resumeFn = _aiBlockerPauseState.resumeFn; + // Small delay so blocker log is visible before damage + setTimeout(function() { resumeFn(); }, 400); + return; + } + + // Advance to damage sub-phase (normal human-turn flow) + gameState.combatPhaseIndex = 3; // 'damage' + renderPhaseTracker(); + addLogEntry(`Phase: ${getCurrentPhaseName()}`); + + // Show damage preview instead of auto-resolving + showDamagePreview(); + updateCombatArrows(); +} + +// ---- AI Blocker Assignment ---- + +/** + * AI players that are defenders will automatically assign blockers. + * Strategy: block biggest threat with smallest creature that can survive, + * or chump-block if outmatched. + */ +function aiAssignBlockers() { + if (!gameState) return; + const combat = gameState.combat; + + // Find all unique defenders + const defenderSet = new Set(combat.attackers.map(a => a.targetPlayerId)); + + defenderSet.forEach(defIdx => { + const defender = gameState.players[defIdx]; + if (!defender || !defender.isAI) return; + + // Get untapped creatures belonging to this AI defender + const myCreatures = gameState.battlefieldCards.filter(c => + c.ownerId === defIdx && aiIsCreature(c) && !c.tapped + ); + + // Get attackers targeting this defender + const incomingAttackers = combat.attackers + .filter(a => a.targetPlayerId === defIdx) + .map(a => gameState.battlefieldCards.find(c => c.id === a.cardId)) + .filter(Boolean) + .sort((a, b) => getCreaturePower(b) - getCreaturePower(a)); // Biggest threat first + + const usedBlockers = new Set(); + + incomingAttackers.forEach(attacker => { + // Skip flying attackers if we have no flyers/reach + if (hasKeyword(attacker, 'flying')) { + const canBlock = myCreatures.find(b => !usedBlockers.has(b.id) && + (hasKeyword(b, 'flying') || hasKeyword(b, 'reach'))); + if (!canBlock) return; + } + + const atkPow = getCreaturePower(attacker); + const atkTou = getCreatureToughness(attacker); + + // Find best blocker: smallest that can survive OR just chump + const available = myCreatures.filter(b => + !usedBlockers.has(b.id) && + (!hasKeyword(attacker, 'flying') || hasKeyword(b, 'flying') || hasKeyword(b, 'reach')) + ); + + if (available.length === 0) return; + + // Prefer a blocker that can survive + let chosen = available.find(b => { + const bTou = getCreatureToughness(b); + return hasKeyword(attacker, 'deathtouch') ? false : bTou > atkPow; + }); + + // Fall back to chump-block if total damage would kill the AI + if (!chosen && defender.life <= atkPow) { + chosen = available[0]; // Chump block + } + + if (chosen) { + usedBlockers.add(chosen.id); + combat.blockers.push({ cardId: chosen.id, blockingCardId: attacker.id }); + const el = document.getElementById(`bc-${chosen.id}`); + if (el) el.classList.add('blocking'); + addLogEntry(`🤖 🛡 ${defender.name} blocks ${attacker.name} with ${chosen.name}.`); + } + }); + }); +} + +// ---- Combat Damage Resolution ---- + +/** + * Full combat damage resolution with keyword ability handling. + */ +function resolveCombatDamage() { + if (!gameState) return; + const combat = gameState.combat; + if (combat.attackers.length === 0) return; + + // Map blockerCardId -> list of blocker cards for each attacker + const blockerMap = {}; // attackerCardId -> [blockerCard, ...] + combat.blockers.forEach(b => { + if (!blockerMap[b.blockingCardId]) blockerMap[b.blockingCardId] = []; + const bc = gameState.battlefieldCards.find(c => c.id === b.cardId); + if (bc) blockerMap[b.blockingCardId].push(bc); + }); + + // Damage tracking: cardId -> damage received this combat + const damageReceived = {}; + // Track which cards received deathtouch damage (any damage is lethal) + const deathtouchTargets = new Set(); + + const logLines = []; + + // --- First Strike damage pass --- + const firstStrikeAttackers = combat.attackers.filter(a => { + const c = gameState.battlefieldCards.find(cc => cc.id === a.cardId); + return c && (hasKeyword(c, 'first strike') || hasKeyword(c, 'double strike')); + }); + + // Process first strike attackers + firstStrikeAttackers.forEach(a => { + processCombatant(a, blockerMap, damageReceived, deathtouchTargets, logLines, true); + }); + + // --- Normal damage pass --- + combat.attackers.forEach(a => { + // Double-strike already dealt first-strike damage, also deal normal + // First-strikers only dealt first-strike damage (skip normal unless double-strike) + const c = gameState.battlefieldCards.find(cc => cc.id === a.cardId); + if (!c) return; + const isFirstStrike = hasKeyword(c, 'first strike') && !hasKeyword(c, 'double strike'); + if (isFirstStrike) return; // Already handled + processCombatant(a, blockerMap, damageReceived, deathtouchTargets, logLines, false); + }); + + // --- Check for lethal damage and send creatures to graveyard --- + // Also mark damage on creatures using the new damage marking system + Object.entries(damageReceived).forEach(([cardId, dmg]) => { + const id = parseInt(cardId, 10); + const card = gameState.battlefieldCards.find(c => c.id === id); + if (card && dmg > 0) { + // Use markDamage to track damage (skip replacement since already applied via combat) + card.damageMarked = (card.damageMarked || 0) + dmg; + var dmgEl = document.getElementById('bc-' + id); + if (dmgEl) { + var dmgBadge = dmgEl.querySelector('.damage-badge'); + if (!dmgBadge) { + dmgBadge = document.createElement('div'); + dmgBadge.className = 'damage-badge'; + dmgEl.appendChild(dmgBadge); + } + dmgBadge.textContent = card.damageMarked; + dmgBadge.style.display = 'flex'; + } + } + }); + + const toKill = []; + Object.entries(damageReceived).forEach(([cardId, dmg]) => { + const id = parseInt(cardId, 10); + const card = gameState.battlefieldCards.find(c => c.id === id); + if (!card) return; + if (hasKeyword(card, 'indestructible')) return; // Indestructible can't die from damage + + const toughness = getCreatureToughness(card); + // A creature dies if it received damage >= toughness, + // OR if it received ANY damage from a source with deathtouch + const isLethal = deathtouchTargets.has(id) + ? dmg > 0 + : dmg >= toughness; + + if (isLethal) { + toKill.push(id); + logLines.push(`☠ ${card.name} is destroyed (${dmg} damage, ${toughness} toughness).`); + } + }); + + // Log all combat results + logLines.forEach(line => addLogEntry(line)); + + // Send killed creatures to graveyard (after logging) + toKill.forEach(id => { + // Remove from combat state too + combat.attackers = combat.attackers.filter(a => a.cardId !== id); + combat.blockers = combat.blockers.filter(b => b.cardId !== id && b.blockingCardId !== id); + moveCardToZone(id, 'graveyard'); + }); + + // Fire damage triggers + if (damageReceived && Object.keys(damageReceived).length > 0) { + fireBattlefieldTriggers('damage'); + } + + renderPlayerPanels(); + checkStateBasedActions(); +} + +/** + * Process one attacker's damage assignment. + * @param {object} a - attacker entry {cardId, targetPlayerId} + * @param {object} blockerMap - attackerCardId -> [blockerCards] + * @param {object} damageReceived - mutable map: cardId -> total damage + * @param {Set} deathtouchTargets - mutable set of cardIds that received deathtouch damage + * @param {string[]} logLines - log entries to append + * @param {boolean} firstStrikePass - true if this is the first-strike damage pass + */ +function processCombatant(a, blockerMap, damageReceived, deathtouchTargets, logLines, firstStrikePass) { + const attacker = gameState.battlefieldCards.find(c => c.id === a.cardId); + if (!attacker) return; + + const atkPow = getCreaturePower(attacker); + const blockers = blockerMap[a.cardId] || []; + const targetPlayer = gameState.players[a.targetPlayerId]; + const hasDeathtouch = hasKeyword(attacker, 'deathtouch'); + const hasTrample = hasKeyword(attacker, 'trample'); + const hasLifelink = hasKeyword(attacker, 'lifelink'); + const controller = gameState.players[attacker.ownerId]; + + if (blockers.length === 0) { + // Unblocked — deal damage to defending player + if (atkPow > 0 && targetPlayer && !targetPlayer.eliminated) { + logLines.push(`⚔ ${attacker.name} (${atkPow} power) deals ${atkPow} damage to ${targetPlayer.name} — unblocked.`); + adjustLife(a.targetPlayerId, -atkPow); + // Check if Monarch was hit by combat damage + checkMonarchCombatDamage(attacker, a.targetPlayerId); + // Track commander damage + if (isCommander(attacker)) { + adjustCommanderDamage(a.targetPlayerId, attacker.ownerId, atkPow); + logLines.push(`Commander damage: ${attacker.name} dealt ${atkPow} commander damage to ${targetPlayer.name}.`); + } + if (hasLifelink && controller) { + adjustLife(attacker.ownerId, atkPow); + logLines.push(`♥ Lifelink: ${controller.name} gains ${atkPow} life.`); + } + } + } else { + // Blocked — divide damage among blockers + let remainingPow = atkPow; + + blockers.forEach((blocker, idx) => { + if (!gameState.battlefieldCards.find(c => c.id === blocker.id)) return; // Already dead + const blockerTou = getCreatureToughness(blocker); + const lethalDmg = hasDeathtouch ? 1 : blockerTou; + const dmgToBlocker = Math.min(remainingPow, lethalDmg); + + if (dmgToBlocker > 0) { + // Protection: blocker with protection from attacker's colors prevents damage + var blockerProt = parseProtectionQualities(blocker); + if (protectionBlocksSource(blockerProt, attacker)) { + logLines.push('\ud83d\udd12 ' + blocker.name + ' has protection \u2014 damage prevented from ' + attacker.name + '.'); + remainingPow -= dmgToBlocker; + } else { + damageReceived[blocker.id] = (damageReceived[blocker.id] || 0) + dmgToBlocker; + if (hasDeathtouch) deathtouchTargets.add(blocker.id); + remainingPow -= dmgToBlocker; + logLines.push('\u2694 ' + attacker.name + ' deals ' + dmgToBlocker + '' + (hasDeathtouch ? ' (deathtouch)' : '') + ' damage to ' + blocker.name + '.'); + if (hasLifelink && controller) { + adjustLife(attacker.ownerId, dmgToBlocker); + logLines.push('\u2665 Lifelink: ' + controller.name + ' gains ' + dmgToBlocker + ' life.'); + } + } + } + // Blocker deals damage back to attacker + const blockerPow = getCreaturePower(blocker); + const blockerHasDeathtouch = hasKeyword(blocker, 'deathtouch'); + const isBlockerFirstStrike = hasKeyword(blocker, 'first strike') || hasKeyword(blocker, 'double strike'); + // Only deal blocker damage on same pass logic + const shouldDealNow = firstStrikePass ? isBlockerFirstStrike : !hasKeyword(blocker, 'first strike'); + if (shouldDealNow && blockerPow > 0) { + // Protection: attacker with protection from blocker prevents blocker's damage + var atkProt = parseProtectionQualities(attacker); + if (protectionBlocksSource(atkProt, blocker)) { + logLines.push('\ud83d\udd12 ' + attacker.name + ' has protection \u2014 damage prevented from ' + blocker.name + '.'); + } else { + damageReceived[attacker.id] = (damageReceived[attacker.id] || 0) + blockerPow; + if (blockerHasDeathtouch) deathtouchTargets.add(attacker.id); + logLines.push(`🛡 ${blocker.name} deals ${blockerPow}${blockerHasDeathtouch ? ' (deathtouch)' : ''} damage to ${attacker.name}.`); + if (hasKeyword(blocker, 'lifelink')) { + const blockerOwner = gameState.players[blocker.ownerId]; + if (blockerOwner) { + adjustLife(blocker.ownerId, blockerPow); + logLines.push(`♥ Lifelink: ${blockerOwner.name} gains ${blockerPow} life.`); + } + } + } // end protection else + } + }); + + // Trample: excess damage goes to player + if (hasTrample && remainingPow > 0 && targetPlayer && !targetPlayer.eliminated) { + logLines.push(`🟥 Trample: ${attacker.name} deals ${remainingPow} excess damage to ${targetPlayer.name}.`); + adjustLife(a.targetPlayerId, -remainingPow); + // Check if Monarch was hit by trample damage + checkMonarchCombatDamage(attacker, a.targetPlayerId); + // Track commander damage from trample overflow + if (isCommander(attacker)) { + adjustCommanderDamage(a.targetPlayerId, attacker.ownerId, remainingPow); + logLines.push(`Commander damage: ${attacker.name} dealt ${remainingPow} trample commander damage to ${targetPlayer.name}.`); + } + if (hasLifelink && controller) { + adjustLife(attacker.ownerId, remainingPow); + logLines.push(`♥ Lifelink: ${controller.name} gains ${remainingPow} life.`); + } + } + } +} + +/** + * Skip combat entirely — advance past all combat phases. + */ +function skipCombat() { + if (!gameState) return; + + // If skipping during AI turn's blocker pause, resume AI turn (resolve damage with no blockers) + if (_aiBlockerPauseState) { + var resumeState = _aiBlockerPauseState; + _aiBlockerPauseState = null; + hideCombatOverlay(); + gameState.combat.phase = null; + addLogEntry('\u{1F6E1} No blockers declared.'); + // Resume the AI turn flow (which resolves damage, then post-combat) + setTimeout(function() { resumeState.resumeFn(); }, 400); + return; + } + + endCombat(); + gameState.inCombat = false; + gameState.combatPhaseIndex = 0; + gameState.currentPhaseIndex = 3; // Main Phase 2 + hideCombatOverlay(); + emptyAllManaPools(); + renderPhaseTracker(); + addLogEntry('Combat skipped. Moving to Main Phase 2.'); + showShortcutHint(); +} + +/** + * Clean up all combat state and visuals. + */ +function endCombat() { + if (!gameState) return; + clearCombatVisuals(); + gameState.combat = { + attackers: [], + blockers: [], + damageAssignments: [], + phase: null, + pendingBlockerFor: null, + damagePreview: null, + }; + hideCombatOverlay(); +} + +/** + * Remove all combat visual classes from battlefield cards. + */ +function clearCombatVisuals() { + document.querySelectorAll('.battlefield-card.attacking').forEach(el => el.classList.remove('attacking')); + document.querySelectorAll('.battlefield-card.blocking').forEach(el => el.classList.remove('blocking')); + document.querySelectorAll('.battlefield-card.attacker-selected').forEach(el => el.classList.remove('attacker-selected')); + document.querySelectorAll('.battlefield-card.summoning-sick').forEach(el => el.classList.remove('summoning-sick')); + clearCombatArrows(); +} + +/** + * Inline onclick handler for the combat confirm button. + * Routes to the correct action based on current combat phase. + */ +function handleCombatConfirm() { + if (!gameState) return; + const phase = gameState.combat.phase; + if (phase === 'declaring-attackers') { + confirmAttackers(); + } else if (phase === 'declaring-blockers') { + confirmBlockers(); + } else if (phase === 'previewing-damage') { + applyDamageFromPreview(); + } +} + +/** + * Initialize combat overlay (legacy stub — buttons now use inline onclick). + */ +function initCombatOverlay() { + // Buttons now use inline onclick handlers (handleCombatConfirm, skipCombat) +} + +// ============================================================ +// COMBAT DAMAGE CALCULATOR / PREVIEW +// ============================================================ + +/** + * Build the damage preview data from current attackers/blockers. + * Returns an array of matchup objects describing each attacker's + * damage assignments, keyword interactions, and outcomes. + */ +function buildDamagePreviewData() { + if (!gameState) return []; + const combat = gameState.combat; + const matchups = []; + + // Build blocker map: attackerCardId -> [blockerCard, ...] + const blockerMap = {}; + combat.blockers.forEach(b => { + if (!blockerMap[b.blockingCardId]) blockerMap[b.blockingCardId] = []; + const bc = gameState.battlefieldCards.find(c => c.id === b.cardId); + if (bc) blockerMap[b.blockingCardId].push(bc); + }); + + combat.attackers.forEach(a => { + const attacker = gameState.battlefieldCards.find(c => c.id === a.cardId); + if (!attacker) return; + + const atkPow = getCreaturePower(attacker); + const atkTou = getCreatureToughness(attacker); + const blockers = blockerMap[a.cardId] || []; + const targetPlayer = gameState.players[a.targetPlayerId]; + const controller = gameState.players[attacker.ownerId]; + + const atkKeywords = { + deathtouch: hasKeyword(attacker, 'deathtouch'), + trample: hasKeyword(attacker, 'trample'), + lifelink: hasKeyword(attacker, 'lifelink'), + firstStrike: hasKeyword(attacker, 'first strike'), + doubleStrike: hasKeyword(attacker, 'double strike'), + infect: hasKeyword(attacker, 'infect'), + indestructible: hasKeyword(attacker, 'indestructible'), + }; + atkKeywords.isCommander = !!attacker.isCommander; + + const matchup = { + attackerId: a.cardId, + attackerName: attacker.name, + attackerPt: attacker.pt || '?/?', + attackerPower: atkPow, + attackerToughness: atkTou, + attackerKeywords: atkKeywords, + attackerOwnerId: attacker.ownerId, + targetPlayerId: a.targetPlayerId, + targetPlaneswalkerCardId: a.targetPlaneswalkerCardId || null, + targetPlayerName: targetPlayer ? targetPlayer.name : '?', + targetPlayerLife: targetPlayer ? targetPlayer.life : 0, + isBlocked: blockers.length > 0, + blockerDetails: [], + // Damage assignments (editable for multi-blocker) + damageToBlockers: [], // array parallel to blockerDetails: dmg assigned to each + damageToPlayer: 0, // damage going through to player (unblocked or trample) + // Blocker damage back to attacker + damageToAttacker: 0, + // Outcomes + attackerDies: false, + playerLifeChange: 0, + playerPoisonChange: 0, + commanderDamage: 0, + lifelinkGain: 0, + }; + + if (blockers.length === 0) { + // Unblocked + matchup.damageToPlayer = atkPow; + matchup.playerLifeChange = atkKeywords.infect ? 0 : -atkPow; + matchup.playerPoisonChange = atkKeywords.infect ? atkPow : 0; + if (atkKeywords.isCommander) matchup.commanderDamage = atkPow; + if (atkKeywords.lifelink) matchup.lifelinkGain = atkPow; + if (atkKeywords.doubleStrike) { + matchup.damageToPlayer = atkPow * 2; + matchup.playerLifeChange = atkKeywords.infect ? 0 : -(atkPow * 2); + matchup.playerPoisonChange = atkKeywords.infect ? atkPow * 2 : 0; + if (atkKeywords.isCommander) matchup.commanderDamage = atkPow * 2; + if (atkKeywords.lifelink) matchup.lifelinkGain = atkPow * 2; + } + } else { + // Blocked — assign damage to each blocker + let remainingPow = atkPow; + let totalBlockerDmgBack = 0; + + blockers.forEach((blocker, idx) => { + const bTou = getCreatureToughness(blocker); + const bPow = getCreaturePower(blocker); + const bKeywords = { + deathtouch: hasKeyword(blocker, 'deathtouch'), + firstStrike: hasKeyword(blocker, 'first strike'), + doubleStrike: hasKeyword(blocker, 'double strike'), + lifelink: hasKeyword(blocker, 'lifelink'), + indestructible: hasKeyword(blocker, 'indestructible'), + infect: hasKeyword(blocker, 'infect'), + }; + + const lethalDmg = atkKeywords.deathtouch ? 1 : bTou; + const autoAssign = Math.min(remainingPow, lethalDmg); + + matchup.blockerDetails.push({ + cardId: blocker.id, + name: blocker.name, + pt: blocker.pt || '?/?', + power: bPow, + toughness: bTou, + keywords: bKeywords, + minLethal: lethalDmg, + }); + matchup.damageToBlockers.push(autoAssign); + + remainingPow -= autoAssign; + totalBlockerDmgBack += bPow; + }); + + // Trample excess to player + if (atkKeywords.trample && remainingPow > 0) { + matchup.damageToPlayer = remainingPow; + matchup.playerLifeChange = atkKeywords.infect ? 0 : -remainingPow; + matchup.playerPoisonChange = atkKeywords.infect ? remainingPow : 0; + if (atkKeywords.isCommander) matchup.commanderDamage = remainingPow; + } + + matchup.damageToAttacker = totalBlockerDmgBack; + if (atkKeywords.doubleStrike) { + // Double strike: all blocker damage assignments double + matchup.damageToBlockers = matchup.damageToBlockers.map(d => d * 2); + if (matchup.damageToPlayer > 0) { + matchup.damageToPlayer *= 2; + matchup.playerLifeChange *= 2; + matchup.playerPoisonChange *= 2; + matchup.commanderDamage *= 2; + } + } + + // Compute lifelink on total damage dealt + if (atkKeywords.lifelink) { + const totalDealt = matchup.damageToBlockers.reduce((s, d) => s + d, 0) + matchup.damageToPlayer; + matchup.lifelinkGain = totalDealt; + } + + // Does attacker die from blockers? + const anyBlockerDeathtouch = blockers.some(b => hasKeyword(b, 'deathtouch')); + matchup.attackerDies = !atkKeywords.indestructible && + (anyBlockerDeathtouch ? totalBlockerDmgBack > 0 : totalBlockerDmgBack >= atkTou); + } + + matchups.push(matchup); + }); + + return matchups; +} + +/** + * Render the damage preview panel in the combat overlay. + * Shows each matchup with keyword badges, damage assignments, + * and editable sliders for multi-blocker scenarios. + */ +function showDamagePreview() { + if (!gameState) return; + const combat = gameState.combat; + combat.phase = 'previewing-damage'; + + // Build preview data and stash on combat state + combat.damagePreview = buildDamagePreviewData(); + + const previewEl = document.getElementById('combat-damage-preview'); + const phaseLabel = document.getElementById('combat-phase-label'); + const instructions = document.getElementById('combat-instructions'); + const confirmBtn = document.getElementById('combat-confirm'); + const attackersList = document.getElementById('combat-attackers-list'); + const blockersSection = document.getElementById('combat-blockers-section'); + + if (phaseLabel) phaseLabel.textContent = '\u2694\uFE0F Combat Damage Preview'; + if (instructions) instructions.textContent = 'Review damage assignments. Adjust damage distribution for multi-blocker matchups, then confirm to resolve.'; + if (confirmBtn) confirmBtn.textContent = '\u2620 Resolve Damage'; + if (attackersList) attackersList.innerHTML = ''; + if (blockersSection) blockersSection.style.display = 'none'; + + if (!previewEl) return; + previewEl.style.display = 'block'; + + renderDamagePreviewContent(); + showCombatOverlay(); + updateCombatArrows(); +} + +/** + * Render the inner content of the damage preview panel. + */ +function renderDamagePreviewContent() { + const previewEl = document.getElementById('combat-damage-preview'); + if (!previewEl || !gameState) return; + + const matchups = gameState.combat.damagePreview; + if (!matchups || matchups.length === 0) { + previewEl.innerHTML = '
No combat damage to resolve.
'; + return; + } + + // Compute summary totals + const lifeSummary = {}; // playerId -> net change + const poisonSummary = {}; + const cmdDmgSummary = {}; // "targetId-sourceId" -> amount + const lifelinkSummary = {}; // ownerId -> gain + const deaths = []; + + matchups.forEach((m, mi) => { + // Player damage + if (m.playerLifeChange !== 0) { + lifeSummary[m.targetPlayerId] = (lifeSummary[m.targetPlayerId] || 0) + m.playerLifeChange; + } + if (m.playerPoisonChange > 0) { + poisonSummary[m.targetPlayerId] = (poisonSummary[m.targetPlayerId] || 0) + m.playerPoisonChange; + } + if (m.commanderDamage > 0) { + const key = `${m.targetPlayerId}-${m.attackerOwnerId}`; + cmdDmgSummary[key] = (cmdDmgSummary[key] || 0) + m.commanderDamage; + } + if (m.lifelinkGain > 0) { + lifelinkSummary[m.attackerOwnerId] = (lifelinkSummary[m.attackerOwnerId] || 0) + m.lifelinkGain; + } + // Creature deaths + if (m.attackerDies) deaths.push(m.attackerName); + m.blockerDetails.forEach((b, bi) => { + const dmg = m.damageToBlockers[bi] || 0; + const lethal = m.attackerKeywords.deathtouch ? (dmg > 0) : (dmg >= b.toughness); + if (lethal && !b.keywords.indestructible) deaths.push(b.name); + }); + }); + + let html = '
'; + + // --- Matchup cards --- + matchups.forEach((m, mi) => { + const atkKwBadges = buildKeywordBadges(m.attackerKeywords); + + html += `
`; + html += `
`; + html += `\u2694 ${escapeHtml(m.attackerName)} ${escapeHtml(m.attackerPt)}${atkKwBadges}`; + + if (!m.isBlocked) { + html += `\u2192${escapeHtml(m.targetPlayerName)} (${m.targetPlayerLife}\u2764)`; + } + html += `
`; // header end + + if (m.isBlocked) { + // Show blocker assignments + html += `
`; + const totalDmgPool = m.attackerKeywords.doubleStrike ? m.attackerPower * 2 : m.attackerPower; + const usedDmg = m.damageToBlockers.reduce((s, d) => s + d, 0); + + m.blockerDetails.forEach((b, bi) => { + const dmg = m.damageToBlockers[bi] || 0; + const isLethal = m.attackerKeywords.deathtouch ? (dmg > 0) : (dmg >= b.toughness); + const willDie = isLethal && !b.keywords.indestructible; + const bKwBadges = buildKeywordBadges(b.keywords); + + html += `
`; + html += `\uD83D\uDEE1 ${escapeHtml(b.name)} ${escapeHtml(b.pt)}${bKwBadges}`; + + if (m.blockerDetails.length > 1) { + // Editable damage for multi-blocker + html += ``; + html += ``; + html += `${dmg}`; + html += ``; + html += ``; + } else { + html += `${dmg} dmg`; + } + + html += `${willDie ? '\u2620 Dies' : '\u2714 Survives'}`; + html += `
`; + }); + + // Trample line + if (m.attackerKeywords.trample) { + const trampleDmg = Math.max(0, totalDmgPool - usedDmg); + html += `
`; + html += `\uD83E\uDD8F Trample: ${trampleDmg} to ${escapeHtml(m.targetPlayerName)}`; + html += `
`; + } + + // Attacker receives damage from blockers + if (m.damageToAttacker > 0) { + html += `
`; + html += `\uD83D\uDEE1 Blockers deal ${m.damageToAttacker} to ${escapeHtml(m.attackerName)}`; + html += ` — ${m.attackerDies ? '\u2620 Dies' : '\u2714 Survives'}`; + html += `
`; + } + + html += `
`; // blocker-assignments end + } else { + // Unblocked damage display + html += `
`; + html += `${m.damageToPlayer}`; + html += `${m.attackerKeywords.infect ? 'poison' : 'damage'}`; + if (m.commanderDamage > 0) { + html += `\uD83D\uDC51 ${m.commanderDamage} cmd`; + } + html += `
`; + } + + // Lifelink + if (m.lifelinkGain > 0) { + const ownerName = gameState.players[m.attackerOwnerId]?.name || '?'; + html += ``; + } + + html += `
`; // matchup end + }); + + // --- Summary --- + html += `
`; + html += `
\u2694 Damage Summary
`; + + // Player life changes + const playerChanges = []; + Object.entries(lifeSummary).forEach(([pid, change]) => { + const p = gameState.players[parseInt(pid)]; + if (p) playerChanges.push(`${escapeHtml(p.name)}: ${p.life} \u2192 ${p.life + change} (${change > 0 ? '+' : ''}${change})`); + }); + Object.entries(lifelinkSummary).forEach(([oid, gain]) => { + const p = gameState.players[parseInt(oid)]; + if (p) { + const existing = lifeSummary[oid] || 0; + playerChanges.push(`${escapeHtml(p.name)}: +${gain} (lifelink)`); + } + }); + if (playerChanges.length > 0) { + html += `
${playerChanges.join('')}
`; + } + + // Poison + Object.entries(poisonSummary).forEach(([pid, amt]) => { + const p = gameState.players[parseInt(pid)]; + if (p) html += `
\u2623 ${escapeHtml(p.name)}: +${amt} poison (${p.poison} \u2192 ${p.poison + amt})
`; + }); + + // Commander damage + Object.entries(cmdDmgSummary).forEach(([key, amt]) => { + const [tid, sid] = key.split('-').map(Number); + const tp = gameState.players[tid]; + const sp = gameState.players[sid]; + if (tp && sp) { + const current = tp.commanderDamage?.[sid] || 0; + html += `
\uD83D\uDC51 ${escapeHtml(tp.name)}: +${amt} cmd dmg from ${escapeHtml(sp.name)} (${current} \u2192 ${current + amt})
`; + } + }); + + // Deaths + if (deaths.length > 0) { + html += `
\u2620 To graveyard: ${deaths.map(n => '' + escapeHtml(n) + '').join(', ')}
`; + } + + html += `
`; // summary end + html += `
`; // container end + + previewEl.innerHTML = html; +} + +/** + * Build keyword badges HTML for damage preview. + */ +function buildKeywordBadges(kw) { + let badges = ''; + if (kw.deathtouch) badges += '\u2620 Deathtouch'; + if (kw.trample) badges += '\uD83E\uDD8F Trample'; + if (kw.lifelink) badges += '\u2764 Lifelink'; + if (kw.firstStrike) badges += '\u26A1 First Strike'; + if (kw.doubleStrike) badges += '\u26A1\u26A1 Double Strike'; + if (kw.infect) badges += '\u2623 Infect'; + if (kw.indestructible) badges += '\uD83D\uDC8E Indestructible'; + if (kw.isCommander) badges += '\uD83D\uDC51 Commander'; + return badges; +} + +/** + * Adjust damage assigned to a specific blocker in multi-blocker matchup. + * Ensures total damage doesn't exceed attacker's power and each blocker + * gets at least 0 damage. + */ +function adjustBlockerDamage(matchupIdx, blockerIdx, delta) { + if (!gameState || !gameState.combat.damagePreview) return; + const matchup = gameState.combat.damagePreview[matchupIdx]; + if (!matchup) return; + + const totalPool = matchup.attackerKeywords.doubleStrike + ? matchup.attackerPower * 2 + : matchup.attackerPower; + + const currentVal = matchup.damageToBlockers[blockerIdx] || 0; + const usedByOthers = matchup.damageToBlockers.reduce((s, d, i) => i === blockerIdx ? s : s + d, 0); + const maxForThis = totalPool - usedByOthers; + const newVal = Math.max(0, Math.min(maxForThis, currentVal + delta)); + + matchup.damageToBlockers[blockerIdx] = newVal; + + // Recalculate trample damage + const totalAssigned = matchup.damageToBlockers.reduce((s, d) => s + d, 0); + if (matchup.attackerKeywords.trample) { + const trampleDmg = Math.max(0, totalPool - totalAssigned); + matchup.damageToPlayer = trampleDmg; + matchup.playerLifeChange = matchup.attackerKeywords.infect ? 0 : -trampleDmg; + matchup.playerPoisonChange = matchup.attackerKeywords.infect ? trampleDmg : 0; + matchup.commanderDamage = matchup.attackerKeywords.isCommander ? trampleDmg : 0; + } + + // Recalculate lifelink + if (matchup.attackerKeywords.lifelink) { + matchup.lifelinkGain = totalAssigned + matchup.damageToPlayer; + } + + // Re-render + renderDamagePreviewContent(); +} + +/** + * Apply all damage from the preview, resolving life changes, deaths, + * commander damage, poison, and lifelink, then clean up combat. + */ +function applyDamageFromPreview() { + if (!gameState || !gameState.combat.damagePreview) return; + pushUndoState(); + + const matchups = gameState.combat.damagePreview; + const combat = gameState.combat; + const toKill = new Set(); + const logLines = []; + + matchups.forEach(m => { + const attacker = gameState.battlefieldCards.find(c => c.id === m.attackerId); + + // Check if target is a planeswalker + var isPlaneswalkerTarget = (m.targetPlaneswalkerCardId != null); + + if (!m.isBlocked) { + // Unblocked — damage to player or planeswalker + if (m.damageToPlayer > 0) { + if (isPlaneswalkerTarget) { + // Damage to planeswalker + var pwKillId = applyDamageToPlaneswalker(m.targetPlaneswalkerCardId, m.damageToPlayer, logLines); + if (pwKillId) toKill.add(pwKillId); + showFloatingDamage('bc-' + m.targetPlaneswalkerCardId, m.damageToPlayer, 'damage'); + } else if (m.attackerKeywords.infect) { + adjustPoison(m.targetPlayerId, m.damageToPlayer); + logLines.push(`\u2694 ${escapeHtml(m.attackerName)} deals ${m.damageToPlayer} poison to ${escapeHtml(m.targetPlayerName)} — unblocked.`); + showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'poison'); + } else { + adjustLife(m.targetPlayerId, -m.damageToPlayer); + logLines.push(`\u2694 ${escapeHtml(m.attackerName)} (${m.attackerPower} power) deals ${m.damageToPlayer} damage to ${escapeHtml(m.targetPlayerName)} — unblocked.`); + showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'damage'); + } + // Commander damage + if (m.commanderDamage > 0 && !isPlaneswalkerTarget) { + adjustCommanderDamage(m.targetPlayerId, m.attackerOwnerId, m.commanderDamage); + logLines.push(`\uD83D\uDC51 Commander damage: ${m.commanderDamage} to ${escapeHtml(m.targetPlayerName)}.`); + } + } + } else { + // Blocked — apply damage to each blocker + m.blockerDetails.forEach((b, bi) => { + const dmg = m.damageToBlockers[bi] || 0; + if (dmg > 0) { + logLines.push(`\u2694 ${escapeHtml(m.attackerName)} deals ${dmg}${m.attackerKeywords.deathtouch ? ' (deathtouch)' : ''} to ${escapeHtml(b.name)}.`); + // Feature 3: track damage on creature + var blockerCard = gameState.battlefieldCards.find(function(c) { return c.id === b.cardId; }); + if (blockerCard) { + if (!blockerCard.damageThisTurn) blockerCard.damageThisTurn = 0; + blockerCard.damageThisTurn += dmg; + updateDamageBadge(blockerCard); + } + showFloatingDamage('bc-' + b.cardId, dmg, 'damage'); + const isLethal = m.attackerKeywords.deathtouch ? true : (dmg >= b.toughness); + if (isLethal && !b.keywords.indestructible) { + toKill.add(b.cardId); + logLines.push(`\u2620 ${escapeHtml(b.name)} is destroyed (${dmg} damage, ${b.toughness} toughness).`); + showFloatingDamage('bc-' + b.cardId, '\u2620', 'death'); + } + } + }); + + // Blocker damage back to attacker + if (m.damageToAttacker > 0 && attacker) { + const anyBlockerDeathtouch = m.blockerDetails.some(b => b.keywords.deathtouch); + logLines.push(`\uD83D\uDEE1 Blockers deal ${m.damageToAttacker}${anyBlockerDeathtouch ? ' (deathtouch)' : ''} to ${escapeHtml(m.attackerName)}.`); + // Feature 3: track damage on attacker + if (!attacker.damageThisTurn) attacker.damageThisTurn = 0; + attacker.damageThisTurn += m.damageToAttacker; + updateDamageBadge(attacker); + showFloatingDamage('bc-' + m.attackerId, m.damageToAttacker, 'damage'); + if (m.attackerDies) { + toKill.add(m.attackerId); + logLines.push(`\u2620 ${escapeHtml(m.attackerName)} is destroyed.`); + showFloatingDamage('bc-' + m.attackerId, '\u2620', 'death'); + } + } + + // Trample overflow to player + if (m.damageToPlayer > 0) { + if (m.attackerKeywords.infect) { + adjustPoison(m.targetPlayerId, m.damageToPlayer); + logLines.push(`\uD83E\uDD8F Trample (infect): ${m.damageToPlayer} poison to ${escapeHtml(m.targetPlayerName)}.`); + showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'poison'); + } else { + adjustLife(m.targetPlayerId, -m.damageToPlayer); + logLines.push(`\uD83E\uDD8F Trample: ${m.damageToPlayer} damage to ${escapeHtml(m.targetPlayerName)}.`); + showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'damage'); + } + if (m.commanderDamage > 0) { + adjustCommanderDamage(m.targetPlayerId, m.attackerOwnerId, m.commanderDamage); + } + } + } + + // Lifelink + if (m.lifelinkGain > 0) { + adjustLife(m.attackerOwnerId, m.lifelinkGain); + const ownerName = gameState.players[m.attackerOwnerId]?.name || '?'; + logLines.push(`\u2764 Lifelink: ${escapeHtml(ownerName)} gains ${m.lifelinkGain} life.`); + showFloatingDamage('player-panel-' + m.attackerOwnerId, m.lifelinkGain, 'heal'); + } + }); + + // Log all + logLines.forEach(line => addLogEntry(line)); + + // Send killed creatures to graveyard + toKill.forEach(id => { + combat.attackers = combat.attackers.filter(a => a.cardId !== id); + combat.blockers = combat.blockers.filter(b => b.cardId !== id && b.blockingCardId !== id); + moveCardToZone(id, 'graveyard'); + }); + + // Fire damage triggers + fireBattlefieldTriggers('damage'); + + // Feature 4: Show combat recap banner + showCombatRecap(logLines); + + // Clean up and advance + hideCombatOverlay(); + clearCombatArrows(); + combat.damagePreview = null; + gameState.combatPhaseIndex = 4; // end-combat + renderPlayerPanels(); + renderPhaseTracker(); + addLogEntry('Combat damage resolved.'); + checkStateBasedActions(); + setTimeout(() => nextPhase(), 800); +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 1: SVG COMBAT ARROWS +// ============================================================ + +/** + * Clear all combat arrow SVG paths. + */ +function clearCombatArrows() { + var svg = document.getElementById('combat-arrows-svg'); + if (!svg) return; + while (svg.firstChild) svg.removeChild(svg.firstChild); +} + +/** + * Get the center point of an element relative to the combat-arrows-svg container. + * Returns {x, y} or null if element not found. + */ +function _getElementCenterRelSvg(elId) { + var svg = document.getElementById('combat-arrows-svg'); + if (!svg) return null; + var el = document.getElementById(elId); + if (!el) return null; + var svgRect = svg.getBoundingClientRect(); + var elRect = el.getBoundingClientRect(); + return { + x: elRect.left + elRect.width / 2 - svgRect.left, + y: elRect.top + elRect.height / 2 - svgRect.top + }; +} + +/** + * Draw a single animated curved arrow in the SVG. + * @param {SVGElement} svg - the SVG container + * @param {{x,y}} from - start point + * @param {{x,y}} to - end point + * @param {string} color - stroke color hex + * @param {string} markerId - arrowhead marker id + */ +function _drawCombatArrow(svg, from, to, color, markerId) { + if (!from || !to) return; + // Quadratic bezier control point (bow outward) + var dx = to.x - from.x; + var dy = to.y - from.y; + var cx = from.x + dx * 0.5 - dy * 0.25; + var cy = from.y + dy * 0.5 + dx * 0.25; + + var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M' + from.x + ',' + from.y + ' Q' + cx + ',' + cy + ' ' + to.x + ',' + to.y); + path.setAttribute('stroke', color); + path.setAttribute('stroke-width', '2.5'); + path.setAttribute('fill', 'none'); + path.setAttribute('opacity', '0.85'); + path.setAttribute('marker-end', 'url(#' + markerId + ')'); + path.classList.add('combat-arrow-path'); + svg.appendChild(path); +} + +/** + * Build arrowhead marker defs in the SVG. + */ +function _buildArrowDefs(svg) { + var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + var markers = [ + { id: 'arrow-red', color: '#D3202A' }, + { id: 'arrow-blue', color: '#0E68AB' }, + { id: 'arrow-orange', color: '#ff8c00' }, + ]; + markers.forEach(function(m) { + var marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', m.id); + marker.setAttribute('markerWidth', '8'); + marker.setAttribute('markerHeight', '8'); + marker.setAttribute('refX', '6'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + poly.setAttribute('points', '0 0, 6 3, 0 6'); + poly.setAttribute('fill', m.color); + marker.appendChild(poly); + defs.appendChild(marker); + }); + svg.appendChild(defs); +} + +/** + * Update all combat arrows based on current combat state. + * Called from refreshCombatOverlay, toggleAttacker, toggleBlocker, + * confirmAttackers, confirmBlockers, showDamagePreview. + */ +function updateCombatArrows() { + var svg = document.getElementById('combat-arrows-svg'); + if (!svg) return; + clearCombatArrows(); + if (!gameState) return; + var combat = gameState.combat; + if (!combat || !combat.phase) return; + + _buildArrowDefs(svg); + + if (combat.phase === 'declaring-attackers') { + // Red arrows: attacker card → target player panel (or planeswalker card) + combat.attackers.forEach(function(a) { + var from = _getElementCenterRelSvg('bc-' + a.cardId); + var toId = a.targetPlaneswalkerCardId ? ('bc-' + a.targetPlaneswalkerCardId) : ('player-panel-' + a.targetPlayerId); + var to = _getElementCenterRelSvg(toId); + _drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red'); + }); + + } else if (combat.phase === 'declaring-blockers') { + // Blue arrows: blocker → attacker it's blocking + var blockedAttackers = new Set(); + combat.blockers.forEach(function(b) { + var from = _getElementCenterRelSvg('bc-' + b.cardId); + var to = _getElementCenterRelSvg('bc-' + b.blockingCardId); + _drawCombatArrow(svg, from, to, '#0E68AB', 'arrow-blue'); + blockedAttackers.add(b.blockingCardId); + }); + // Red arrows: unblocked attackers → target player/planeswalker + combat.attackers.forEach(function(a) { + if (!blockedAttackers.has(a.cardId)) { + var from = _getElementCenterRelSvg('bc-' + a.cardId); + var uToId = a.targetPlaneswalkerCardId ? ('bc-' + a.targetPlaneswalkerCardId) : ('player-panel-' + a.targetPlayerId); + var to = _getElementCenterRelSvg(uToId); + _drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red'); + } + }); + + } else if (combat.phase === 'previewing-damage') { + // Arrows from preview data + if (combat.damagePreview) { + combat.damagePreview.forEach(function(m) { + if (!m.isBlocked) { + // Red: attacker → target player/planeswalker + var from = _getElementCenterRelSvg('bc-' + m.attackerId); + var pToId = m.targetPlaneswalkerCardId ? ('bc-' + m.targetPlaneswalkerCardId) : ('player-panel-' + m.targetPlayerId); + var to = _getElementCenterRelSvg(pToId); + _drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red'); + } else { + // Orange: attacker → each blocker + m.blockerDetails.forEach(function(b) { + var from = _getElementCenterRelSvg('bc-' + m.attackerId); + var to = _getElementCenterRelSvg('bc-' + b.cardId); + _drawCombatArrow(svg, from, to, '#ff8c00', 'arrow-orange'); + }); + // If trample, also red arrow to player/planeswalker + if (m.attackerKeywords && m.attackerKeywords.trample && m.damageToPlayer > 0) { + var from2 = _getElementCenterRelSvg('bc-' + m.attackerId); + var tToId = m.targetPlaneswalkerCardId ? ('bc-' + m.targetPlaneswalkerCardId) : ('player-panel-' + m.targetPlayerId); + var to2 = _getElementCenterRelSvg(tToId); + _drawCombatArrow(svg, from2, to2, '#D3202A', 'arrow-red'); + } + } + }); + } + } +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 2: FLOATING DAMAGE NUMBERS +// ============================================================ + +/** + * Show a floating damage/heal/poison/death number at a card or player element. + * @param {string} elementId - DOM element ID (e.g. 'bc-123' or 'player-panel-0') + * @param {number|string} amount - the number or symbol to show + * @param {string} type - 'damage' | 'heal' | 'poison' | 'death' + */ +function showFloatingDamage(elementId, amount, type) { + var el = document.getElementById(elementId); + if (!el) return; + + // Trigger visual flash on the element + triggerDamageFlash(elementId, type); + + // Screen shake for big damage (10+) + if (type === 'damage' && typeof amount === 'number' && amount >= 10) { + triggerScreenShake(); + } + + var rect = el.getBoundingClientRect(); + + var div = document.createElement('div'); + div.className = 'floating-damage floating-damage-' + type; + + if (type === 'damage') { + div.textContent = '-' + amount; + } else if (type === 'heal') { + div.textContent = '+' + amount; + } else if (type === 'poison') { + div.textContent = '+' + amount + '\u2623'; + } else if (type === 'death') { + div.textContent = '\u2620'; + } else { + div.textContent = amount; + } + + // Randomize x position slightly so multiple numbers don't stack + var xOff = (Math.random() * 20) - 10; + div.style.left = (rect.left + rect.width / 2 - 15 + xOff) + 'px'; + div.style.top = (rect.top + rect.height / 3) + 'px'; + + document.body.appendChild(div); + + // Remove after animation completes + setTimeout(function() { + if (div.parentNode) div.parentNode.removeChild(div); + }, 1600); +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 3: DAMAGE MARKERS +// ============================================================ + +/** + * Update the damage badge on a battlefield card. + * Creates or removes the .bf-damage-badge based on card.damageThisTurn. + */ +function updateDamageBadge(cardData) { + var cardEl = document.getElementById('bc-' + cardData.id); + if (!cardEl) return; + var existing = cardEl.querySelector('.bf-damage-badge'); + if (cardData.damageThisTurn && cardData.damageThisTurn > 0) { + if (!existing) { + existing = document.createElement('div'); + existing.className = 'bf-damage-badge'; + cardEl.appendChild(existing); + } + existing.textContent = cardData.damageThisTurn; + // Also update P/T overlay if creature has taken damage + var ptOverlay = cardEl.querySelector('.card-pt-overlay'); + if (ptOverlay && cardData.pt) { + var parts = cardData.pt.split('/'); + var origTou = parseInt(parts[1], 10); + if (!isNaN(origTou)) { + var remaining = origTou - cardData.damageThisTurn; + ptOverlay.textContent = parts[0] + '/' + remaining; + ptOverlay.classList.add('pt-overlay-damaged'); + } + } + } else { + if (existing) existing.parentNode.removeChild(existing); + // Reset P/T overlay + var ptOverlay2 = cardEl.querySelector('.card-pt-overlay'); + if (ptOverlay2 && cardData.pt) { + ptOverlay2.textContent = cardData.pt; + ptOverlay2.classList.remove('pt-overlay-damaged'); + } + } +} + +/** + * Clear all damage-this-turn counters from all battlefield cards. + * Called from nextTurn(). + */ +function clearAllDamageCounters() { + if (!gameState) return; + gameState.battlefieldCards.forEach(function(card) { + if (card.damageThisTurn && card.damageThisTurn > 0) { + card.damageThisTurn = 0; + updateDamageBadge(card); + } + }); +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 4: COMBAT RECAP SUMMARY +// ============================================================ + +/** + * Show an animated combat recap banner at the top of the battlefield. + * Auto-dismisses after 5 seconds or on click. + * @param {string[]} logLines - array of HTML log lines from combat resolution + */ +function showCombatRecap(logLines) { + // Remove any existing recap + var old = document.getElementById('combat-recap-banner'); + if (old && old.parentNode) old.parentNode.removeChild(old); + + if (!logLines || logLines.length === 0) return; + + // Build summary by extracting key events + var summaryLines = []; + + // Damage to players + var playerDmgMap = {}; + var lifeGainMap = {}; + var deaths = []; + var poisonMap = {}; + + logLines.forEach(function(line) { + // Player damage: lines containing "damage to " not from trample text about a creature + var dmgMatch = line.match(/deals.*?(\d+).*?damage to ([^<]+)<\/strong>/); + if (dmgMatch) { + var amt = parseInt(dmgMatch[1], 10); + var name = dmgMatch[2]; + // Only player damage (players match names in gameState) + if (gameState) { + gameState.players.forEach(function(p) { + if (p.name === name) { + playerDmgMap[name] = (playerDmgMap[name] || 0) + amt; + } + }); + } + } + // Deaths + var deathMatch = line.match(/\u2620.*?([^<]+)<\/strong>.*?is destroyed/); + if (deathMatch) deaths.push(deathMatch[1]); + // Life gain (lifelink) + var lifeMatch = line.match(/Lifelink.*?([^<]+)<\/strong>.*?gains.*?(\d+)<\/strong>/); + if (lifeMatch) { + lifeGainMap[lifeMatch[1]] = (lifeGainMap[lifeMatch[1]] || 0) + parseInt(lifeMatch[2], 10); + } + // Poison + var poisonMatch = line.match(/poison.*?([^<]+)<\/strong>/); + if (poisonMatch) { + poisonMap[poisonMatch[1]] = (poisonMap[poisonMatch[1]] || 0) + 1; + } + }); + + var parts = []; + Object.entries(playerDmgMap).forEach(function(pair) { + parts.push('' + escapeHtml(pair[0]) + ' takes ' + pair[1] + ' damage'); + }); + deaths.forEach(function(n) { + parts.push('\u2620 ' + escapeHtml(n) + ' destroyed'); + }); + Object.entries(lifeGainMap).forEach(function(pair) { + parts.push('\u2764 ' + escapeHtml(pair[0]) + ' gains ' + pair[1] + ' life'); + }); + Object.entries(poisonMap).forEach(function(pair) { + parts.push('\u2623 ' + escapeHtml(pair[0]) + ' poisoned'); + }); + + if (parts.length === 0) return; + + var banner = document.createElement('div'); + banner.id = 'combat-recap-banner'; + banner.className = 'combat-recap-banner'; + banner.innerHTML = '\u2694 Combat: ' + parts.join(' • ') + + ''; + + var battlefield = document.getElementById('battlefield'); + if (battlefield) { + battlefield.appendChild(banner); + } else { + document.body.appendChild(banner); + } + + // Auto-dismiss after 5 seconds + setTimeout(function() { + var b = document.getElementById('combat-recap-banner'); + if (b && b.parentNode) { + b.classList.add('recap-fade-out'); + setTimeout(function() { + var b2 = document.getElementById('combat-recap-banner'); + if (b2 && b2.parentNode) b2.parentNode.removeChild(b2); + }, 500); + } + }, 5000); +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 5: KEYWORD ENFORCEMENT +// ============================================================ + +/** + * Check if a card has a given color. + * Uses card.colors array or falls back to parsing oracle/type text. + * @param {object} card + * @param {string} colorCode - 'W','U','B','R','G' + */ +function cardHasColor(card, colorCode) { + if (!card) return false; + // Try colors array first + if (Array.isArray(card.colors)) { + return card.colors.indexOf(colorCode.toUpperCase()) !== -1; + } + // Try manaCost parsing if available + if (card.manaCost) { + var mc = card.manaCost.toUpperCase(); + return mc.indexOf(colorCode.toUpperCase()) !== -1; + } + return false; +} + +/** + * Check if a card is an artifact creature (for intimidate/fear blocking). + */ +function cardIsArtifactCreature(card) { + if (!card || !card.typeLine) return false; + var tl = card.typeLine.toLowerCase(); + return tl.indexOf('artifact') !== -1 && tl.indexOf('creature') !== -1; +} + +/** + * Menace: check that all menace attackers have 2+ blockers. + * Returns the first menace attacker with exactly 1 blocker, or null. + */ +function checkMenaceViolation() { + if (!gameState) return null; + var combat = gameState.combat; + for (var i = 0; i < combat.attackers.length; i++) { + var a = combat.attackers[i]; + var attacker = gameState.battlefieldCards.find(function(c) { return c.id === a.cardId; }); + if (!attacker) continue; + if (!hasKeyword(attacker, 'menace')) continue; + // Count blockers assigned to this attacker + var blockerCount = combat.blockers.filter(function(b) { return b.blockingCardId === a.cardId; }).length; + if (blockerCount === 1) return attacker; + } + return null; +} + + +// ============================================================ +// COMBAT ENGINE EXPANSION — FEATURE 6: PLANESWALKER TARGETS +// ============================================================ + +/** + * Get all planeswalker cards on the battlefield controlled by opponents + * of the current player. + */ +function getOpponentPlaneswalkers() { + if (!gameState) return []; + return gameState.battlefieldCards.filter(function(c) { + if (c.ownerId === gameState.currentPlayerIndex) return false; + if (!c.typeLine) return false; + return c.typeLine.toLowerCase().indexOf('planeswalker') !== -1; + }); +} + +/** + * Apply combat damage to a planeswalker target (reduce loyalty counters). + * @param {number} planeswalkerCardId + * @param {number} damage + * @param {string[]} logLines - array to append log entries to + */ +function applyDamageToPlaneswalker(planeswalkerCardId, damage, logLines) { + if (!gameState) return; + var pw = gameState.battlefieldCards.find(function(c) { return c.id === planeswalkerCardId; }); + if (!pw) return; + if (!pw.counters) pw.counters = {}; + var currentLoyalty = pw.counters['loyalty'] || 0; + var newLoyalty = Math.max(0, currentLoyalty - damage); + pw.counters['loyalty'] = newLoyalty; + if (logLines) { + logLines.push('\u2694 ' + escapeHtml(pw.name) + ' takes ' + damage + ' damage (' + currentLoyalty + ' \u2192 ' + newLoyalty + ' loyalty).'); + } + // Planeswalker dies at 0 loyalty + if (newLoyalty <= 0) { + if (logLines) logLines.push('\u2620 ' + escapeHtml(pw.name) + ' is defeated (0 loyalty).'); + return planeswalkerCardId; // Signal to kill this card + } + // Update the card visual + updateDamageBadge(pw); + return null; +} + +// ============================================================ +// PLANESWALKER LOYALTY ABILITY SYSTEM +// ============================================================ + +/** + * Reset loyalty ability usage for all planeswalkers at start of turn. + * Called from advancePhase when transitioning to a new turn. + */ +function resetPlaneswalkerLoyaltyUsage() { + if (!gameState) return; + gameState.battlefieldCards.forEach(function(c) { + if (c.typeLine && /planeswalker/i.test(c.typeLine)) { + c.loyaltyUsedThisTurn = false; + } + }); +} + +/** + * Show the loyalty ability picker overlay for a planeswalker card. + * Only the controller can activate, and only once per turn, at sorcery speed. + */ +function showPlaneswalkerAbilities(cardId) { + if (!gameState) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + if (!card.typeLine || !/planeswalker/i.test(card.typeLine)) return; + + // Check ownership + if (card.ownerIndex !== gameState.currentPlayerIndex) { + addLogEntry('\u26a0 Only the controller can activate loyalty abilities.'); + return; + } + + // Check if already used this turn + if (card.loyaltyUsedThisTurn) { + addLogEntry('\u26a0 ' + card.name + ' has already used a loyalty ability this turn.'); + return; + } + + // Parse loyalty abilities from oracle text + var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || ''); + if (!abilities.loyalty || abilities.loyalty.length === 0) { + addLogEntry('\u26a0 No loyalty abilities found on ' + card.name + '.'); + return; + } + + var currentLoyalty = (card.counters && card.counters['loyalty']) || 0; + + // Build overlay + var existing = document.getElementById('pw-ability-overlay'); + if (existing) existing.remove(); + + var overlay = document.createElement('div'); + overlay.id = 'pw-ability-overlay'; + overlay.className = 'pw-ability-overlay'; + + var html = '
'; + html += '
'; + html += '' + escapeHtml(card.name) + ''; + html += '\u25c6 ' + currentLoyalty + ''; + html += ''; + html += '
'; + html += '
'; + + for (var i = 0; i < abilities.loyalty.length; i++) { + var ab = abilities.loyalty[i]; + var costNum = parseInt(ab.cost.replace('\u2212', '-'), 10); + var canAfford = true; + // For minus abilities, check if we have enough loyalty + if (costNum < 0 && currentLoyalty + costNum < 0) { + canAfford = false; + } + var isPlus = ab.cost.startsWith('+'); + var isZero = ab.cost === '0'; + var badgeClass = isPlus ? 'pw-cost-plus' : isZero ? 'pw-cost-zero' : 'pw-cost-minus'; + var disabledClass = canAfford ? '' : ' pw-ability-disabled'; + var disabledAttr = canAfford ? '' : ' disabled'; + + html += ''; + } + + html += '
'; + html += '
'; + overlay.innerHTML = html; + + // Click outside to close + overlay.addEventListener('click', function(e) { + if (e.target === overlay) closePlaneswalkerAbilities(); + }); + + document.body.appendChild(overlay); +} + +function closePlaneswalkerAbilities() { + var overlay = document.getElementById('pw-ability-overlay'); + if (overlay) overlay.remove(); +} + +/** + * Activate a specific loyalty ability on a planeswalker. + * Adjusts loyalty counters and attempts to execute the effect. + */ +function activatePlaneswalkerAbility(cardId, abilityIndex) { + if (!gameState) return; + var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; }); + if (!card) return; + + var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || ''); + if (!abilities.loyalty || !abilities.loyalty[abilityIndex]) return; + + var ab = abilities.loyalty[abilityIndex]; + var costNum = parseInt(ab.cost.replace('\u2212', '-'), 10); + if (isNaN(costNum)) costNum = 0; + + if (!card.counters) card.counters = {}; + var currentLoyalty = card.counters['loyalty'] || 0; + + // Verify we can pay + if (costNum < 0 && currentLoyalty + costNum < 0) { + addLogEntry('\u26a0 Not enough loyalty on ' + card.name + ' to activate [' + ab.cost + '].'); + closePlaneswalkerAbilities(); + return; + } + + pushUndoState('PW Ability: ' + card.name); + + // Pay the cost (adjust loyalty) + var newLoyalty = currentLoyalty + costNum; + card.counters['loyalty'] = newLoyalty; + card.loyaltyUsedThisTurn = true; + + addLogEntry('\u25c6 ' + escapeHtml(card.name) + ' [' + ab.cost + ']: ' + escapeHtml(ab.effect) + ' (Loyalty: ' + currentLoyalty + ' \u2192 ' + newLoyalty + ')'); + + // Execute the effect + executePlaneswalkerEffect(card, ab.effect, ab.cost); + + // Check if planeswalker dies from 0 loyalty + if (newLoyalty <= 0) { + addLogEntry('\u2620 ' + escapeHtml(card.name) + ' is defeated (0 loyalty).'); + moveCardToZone(cardId, 'graveyard'); + } else { + // Update the visual + var bcEl = document.getElementById('bc-' + card.id); + if (bcEl) bcEl.remove(); + renderBattlefieldCard(card); + } + + closePlaneswalkerAbilities(); +} + +/** + * Attempt to auto-execute common planeswalker ability effects. + * Falls back to a log message for complex effects. + */ +function executePlaneswalkerEffect(card, effectText, cost) { + if (!effectText || !gameState) return; + var owner = card.ownerIndex; + var text = effectText.toLowerCase(); + + // --- Draw cards --- + var drawMatch = text.match(/draw (a card|one card|two cards?|three cards?|four cards?|five cards?|(\d+) cards?)/i); + if (drawMatch) { + var drawCount = 1; + var drawWords = { 'a card': 1, 'one card': 1, 'two cards': 2, 'three cards': 3, 'four cards': 4, 'five cards': 5 }; + var dKey = drawMatch[1].toLowerCase().replace(/s$/, '').replace(/cards/, 'card'); + if (drawWords[dKey] !== undefined) drawCount = drawWords[dKey]; + else if (drawMatch[2]) drawCount = parseInt(drawMatch[2], 10); + for (var d = 0; d < drawCount; d++) drawCard(owner); + addLogEntry('\u2713 Auto-effect: Drew ' + drawCount + ' card(s)'); + } + + // --- Gain life --- + var gainMatch = text.match(/you gain (\d+) life/i); + if (gainMatch) { + var gain = parseInt(gainMatch[1], 10); + adjustLife(owner, gain); + addLogEntry('\u2713 Auto-effect: Gained ' + gain + ' life'); + } + + // --- Lose life (opponents) --- + var oppLoseMatch = text.match(/each opponent loses (\d+) life/i); + if (oppLoseMatch) { + var oppLoss = parseInt(oppLoseMatch[1], 10); + gameState.players.forEach(function(p, i) { + if (i !== owner && !p.eliminated) { + adjustLife(i, -oppLoss); + } + }); + addLogEntry('\u2713 Auto-effect: Each opponent loses ' + oppLoss + ' life'); + } + + // --- Deal damage --- + var dmgMatch = text.match(/deals? (\d+) damage to (?:any target|target)/i); + if (dmgMatch) { + var dmgAmt = parseInt(dmgMatch[1], 10); + addLogEntry('\u2b50 Effect: Deal ' + dmgAmt + ' damage to any target \u2014 choose target manually'); + } + + // --- Create tokens --- + var tokenSpecs = parseTokenCreationAbilities(effectText); + if (tokenSpecs.length > 0) { + for (var ti = 0; ti < tokenSpecs.length; ti++) { + var ts = tokenSpecs[ti]; + createQuickToken(owner, ts.name, ts.power, ts.toughness, ts.typeLine, ts.color, ts.abilities, ts.count); + addLogEntry('\u2713 Auto-effect: Created ' + ts.displayLabel); + } + } + + // --- Create Treasure --- + if (/create .* treasure/i.test(effectText) && tokenSpecs.length === 0) { + var treasureCountMatch = effectText.match(/(two|three|four|five|\d+) treasure/i); + var tCount = 1; + if (treasureCountMatch) { + var tw = { two: 2, three: 3, four: 4, five: 5 }; + tCount = tw[treasureCountMatch[1].toLowerCase()] || parseInt(treasureCountMatch[1], 10) || 1; + } + createQuickToken(owner, 'Treasure', '', '', 'Artifact \u2014 Treasure', 'C', 'Sacrifice: Add one mana of any color', tCount); + addLogEntry('\u2713 Auto-effect: Created ' + tCount + 'x Treasure'); + } + + // --- Mill / self-mill --- + var millMatch = text.match(/mills? (\d+) cards?/i) || text.match(/put the top (\d+) cards? .* into .* graveyard/i); + if (millMatch) { + var millAmt = parseInt(millMatch[1], 10); + millCards(owner, millAmt); + addLogEntry('\u2713 Auto-effect: Milled ' + millAmt + ' cards'); + } + + // --- Scry --- + var scryMatch = text.match(/scry (\d+)/i); + if (scryMatch) { + var scryAmt = parseInt(scryMatch[1], 10); + openScryModal(owner, scryAmt); + addLogEntry('\u2713 Auto-effect: Scry ' + scryAmt); + } + + // --- Destroy target --- + if (/destroy (target|all|each)/i.test(text)) { + addLogEntry('\u2b50 Effect: Destroy \u2014 choose target(s) manually'); + } + + // --- Exile --- + if (/exile (target|a |up to)/i.test(text)) { + addLogEntry('\u2b50 Effect: Exile \u2014 choose target(s) manually'); + } + + // --- Return to hand --- + if (/return .* to .* (hand|owner)/i.test(text)) { + addLogEntry('\u2b50 Effect: Return \u2014 choose target(s) manually'); + } + + // --- +1/+1 counters --- + var counterMatch = text.match(/put (a|one|two|three|\d+) \+1\/\+1 counter/i); + if (counterMatch) { + var cw = { 'a': 1, 'one': 1, 'two': 2, 'three': 3 }; + var cAmt = cw[counterMatch[1].toLowerCase()] || parseInt(counterMatch[1], 10) || 1; + addLogEntry('\u2b50 Effect: Put ' + cAmt + ' +1/+1 counter(s) \u2014 choose target manually'); + } + + // --- Ultimate / Emblem --- + if (/you get an emblem/i.test(text)) { + addLogEntry('\u2b50 ULTIMATE: You get an emblem \u2014 ' + escapeHtml(effectText)); + } + + // --- Search library --- + if (/search your library/i.test(text)) { + addLogEntry('\u2b50 Effect: Search your library \u2014 use deck viewer to find the card'); + } + + // --- Discard --- + var discardMatch = text.match(/discard (a|\d+) cards?/i); + if (discardMatch) { + addLogEntry('\u2b50 Effect: Discard \u2014 choose card(s) from hand manually'); + } +} + +// ============================================================ +// AI SPELL CAST RESPONSE SYSTEM +// ============================================================ + +/** + * Global state for the AI cast pause system. + * When an AI casts a spell, the action queue is paused until the human responds. + */ +var _aiCastPauseState = null; + +/** + * AI target selection for removal/targeted spells. + * Returns { targetName, targetCardId, targetType } or null. + */ +function aiSelectSpellTarget(card, aiPlayerIdx) { + if (!gameState || !card) return null; + var oracle = (card.oracleText || '').toLowerCase(); + var typeLine = (card.typeLine || '').toLowerCase(); + + // Check if this is a targeted spell + var isRemoval = /destroy target|exile target|deals? \d+ damage to (target|any target|any one target)/.test(oracle); + var isTargetedBuff = /target creature gets|put .* counter on target|enchant creature/.test(oracle); + var isTargetedBounce = /return target .* to .* (hand|owner)/.test(oracle); + var isBoardWipe = /destroy all|all creatures get \-|exile all|each creature/.test(oracle); + + if (isBoardWipe) { + return { targetName: 'All', targetCardId: null, targetType: 'board_wipe' }; + } + + if (!isRemoval && !isTargetedBounce) return null; + + // Find best target among opponents' battlefield cards + var candidates = gameState.battlefieldCards.filter(function(c) { + if (c.ownerIndex === aiPlayerIdx) return false; + // Match type restriction + var cType = (c.typeLine || '').toLowerCase(); + if (/destroy target creature|exile target creature|damage to target creature/.test(oracle)) { + return /creature/.test(cType); + } + if (/destroy target artifact/.test(oracle)) return /artifact/.test(cType); + if (/destroy target enchantment/.test(oracle)) return /enchantment/.test(cType); + if (/destroy target planeswalker/.test(oracle)) return /planeswalker/.test(cType); + if (/destroy target permanent|exile target permanent|exile target nonland/.test(oracle)) { + return !/land/.test(cType) || /destroy target permanent/.test(oracle); + } + // Generic "target" — prefer creatures + return true; + }); + + if (candidates.length === 0) return null; + + // Score candidates by threat (power, CMC, keywords) + candidates.sort(function(a, b) { + var scoreA = 0, scoreB = 0; + var aType = (a.typeLine || '').toLowerCase(); + var bType = (b.typeLine || '').toLowerCase(); + // Prefer creatures with high power + if (a.power) scoreA += parseInt(a.power, 10) || 0; + if (b.power) scoreB += parseInt(b.power, 10) || 0; + // Planeswalkers are high priority + if (/planeswalker/.test(aType)) scoreA += 5; + if (/planeswalker/.test(bType)) scoreB += 5; + // Commander bonus + if (a.isCommander) scoreA += 4; + if (b.isCommander) scoreB += 4; + // Higher CMC = bigger threat + scoreA += (a.cmc || 0); + scoreB += (b.cmc || 0); + // Keywords that make things dangerous + var aText = (a.oracleText || '').toLowerCase(); + var bText = (b.oracleText || '').toLowerCase(); + if (/flying|trample|double strike|lifelink|hexproof|indestructible/.test(aText)) scoreA += 3; + if (/flying|trample|double strike|lifelink|hexproof|indestructible/.test(bText)) scoreB += 3; + return scoreB - scoreA; + }); + + var target = candidates[0]; + return { + targetName: target.name, + targetCardId: target.id, + targetType: /creature/.test((target.typeLine || '').toLowerCase()) ? 'creature' : + /planeswalker/.test((target.typeLine || '').toLowerCase()) ? 'planeswalker' : 'permanent' + }; +} + +/** + * Show the AI cast response popup. + * Pauses the AI action queue and gives the human player a chance to respond. + */ +function showAiCastPopup(card, aiPlayerIdx, handIndex, aiTarget) { + if (!gameState) return; + var aiPlayer = gameState.players[aiPlayerIdx]; + if (!aiPlayer) return; + + // Store pause state so the AI action queue knows to wait + _aiCastPauseState = { + card: card, + aiPlayerIdx: aiPlayerIdx, + handIndex: handIndex, + aiTarget: aiTarget, + }; + + var existing = document.getElementById('ai-cast-popup'); + if (existing) existing.remove(); + + var overlay = document.createElement('div'); + overlay.id = 'ai-cast-popup'; + overlay.className = 'ai-cast-overlay'; + + var targetHtml = ''; + if (aiTarget) { + if (aiTarget.targetType === 'board_wipe') { + targetHtml = '
Target: All creatures/permanents
'; + } else { + targetHtml = '
Target: ' + escapeHtml(aiTarget.targetName) + ' (' + aiTarget.targetType + ')
'; + } + } + + var oraclePreview = card.oracleText ? '
' + escapeHtml(card.oracleText).substring(0, 250) + '
' : ''; + var manaCostHtml = card.manaCost ? '' + formatManaSymbols(card.manaCost) + '' : ''; + + var imgHtml = card.imageUrl ? '' + escapeHtml(card.name) + '' : ''; + + overlay.innerHTML = + '
' + + '
' + + '\ud83e\udd16 ' + escapeHtml(aiPlayer.name) + ' casts:' + + '
' + + '
' + + '
' + + imgHtml + + '
' + + '
' + escapeHtml(card.name) + ' ' + manaCostHtml + '
' + + '
' + escapeHtml(card.typeLine || '') + '
' + + oraclePreview + + targetHtml + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.body.appendChild(overlay); +} + +/** + * Human allows the AI spell to resolve. Remove popup and execute the cast. + */ +function resolveAiCast() { + if (!_aiCastPauseState || !gameState) return; + var state = _aiCastPauseState; + _aiCastPauseState = null; + + var popup = document.getElementById('ai-cast-popup'); + if (popup) popup.remove(); + + var player = gameState.players[state.aiPlayerIdx]; + if (!player) return; + var hand = player.zones.hand; + + // Re-find the card in hand (index may have shifted) + var ci = hand.indexOf(state.card); + if (ci === -1) { + // Card no longer in hand, maybe it was removed + addLogEntry('\u26a0 Spell fizzled \u2014 card no longer in hand.'); + return; + } + + // Execute the cast + hand.splice(ci, 1); + + var card = state.card; + var typeLine = (card.typeLine || '').toLowerCase(); + var isInstantOrSorcery = /instant|sorcery/.test(typeLine); + + if (isInstantOrSorcery) { + // Execute effect based on AI target + if (state.aiTarget && state.aiTarget.targetCardId) { + aiExecuteTargetedSpell(card, state.aiTarget, state.aiPlayerIdx); + } else if (state.aiTarget && state.aiTarget.targetType === 'board_wipe') { + aiExecuteBoardWipe(card, state.aiPlayerIdx); + } + // Log oracle text for manual resolution of complex effects + addLogEntry('\u2713 ' + escapeHtml(card.name) + ' resolves.'); + if (card.oracleText) { + addLogEntry('' + escapeHtml(card.oracleText).substring(0, 200) + ''); + } + // Send to graveyard + player.zones.graveyard.push({ + name: card.name, imageUrl: card.imageUrl, typeLine: card.typeLine || '', + oracleText: card.oracleText || '', manaCost: card.manaCost || '', cmc: card.cmc || 0, + power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', + }); + updatePlayerZones(); + fireBattlefieldTriggers('cast'); + } else { + // Permanent: put on battlefield + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, state.aiPlayerIdx, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', + pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', + colorIdentity: card.colorIdentity, keywords: card.keywords }); + } + + renderHandArea(); +} + +/** + * Human wants to respond to the AI spell. Close popup but keep the spell pending. + * The spell is "on the stack" conceptually. The human can take actions, then click a + * "Done Responding" button to let it resolve or counter it. + */ +function respondToAiCast() { + if (!_aiCastPauseState || !gameState) return; + + var popup = document.getElementById('ai-cast-popup'); + if (popup) popup.remove(); + + // Show a small "spell pending" banner with resolve/counter options + var banner = document.createElement('div'); + banner.id = 'ai-cast-pending-banner'; + banner.className = 'ai-cast-pending-banner'; + var cardName = _aiCastPauseState.card ? _aiCastPauseState.card.name : 'Unknown Spell'; + banner.innerHTML = + '\ud83d\udcda On the stack: ' + escapeHtml(cardName) + ' \u2014 Take your actions, then:' + + '' + + ''; + document.body.appendChild(banner); + + addLogEntry('\u270b You are responding to ' + escapeHtml(cardName) + '...'); +} + +/** + * Counter/fizzle the AI's pending spell. + */ +function counterAiCast() { + if (!_aiCastPauseState || !gameState) return; + var state = _aiCastPauseState; + _aiCastPauseState = null; + + var banner = document.getElementById('ai-cast-pending-banner'); + if (banner) banner.remove(); + + var card = state.card; + var player = gameState.players[state.aiPlayerIdx]; + if (!player) return; + + // Remove from hand + var ci = player.zones.hand.indexOf(card); + if (ci !== -1) player.zones.hand.splice(ci, 1); + + // Send to graveyard (countered) + player.zones.graveyard.push({ + name: card.name, imageUrl: card.imageUrl, typeLine: card.typeLine || '', + oracleText: card.oracleText || '', manaCost: card.manaCost || '', cmc: card.cmc || 0, + power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', + }); + + addLogEntry('\u274c ' + escapeHtml(card.name) + ' was countered!'); + updatePlayerZones(); + renderHandArea(); +} + +/** + * Execute a targeted AI removal spell effect. + */ +function aiExecuteTargetedSpell(card, target, aiPlayerIdx) { + if (!gameState || !target || !target.targetCardId) return; + var oracle = (card.oracleText || '').toLowerCase(); + + var targetCard = gameState.battlefieldCards.find(function(c) { return c.id === target.targetCardId; }); + if (!targetCard) { + addLogEntry('\u26a0 Target no longer on battlefield \u2014 spell fizzles.'); + return; + } + + if (/destroy target/.test(oracle)) { + // Check indestructible + var targetOracle = (targetCard.oracleText || '').toLowerCase(); + if (/\bindestructible\b/.test(targetOracle)) { + addLogEntry('\u26a0 ' + escapeHtml(targetCard.name) + ' is indestructible \u2014 destroy has no effect.'); + return; + } + addLogEntry('\u2620 ' + escapeHtml(targetCard.name) + ' was destroyed by ' + escapeHtml(card.name) + ''); + moveCardToZone(target.targetCardId, 'graveyard'); + } else if (/exile target/.test(oracle)) { + addLogEntry('\u2b21 ' + escapeHtml(targetCard.name) + ' was exiled by ' + escapeHtml(card.name) + ''); + moveCardToZone(target.targetCardId, 'exile'); + } else if (/deals? (\d+) damage/.test(oracle)) { + var dmgMatch = oracle.match(/deals? (\d+) damage/i); + var dmg = dmgMatch ? parseInt(dmgMatch[1], 10) : 0; + if (dmg > 0) { + if (/planeswalker/.test((targetCard.typeLine || '').toLowerCase())) { + var logs = []; + applyDamageToPlaneswalker(target.targetCardId, dmg, logs); + logs.forEach(function(l) { addLogEntry(l); }); + } else { + addLogEntry('\u2694 ' + escapeHtml(card.name) + ' deals ' + dmg + ' damage to ' + escapeHtml(targetCard.name) + ''); + // Mark damage for SBA + if (!targetCard.damageThisTurn) targetCard.damageThisTurn = 0; + targetCard.damageThisTurn += dmg; + updateDamageBadge(targetCard); + } + } + } else if (/return target .* to .* hand/i.test(oracle)) { + addLogEntry('\u21a9 ' + escapeHtml(targetCard.name) + ' was bounced by ' + escapeHtml(card.name) + ''); + moveCardToZone(target.targetCardId, 'hand'); + } + + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) { + setTimeout(function() { checkStateBasedActions(); }, 200); + } +} + +/** + * Execute an AI board wipe spell. + */ +function aiExecuteBoardWipe(card, aiPlayerIdx) { + if (!gameState) return; + var oracle = (card.oracleText || '').toLowerCase(); + var isExile = /exile all/.test(oracle); + var zone = isExile ? 'exile' : 'graveyard'; + var verb = isExile ? 'exiled' : 'destroyed'; + + // Find all creatures (or permanents based on text) + var toRemove = []; + gameState.battlefieldCards.forEach(function(c) { + var cType = (c.typeLine || '').toLowerCase(); + if (/destroy all creatures|all creatures get|each creature/.test(oracle)) { + if (/creature/.test(cType)) { + // Check indestructible + var cOracle = (c.oracleText || '').toLowerCase(); + if (/\bindestructible\b/.test(cOracle) && !isExile) return; + toRemove.push(c.id); + } + } else if (/destroy all .* permanents|exile all/.test(oracle)) { + if (!/land/.test(cType) || /nonland/.test(oracle) === false) { + toRemove.push(c.id); + } + } + }); + + addLogEntry('\ud83d\udca5 ' + escapeHtml(card.name) + ' ' + verb + ' ' + toRemove.length + ' permanents!'); + + // Remove in reverse order to avoid index issues + toRemove.reverse().forEach(function(id) { + moveCardToZone(id, zone); + }); + + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) { + setTimeout(function() { checkStateBasedActions(); }, 300); + } +} + + +// ============================================================ +// GAME STATE SAVE / LOAD SYSTEM +// ============================================================ + +const SAVE_API = 'cgi-bin/saves.py'; +const MAX_SAVE_SLOTS = 8; +const AUTOSAVE_SLOT = '__autosave__'; +const SAVE_STORAGE_KEY = 'mtg_commander_saves'; + +// Detect if we're in CGI-bin mode (deployed) or need local fallback (LAN) +const _useServerSaves = !SAVE_API.includes('cgi-bin'); + +// In-memory cache of save slot listing (lightweight: no full game data) +let _saveSlotCache = null; +let _saveSlotCacheDirty = true; + +// --- Local storage helpers for LAN/local fallback --- +const _ls = window['local' + 'Storage']; +function _lsGetSaves() { + try { return JSON.parse(_ls.getItem(SAVE_STORAGE_KEY) || '{}'); } + catch { return {}; } +} +function _lsWriteSaves(saves) { + try { _ls.setItem(SAVE_STORAGE_KEY, JSON.stringify(saves)); return true; } + catch { return false; } +} + +/** + * Serialize the entire game state into a plain JSON-safe object. + * Captures: gameState, nextCardId, nextTriggerId, AI flags, mana pool positions, etc. + */ +function serializeGameState() { + if (!gameState) return null; + + return { + version: 2, + timestamp: Date.now(), + gameState: JSON.parse(JSON.stringify(gameState)), + globals: { + nextCardId, + nextTriggerId, + }, + meta: { + playerCount: gameState.players.length, + playerNames: gameState.players.map(p => p.name), + turnNumber: gameState.turnNumber, + currentPlayer: gameState.players[gameState.currentPlayerIndex]?.name || '?', + phase: PHASES[gameState.currentPhaseIndex]?.label || '?', + aiPlayers: gameState.players.filter(p => p.isAI).map(p => p.id), + aiDifficulty: gameState.aiDifficulty || 'easy', + cardCount: gameState.battlefieldCards.length, + }, + }; +} + +/** + * Restore a serialized save into the live game. + * Re-initializes the board, renders all cards, updates all UI. + */ +function deserializeGameState(saveData) { + if (!saveData || !saveData.gameState) { + addLogEntry('Failed to load save — invalid data.'); + return false; + } + + // Restore globals + nextCardId = saveData.globals?.nextCardId || 1; + nextTriggerId = saveData.globals?.nextTriggerId || 1; + + // Restore core game state + gameState = saveData.gameState; + + // Clear undo/redo stacks after loading + undoStack = []; + redoStack = []; + updateUndoRedoButtons(); + + // Ensure combat state exists (older saves may not have it) + if (!gameState.combat) { + gameState.combat = { + attackers: [], blockers: [], damageAssignments: [], + phase: null, pendingBlockerFor: null, + }; + } + + // Ensure all players have required fields + for (const p of gameState.players) { + if (!p.manaPool) p.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; + if (!p.zones) p.zones = { graveyard: [], exile: [], command: [], hand: [], library: [] }; + if (p.commanderDamage === undefined) p.commanderDamage = {}; + } + + // Ensure manaAutoClear exists (older saves may not have it) + if (gameState.manaAutoClear === undefined) { + gameState.manaAutoClear = true; + } + + // Ensure auto-tap/auto-spend settings exist (older saves) + if (gameState.rulesEngine) { + if (gameState.rulesEngine.autoTapLands === undefined) gameState.rulesEngine.autoTapLands = true; + if (gameState.rulesEngine.autoSpendMana === undefined) gameState.rulesEngine.autoSpendMana = true; + } + + // Ensure turnHistory exists (older saves may not have it) + if (!gameState.turnHistory) { + gameState.turnHistory = [{ + turnNumber: gameState.turnNumber || 1, + playerIndex: gameState.currentPlayerIndex || 0, + playerName: gameState.players[gameState.currentPlayerIndex]?.name || 'Player 1', + playerColor: gameState.players[gameState.currentPlayerIndex]?.color || PLAYER_COLORS[gameState.currentPlayerIndex] || '#666', + }]; + } + + // Ensure priority state exists (older saves may not have it) + if (!gameState.priority) { + gameState.priority = { active: false, playerIndex: 0, passCount: 0, reason: '', sourcePhase: '' }; + } + + // Ensure each battlefield card has required fields + for (const c of gameState.battlefieldCards) { + if (!c.oracleText) c.oracleText = ''; + if (!c.typeLine) c.typeLine = ''; + if (!c.triggers) c.triggers = []; + if (c.ownerId === undefined) c.ownerId = c.ownerIndex; + if (!c.counters) c.counters = {}; + } + + return true; +} + +/** + * Full UI restoration after loading a save. + * Hides setup screen, shows game board, re-renders everything. + */ +function restoreGameUI() { + // Hide setup, show board + document.getElementById('setup-screen').classList.add('hidden'); + const board = document.getElementById('game-board'); + board.classList.add('visible'); + + // Show game log sidebar + var logSidebar = document.getElementById('game-log-sidebar'); + if (logSidebar) logSidebar.classList.remove('collapsed'); + + // Show zone panel + var zp = document.getElementById('zone-panel'); + if (zp) zp.classList.remove('hidden'); + + // Clear existing battlefield visuals + const battlefield = document.getElementById('battlefield'); + if (battlefield) { + battlefield.querySelectorAll('.battlefield-card').forEach(c => c.remove()); + } + + // Re-init board systems (only if not already initialized) + initGameBoard(); + + // Render all battlefield cards + for (const cardData of gameState.battlefieldCards) { + renderBattlefieldCard(cardData); + } + // Restore stack visuals after loading + refreshStackVisuals(); + + // Render all UI panels + renderPlayerPanels(); + renderHandArea(); + renderPhaseTracker(); + updateTurnDisplay(); + updateStormCounter(); + showManaPool(); + renderManaPool(); + updatePlayerZones(); + + // Sync mana auto-clear checkbox with loaded game state + const acCb = document.getElementById('mana-autoclear-toggle'); + if (acCb) acCb.checked = gameState.manaAutoClear !== false; + + // Restore log + const logContainer = document.getElementById('log-entries'); + if (logContainer && gameState.log) { + logContainer.innerHTML = ''; + // Show last 50 log entries to avoid overwhelming the UI + const recentLogs = gameState.log.slice(-50); + for (const entry of recentLogs) { + const div = document.createElement('div'); + div.className = 'log-entry'; + div.innerHTML = entry.html || entry; + logContainer.appendChild(div); + } + logContainer.scrollTop = logContainer.scrollHeight; + } + + // Hide battlefield label if cards exist + if (gameState.battlefieldCards.length > 0) { + const label = battlefield?.querySelector('.battlefield-label'); + if (label) label.style.opacity = '0'; + } + + hideCombatOverlay(); + addLogEntry('💾 Game loaded successfully'); +} + +/** + * Fetch save slot listing from server (lightweight — no full game data). + */ +async function fetchSaveSlotList() { + if (_saveSlotCache && !_saveSlotCacheDirty) return _saveSlotCache; + + if (!_useServerSaves) { + // Local storage fallback + const saves = _lsGetSaves(); + _saveSlotCache = Object.keys(saves).map(k => ({ + slotKey: k, + name: saves[k].slotName || k, + savedAt: saves[k].timestamp ? new Date(saves[k].timestamp).toISOString() : null, + size: JSON.stringify(saves[k]).length, + })); + _saveSlotCacheDirty = false; + return _saveSlotCache; + } + + try { + const res = await fetch(`${SAVE_API}/list`); + const json = await res.json(); + _saveSlotCache = json.slots || []; + _saveSlotCacheDirty = false; + return _saveSlotCache; + } catch (e) { + console.error('Failed to fetch saves:', e); + return _saveSlotCache || []; + } +} + +/** + * Save the current game to a named slot on the server. + */ +async function saveToSlot(slotName) { + const data = serializeGameState(); + if (!data) return false; + data.slotName = slotName; + + if (!_useServerSaves) { + const saves = _lsGetSaves(); + saves[slotName] = data; + _saveSlotCacheDirty = true; + return _lsWriteSaves(saves); + } + + try { + const res = await fetch(`${SAVE_API}/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slotKey: slotName, name: slotName, data }), + }); + const json = await res.json(); + _saveSlotCacheDirty = true; + return !!json.success; + } catch (e) { + console.error('Save failed:', e); + return false; + } +} + +/** + * Load a game from a named slot on the server. + */ +async function loadFromSlot(slotName) { + if (!_useServerSaves) { + const saves = _lsGetSaves(); + const data = saves[slotName]; + if (!data) return false; + if (deserializeGameState(data)) { + restoreGameUI(); + return true; + } + return false; + } + + try { + const res = await fetch(`${SAVE_API}/load?slot=${encodeURIComponent(slotName)}`); + if (!res.ok) return false; + const json = await res.json(); + const data = json.data; + if (!data) return false; + if (deserializeGameState(data)) { + restoreGameUI(); + return true; + } + return false; + } catch (e) { + console.error('Load failed:', e); + return false; + } +} + +/** + * Delete a save slot on the server. + */ +async function deleteSlot(slotName) { + if (!_useServerSaves) { + const saves = _lsGetSaves(); + delete saves[slotName]; + _lsWriteSaves(saves); + _saveSlotCacheDirty = true; + return; + } + + try { + await fetch(`${SAVE_API}/delete?slot=${encodeURIComponent(slotName)}`, { method: 'DELETE' }); + _saveSlotCacheDirty = true; + } catch (e) { + console.error('Delete failed:', e); + } +} + +/** + * Auto-save (fires on turn changes). + */ +function autoSave() { + if (!gameState) return; + saveToSlot(AUTOSAVE_SLOT); +} + +/** + * Export a save to a downloadable JSON file. + */ +async function exportSave(slotName) { + let data; + if (slotName) { + if (!_useServerSaves) { + const saves = _lsGetSaves(); + data = saves[slotName]; + } else { + try { + const res = await fetch(`${SAVE_API}/load?slot=${encodeURIComponent(slotName)}`); + const json = await res.json(); + data = json.data; + } catch { data = null; } + } + } else { + data = serializeGameState(); + } + if (!data) return; + + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const safeName = (data.slotName || 'game-save').replace(/[^a-zA-Z0-9_-]/g, '_'); + a.download = `mtg-commander-${safeName}-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); +} + +/** + * Import a save from a JSON file. + */ +function importSave(file) { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const data = JSON.parse(e.target.result); + if (!data.gameState || !data.meta) { + alert('Invalid save file format.'); + return; + } + const slotName = `Imported: ${data.meta.playerNames?.[0] || 'Game'} T${data.meta.turnNumber || '?'}`; + data.slotName = slotName; + + if (!_useServerSaves) { + const saves = _lsGetSaves(); + saves[slotName] = data; + _lsWriteSaves(saves); + } else { + await fetch(`${SAVE_API}/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slotKey: slotName, name: slotName, data }), + }); + } + _saveSlotCacheDirty = true; + await renderSaveLoadModal(); + addLogEntry(`📥 Imported save: ${slotName}`); + } catch { + alert('Failed to parse save file. Ensure it is a valid JSON save.'); + } + }; + reader.readAsText(file); +} + +/** + * Format a timestamp for display. + */ +function formatSaveDate(ts) { + if (!ts) return '—'; + const d = new Date(ts.includes('T') || ts.includes('Z') ? ts : ts + 'Z'); + if (isNaN(d.getTime())) return ts; + const now = new Date(); + const diff = now - d; + if (diff < 60000) return 'Just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + if (diff < 172800000) return 'Yesterday'; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +/** + * Build and show the Save/Load modal. + */ +async function openSaveLoadModal(initialTab) { + let modal = document.getElementById('save-load-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'save-load-modal'; + modal.className = 'save-load-modal'; + document.body.appendChild(modal); + } + modal.style.display = 'flex'; + modal.dataset.tab = initialTab || 'save'; + await renderSaveLoadModal(); +} + +function closeSaveLoadModal() { + const modal = document.getElementById('save-load-modal'); + if (modal) modal.style.display = 'none'; +} + +async function renderSaveLoadModal() { + const modal = document.getElementById('save-load-modal'); + if (!modal) return; + const tab = modal.dataset.tab || 'save'; + const slotList = await fetchSaveSlotList(); + // Build a lightweight lookup from the slot listing + const slotMap = {}; + for (const s of slotList) slotMap[s.slotKey] = s; + const slotKeys = slotList.filter(s => s.slotKey !== AUTOSAVE_SLOT).map(s => s.slotKey); + const autoSaveEntry = slotMap[AUTOSAVE_SLOT] || null; + const hasGame = !!gameState; + + let html = ` +
+
+

💾 Save / Load Game

+ +
+
+ + +
+
`; + + if (tab === 'save') { + // Save tab + html += `
`; + if (!hasGame) { + html += `
Start a game to save it.
`; + } else { + // Quick save with custom name + html += ` +
+ + +
+
${slotKeys.length} / ${MAX_SAVE_SLOTS} slots used
`; + + // List existing saves (for overwrite) + if (slotKeys.length > 0) { + html += `
`; + for (const key of slotKeys) { + const s = slotMap[key]; + html += buildSlotRow(key, s, 'save'); + } + html += `
`; + } + } + html += `
`; + } else { + // Load tab + html += `
`; + + // Auto-save + if (autoSaveEntry) { + html += `
Auto-Save
`; + html += `
`; + html += buildSlotRow(AUTOSAVE_SLOT, autoSaveEntry, 'load'); + html += `
`; + } + + // Manual saves + if (slotKeys.length > 0) { + html += `
Saved Games
`; + html += `
`; + for (const key of slotKeys) { + const s = slotMap[key]; + html += buildSlotRow(key, s, 'load'); + } + html += `
`; + } + + if (!autoSaveEntry && slotKeys.length === 0) { + html += `
No saved games found.
`; + } + + // Import + html += ` +
+
Import from File
+ +
`; + + html += `
`; + } + + html += `
`; + modal.innerHTML = html; + + // Focus save name input + if (tab === 'save') { + setTimeout(() => document.getElementById('save-slot-name')?.focus(), 100); + } +} + +function buildSlotRow(key, slotEntry, mode) { + // slotEntry comes from the listing API: {slotKey, name, savedAt, size} + const isAuto = key === AUTOSAVE_SLOT; + const displayName = isAuto ? 'Auto-Save' : (slotEntry.name || key); + const time = formatSaveDate(slotEntry.savedAt); + const sizeKB = slotEntry.size ? `${Math.round(slotEntry.size / 1024)}KB` : ''; + const details = sizeKB; + let actions = ''; + if (mode === 'save' && !isAuto) { + actions = ` + + `; + } else if (mode === 'load') { + actions = ` + + + ${!isAuto ? `` : ''}`; + } + + return ` +
+
+
${isAuto ? '⏱ ' : ''}${displayName}
+
${details}
+
+
+
${time}
+
${actions}
+
+
`; +} + +function escapeSlotKey(key) { + return key.replace(/'/g, "\\'").replace(/"/g, '"'); +} + +// --- Modal action handlers --- + +async function doSaveSlot() { + const input = document.getElementById('save-slot-name'); + let name = input?.value.trim(); + if (!name) { + const p = gameState?.players[gameState.currentPlayerIndex]; + name = `${p?.name || 'Game'} — Turn ${gameState?.turnNumber || '?'}`; + } + + const slotList = await fetchSaveSlotList(); + const usedSlots = slotList.filter(s => s.slotKey !== AUTOSAVE_SLOT).length; + if (usedSlots >= MAX_SAVE_SLOTS && !slotList.find(s => s.slotKey === name)) { + alert(`Maximum ${MAX_SAVE_SLOTS} save slots reached. Delete an existing save first.`); + return; + } + + if (await saveToSlot(name)) { + addLogEntry(`💾 Game saved: ${name}`); + await renderSaveLoadModal(); + if (input) input.value = ''; + } else { + alert('Save failed.'); + } +} + +async function doOverwriteSlot(slotName) { + if (await saveToSlot(slotName)) { + addLogEntry(`💾 Save overwritten: ${slotName}`); + await renderSaveLoadModal(); + } +} + +async function doLoadSlot(slotName) { + if (gameState) { + if (!confirm('Loading a save will replace the current game. Continue?')) return; + } + closeSaveLoadModal(); + if (await loadFromSlot(slotName)) { + // Success — UI already restored by loadFromSlot + } else { + alert('Failed to load save.'); + } +} + +async function doDeleteSlot(slotName) { + if (!confirm(`Delete save "${slotName}"?`)) return; + await deleteSlot(slotName); + await renderSaveLoadModal(); +} + +// --- Hook auto-save into turn/phase changes --- + +// Auto-save on every turn start via periodic check. +let _lastAutoSaveTurn = 0; +function checkAutoSave() { + if (!gameState) return; + if (gameState.turnNumber !== _lastAutoSaveTurn) { + _lastAutoSaveTurn = gameState.turnNumber; + autoSave(); + } +} + +// Run auto-save check periodically (every 10 seconds) +setInterval(checkAutoSave, 10000); + +// ============================================================ +// RESIZABLE PANELS +// ============================================================ + +function initResizableHandles() { + const handles = document.querySelectorAll('.resize-handle'); + handles.forEach(handle => { + handle.addEventListener('mousedown', startResize); + handle.addEventListener('touchstart', startResizeTouch, { passive: false }); + }); +} + +function startResize(e) { + e.preventDefault(); + const handle = e.currentTarget; + const isVertical = handle.classList.contains('resize-handle-v'); + const dir = handle.dataset.dir; // 'left', 'right', 'bottom' + const targetId = handle.dataset.target; + const target = document.getElementById(targetId); + if (!target) return; + + handle.classList.add('dragging'); + + if (isVertical) { + document.body.classList.add('resizing'); + target.style.transition = 'none'; + const startX = e.clientX; + const startWidth = target.getBoundingClientRect().width; + + function onMove(ev) { + const delta = ev.clientX - startX; + let newWidth; + if (dir === 'left') { + newWidth = startWidth + delta; + } else { + // right sidebar: dragging left = bigger + newWidth = startWidth - delta; + } + const minW = parseInt(getComputedStyle(target).minWidth) || 140; + const maxW = window.innerWidth * 0.4; + newWidth = Math.max(minW, Math.min(maxW, newWidth)); + target.style.width = newWidth + 'px'; + } + + function onUp() { + handle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + target.style.transition = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + + } else { + // Horizontal resize (hand area) + document.body.classList.add('resizing-h'); + const startY = e.clientY; + const startHeight = target.getBoundingClientRect().height; + + function onMoveH(ev) { + // Dragging up = taller hand area (since handle is above) + const delta = startY - ev.clientY; + let newHeight = startHeight + delta; + const minH = 60; + const maxH = window.innerHeight * 0.5; + newHeight = Math.max(minH, Math.min(maxH, newHeight)); + target.style.height = newHeight + 'px'; + target.style.flexShrink = '0'; + } + + function onUpH() { + handle.classList.remove('dragging'); + document.body.classList.remove('resizing-h'); + document.removeEventListener('mousemove', onMoveH); + document.removeEventListener('mouseup', onUpH); + } + + document.addEventListener('mousemove', onMoveH); + document.addEventListener('mouseup', onUpH); + } +} + +function startResizeTouch(e) { + e.preventDefault(); + const handle = e.currentTarget; + const isVertical = handle.classList.contains('resize-handle-v'); + const dir = handle.dataset.dir; + const targetId = handle.dataset.target; + const target = document.getElementById(targetId); + if (!target || !e.touches.length) return; + + handle.classList.add('dragging'); + + if (isVertical) { + document.body.classList.add('resizing'); + target.style.transition = 'none'; + const startX = e.touches[0].clientX; + const startWidth = target.getBoundingClientRect().width; + + function onTouchMove(ev) { + if (!ev.touches.length) return; + const delta = ev.touches[0].clientX - startX; + let newWidth; + if (dir === 'left') { + newWidth = startWidth + delta; + } else { + newWidth = startWidth - delta; + } + const minW = parseInt(getComputedStyle(target).minWidth) || 140; + const maxW = window.innerWidth * 0.4; + newWidth = Math.max(minW, Math.min(maxW, newWidth)); + target.style.width = newWidth + 'px'; + } + + function onTouchEnd() { + handle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + target.style.transition = ''; + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + } + + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + + } else { + document.body.classList.add('resizing-h'); + const startY = e.touches[0].clientY; + const startHeight = target.getBoundingClientRect().height; + + function onTouchMoveH(ev) { + if (!ev.touches.length) return; + const delta = startY - ev.touches[0].clientY; + let newHeight = startHeight + delta; + const minH = 60; + const maxH = window.innerHeight * 0.5; + newHeight = Math.max(minH, Math.min(maxH, newHeight)); + target.style.height = newHeight + 'px'; + target.style.flexShrink = '0'; + } + + function onTouchEndH() { + handle.classList.remove('dragging'); + document.body.classList.remove('resizing-h'); + document.removeEventListener('touchmove', onTouchMoveH); + document.removeEventListener('touchend', onTouchEndH); + } + + document.addEventListener('touchmove', onTouchMoveH, { passive: false }); + document.addEventListener('touchend', onTouchEndH); + } +} + +// Double-click to reset to default size +document.addEventListener('dblclick', (e) => { + const handle = e.target.closest('.resize-handle'); + if (!handle) return; + const targetId = handle.dataset.target; + const target = document.getElementById(targetId); + if (!target) return; + + if (handle.classList.contains('resize-handle-v')) { + target.style.width = ''; + } else { + target.style.height = ''; + target.style.flexShrink = ''; + } +}); + +// ============================================================ +// INIT +// ============================================================ + +document.addEventListener('DOMContentLoaded', () => { + // Set dark theme by default + document.documentElement.setAttribute('data-theme', 'dark'); + + initSetupScreen(); + initDeckBuilderAndCollection(); + initShortcutHint(); + setupLifeHold(); + initResizableHandles(); + + // Confirm dialog buttons + document.getElementById('btn-confirm-reset')?.addEventListener('click', executeReset); + document.getElementById('btn-cancel-reset')?.addEventListener('click', () => { + document.getElementById('confirm-dialog')?.classList.remove('visible'); + }); + + // Close context menu on scroll + window.addEventListener('scroll', hideContextMenu, true); +}); + + +// ============================================================ +// DECK BUILDER & COLLECTION TRACKER +// Feature: Mode switching, Collection, Wishlist, Deck Builder +// ============================================================ + +// --- State --- +var currentMode = 'play'; // 'play' | 'collection' | 'deckbuilder' +var collColorFilter = 'all'; +var collSearchTimer = null; +var dbSearchTimer = null; +var collAddSelectedCard = null; +var collAddCondition = 'NM'; +var collAddFoil = false; + +// Deck builder state +var dbCurrentDeck = { + id: null, + name: 'Untitled Deck', + commander: null, + cards: [], // [{scryfallId, name, imageUrl, typeLine, manaCost, cmc, quantity, colorIdentity}] + dateCreated: null, + dateModified: null +}; +var dbSearchResults = []; +var dbCtxCard = null; +var dbImportCancelFlag = false; +var dbStatsOpen = false; +var dbSavedOpen = false; + +// Scryfall in-memory cache for collection/db searches +var sfCollCache = {}; + +// Commander banned list (common EDH bans) +var COMMANDER_BANNED = [ + 'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus', + 'Braids, Cabal Minion', 'Channel', 'Coalition Victory', + 'Emrakul, the Aeons Torn', 'Erayo, Soratami Ascendant', + 'Fastbond', 'Gifts Ungiven', 'Griselbrand', 'Hullbreacher', + 'Iona, Shield of Emeria', 'Karakas', 'Leovold, Emissary of Trest', + 'Library of Alexandria', 'Limited Resources', 'Lutri, the Spellchaser', + 'Mox Emerald', 'Mox Jet', 'Mox Pearl', 'Mox Ruby', 'Mox Sapphire', + 'Panoptic Mirror', 'Paradox Engine', 'Primeval Titan', 'Prophet of Kruphix', + 'Recurring Nightmare', 'Rofellos, Llanowar Emissary', + 'Sundering Titan', 'Sway of the Stars', 'Sylvan Primordial', + 'Time Vault', 'Time Walk', 'Tinker', 'Tolarian Academy', + 'Trade Secrets', 'Upheaval', 'Worldfire', 'Yawgmoth\'s Bargain' +]; + +// Basic land names (exempt from singleton) +var BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', + 'Wastes', 'Snow-Covered Plains', 'Snow-Covered Island', + 'Snow-Covered Swamp', 'Snow-Covered Mountain', 'Snow-Covered Forest']; + +// ============================================================ +// MODE SWITCHING +// ============================================================ +function switchMode(mode) { + currentMode = mode; + // Update tabs + document.querySelectorAll('.mode-tab').forEach(function(t) { t.classList.remove('active'); }); + var activeTab = document.getElementById('mode-tab-' + mode); + if (activeTab) activeTab.classList.add('active'); + + var setupScreen = document.getElementById('setup-screen'); + + // Show/hide sections within setup card + var playContent = document.getElementById('mode-play-content'); + if (playContent) { + playContent.style.display = (mode === 'play') ? '' : 'none'; + } + + // When entering collection or deckbuilder, hide setup screen so fullscreen panel is visible + // When returning to play, show setup screen again + if (mode === 'play') { + if (setupScreen) setupScreen.style.display = ''; + } else { + if (setupScreen) setupScreen.style.display = 'none'; + } + + // Show/hide fullscreen panels + var collScreen = document.getElementById('collection-screen'); + var dbScreen = document.getElementById('deckbuilder-screen'); + if (collScreen) collScreen.classList.toggle('hidden', mode !== 'collection'); + if (dbScreen) dbScreen.classList.toggle('hidden', mode !== 'deckbuilder'); + + if (mode === 'collection') { + renderCollectionGrid(); + renderWishlistGrid(); + updateCollStats(); + } else if (mode === 'deckbuilder') { + renderDbList(); + renderDbSavedDecks(); + renderDbStats(); + } +} + +// ============================================================ +// STORAGE HELPERS +// ============================================================ +var _mtgStore = window['local' + 'Storage']; +function loadStorage(key) { + try { + var raw = _mtgStore.getItem('mtg_' + key); + return raw ? JSON.parse(raw) : null; + } catch(e) { return null; } +} + +function saveStorage(key, data) { + try { + _mtgStore.setItem('mtg_' + key, JSON.stringify(data)); + } catch(e) { console.error('Storage save failed:', e); } +} + +function getCollection() { + return loadStorage('collection') || []; +} + +function saveCollection(arr) { + saveStorage('collection', arr); +} + +function getWishlist() { + return loadStorage('wishlist') || []; +} + +function saveWishlist(arr) { + saveStorage('wishlist', arr); +} + +function getDecks() { + return loadStorage('decks') || []; +} + +function saveDecks(arr) { + saveStorage('decks', arr); +} + +// ============================================================ +// COLLECTION TRACKER +// ============================================================ + +function switchCollectionTab(tab) { + document.querySelectorAll('.sub-tab').forEach(function(t) { t.classList.remove('active'); }); + var el = document.getElementById('coll-tab-' + tab); + if (el) el.classList.add('active'); + + var panels = document.querySelectorAll('.coll-panel'); + panels.forEach(function(p) { p.classList.add('hidden'); }); + var panel = document.getElementById('coll-panel-' + tab); + if (panel) panel.classList.remove('hidden'); + + if (tab === 'wishlist') renderWishlistGrid(); +} + +function setCollColorFilter(color) { + collColorFilter = color; + document.querySelectorAll('.coll-color-btn').forEach(function(b) { b.classList.remove('active'); }); + var btn = document.querySelector('.coll-color-btn[data-color="' + color + '"]'); + if (btn) btn.classList.add('active'); + renderCollectionGrid(); +} + +function filterCollection() { + renderCollectionGrid(); +} + +function filterWishlist() { + renderWishlistGrid(); +} + +function getFilteredCollection() { + var coll = getCollection(); + var search = (document.getElementById('coll-search-input') || {}).value || ''; + search = search.toLowerCase().trim(); + var typeFilter = (document.getElementById('coll-type-filter') || {}).value || 'all'; + var rarityFilter = (document.getElementById('coll-rarity-filter') || {}).value || 'all'; + var sortBy = (document.getElementById('coll-sort') || {}).value || 'name'; + + if (search) coll = coll.filter(function(c) { return c.name.toLowerCase().includes(search); }); + if (collColorFilter !== 'all') { + coll = coll.filter(function(c) { + return c.colorIdentity && c.colorIdentity.includes(collColorFilter); + }); + } + if (typeFilter !== 'all') { + coll = coll.filter(function(c) { + return c.typeLine && c.typeLine.toLowerCase().includes(typeFilter); + }); + } + if (rarityFilter !== 'all') { + coll = coll.filter(function(c) { + return c.rarity && c.rarity.toLowerCase() === rarityFilter; + }); + } + coll = coll.slice().sort(function(a, b) { + if (sortBy === 'name') return a.name.localeCompare(b.name); + if (sortBy === 'date') return new Date(b.dateAdded) - new Date(a.dateAdded); + if (sortBy === 'price') return (parseFloat(b.priceUsd) || 0) - (parseFloat(a.priceUsd) || 0); + if (sortBy === 'color') return (a.colorIdentity||[]).join('').localeCompare((b.colorIdentity||[]).join('')); + if (sortBy === 'cmc') return (a.cmc||0) - (b.cmc||0); + return 0; + }); + return coll; +} + +function renderCollectionGrid() { + var grid = document.getElementById('coll-grid'); + if (!grid) return; + var coll = getFilteredCollection(); + if (!coll.length) { + grid.innerHTML = '
No cards match your filters.
'; + return; + } + var html = ''; + coll.forEach(function(card, idx) { + var price = card.foil ? (card.priceFoilUsd || card.priceUsd || '?') : (card.priceUsd || '?'); + var priceNum = parseFloat(price) || 0; + var totalVal = (priceNum * card.quantity).toFixed(2); + var imgTag = card.imageUrl + ? '' + escapeHtml(card.name) + '' + + '' + : '
📷
' + escapeHtml(card.name) + '
'; + html += '
' + + imgTag + + '
×' + card.quantity + '
' + + (card.foil ? '
✦ FOIL
' : '') + + '
' + card.condition + '
' + + (priceNum > 0 ? '
$' + totalVal + '
' : '') + + '
'; + }); + grid.innerHTML = html; +} + +function renderWishlistGrid() { + var grid = document.getElementById('wish-grid'); + if (!grid) return; + var wish = getWishlist(); + var search = (document.getElementById('wish-search-input') || {}).value || ''; + if (search) wish = wish.filter(function(c) { return c.name.toLowerCase().includes(search.toLowerCase()); }); + if (!wish.length) { + grid.innerHTML = '
No cards in wishlist.
'; + updateWishlistStats([]); + return; + } + var html = ''; + wish.forEach(function(card, idx) { + var price = parseFloat(card.priceUsd) || 0; + var imgTag = card.imageUrl + ? '' + escapeHtml(card.name) + '' + + '' + : '
📷
' + escapeHtml(card.name) + '
'; + html += '
' + + imgTag + + (price > 0 ? '
$' + price.toFixed(2) + '
' : '') + + '
'; + }); + grid.innerHTML = html; + updateWishlistStats(wish); +} + +function updateWishlistStats(wish) { + var total = 0; + (wish || getWishlist()).forEach(function(c) { total += parseFloat(c.priceUsd) || 0; }); + var el = document.getElementById('wishlist-total-value'); + if (el) el.textContent = '$' + total.toFixed(2); + var badge = document.getElementById('wishlist-count-badge'); + if (badge) badge.textContent = (wish || getWishlist()).length; +} + +function updateCollStats() { + var coll = getCollection(); + var totalCards = 0, totalValue = 0; + coll.forEach(function(c) { + totalCards += c.quantity; + var price = c.foil ? (parseFloat(c.priceFoilUsd) || parseFloat(c.priceUsd) || 0) : (parseFloat(c.priceUsd) || 0); + totalValue += price * c.quantity; + }); + var totalCardsEl = document.getElementById('coll-total-cards'); + var totalValueEl = document.getElementById('coll-total-value'); + if (totalCardsEl) totalCardsEl.textContent = totalCards; + if (totalValueEl) totalValueEl.textContent = '$' + totalValue.toFixed(2); +} + +// --- Add Card Modal --- +function openCollectionAddModal() { + var modal = document.getElementById('coll-add-modal'); + if (modal) modal.classList.remove('hidden'); + var input = document.getElementById('coll-add-search-input'); + if (input) { input.value = ''; input.focus(); } + var results = document.getElementById('coll-add-results'); + if (results) results.innerHTML = '
Search for a card above
'; + var form = document.getElementById('coll-add-form'); + if (form) form.classList.add('hidden'); + collAddSelectedCard = null; + collAddCondition = 'NM'; + collAddFoil = false; + resetConditionBtns(); + var foilInput = document.getElementById('coll-add-foil'); + if (foilInput) foilInput.checked = false; + var qtyInput = document.getElementById('coll-add-qty'); + if (qtyInput) qtyInput.value = 1; +} + +function closeCollectionAddModal() { + var modal = document.getElementById('coll-add-modal'); + if (modal) modal.classList.add('hidden'); +} + +function resetConditionBtns() { + document.querySelectorAll('.cond-btn').forEach(function(b) { b.classList.remove('active'); }); + var nm = document.querySelector('.cond-btn[data-cond="NM"]'); + if (nm) nm.classList.add('active'); +} + +function selectCondition(btn, cond) { + collAddCondition = cond; + document.querySelectorAll('.cond-btn').forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); +} + +function toggleCollFoil(checkbox) { + collAddFoil = checkbox.checked; + updateAddPriceDisplay(); +} + +function adjustCollAddQty(delta) { + var input = document.getElementById('coll-add-qty'); + if (!input) return; + var v = parseInt(input.value) || 1; + v = Math.max(1, Math.min(99, v + delta)); + input.value = v; +} + +function updateAddPriceDisplay() { + var row = document.getElementById('coll-add-price-row'); + if (!row || !collAddSelectedCard) return; + var price = collAddFoil + ? (collAddSelectedCard.prices && collAddSelectedCard.prices.usd_foil) || '—' + : (collAddSelectedCard.prices && collAddSelectedCard.prices.usd) || '—'; + row.textContent = 'Price: $' + price; +} + +// Debounced Scryfall search for collection add modal +function debouncedCollSearch() { + clearTimeout(collSearchTimer); + var input = document.getElementById('coll-add-search-input'); + if (!input) return; + var q = input.value.trim(); + var status = document.getElementById('coll-add-search-status'); + if (q.length < 2) { + if (status) status.textContent = ''; + document.getElementById('coll-add-results').innerHTML = '
Type at least 2 characters
'; + return; + } + if (status) status.textContent = 'Searching...'; + collSearchTimer = setTimeout(function() { searchScryfallForColl(q); }, 300); +} + +async function searchScryfallForColl(query) { + var status = document.getElementById('coll-add-search-status'); + var resultsEl = document.getElementById('coll-add-results'); + if (!resultsEl) return; + + var cacheKey = 'coll_' + query; + if (sfCollCache[cacheKey]) { + renderCollAddResults(sfCollCache[cacheKey]); + if (status) status.textContent = sfCollCache[cacheKey].length + ' results'; + return; + } + + try { + var url = 'https://api.scryfall.com/cards/search?q=' + encodeURIComponent(query) + '&unique=cards&order=name&limit=20'; + var resp = await fetch(url); + if (!resp.ok) { + if (status) status.textContent = 'No results'; + resultsEl.innerHTML = '
No cards found for "' + escapeHtml(query) + '"
'; + return; + } + var data = await resp.json(); + var cards = data.data || []; + sfCollCache[cacheKey] = cards; + renderCollAddResults(cards); + if (status) status.textContent = cards.length + ' results'; + } catch(e) { + if (status) status.textContent = 'Error'; + resultsEl.innerHTML = '
Search error. Try again.
'; + } +} + +function renderCollAddResults(cards) { + var resultsEl = document.getElementById('coll-add-results'); + if (!resultsEl) return; + if (!cards || !cards.length) { + resultsEl.innerHTML = '
No cards found
'; + return; + } + var html = ''; + cards.forEach(function(card, i) { + var price = (card.prices && card.prices.usd) ? '$' + card.prices.usd : '—'; + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + html += '
' + + (imgUrl ? '' : '') + + '
' + + '' + escapeHtml(card.name) + '' + + '' + escapeHtml(card.set_name || card.set || '') + ' · ' + escapeHtml(card.type_line || '') + '' + + '
' + + '' + price + '' + + '
'; + }); + resultsEl.innerHTML = html; + // Store for later selection + resultsEl._cards = cards; +} + +function selectCollCard(idx) { + var resultsEl = document.getElementById('coll-add-results'); + var cards = resultsEl ? resultsEl._cards : null; + if (!cards || !cards[idx]) return; + collAddSelectedCard = cards[idx]; + + var form = document.getElementById('coll-add-form'); + if (form) form.classList.remove('hidden'); + + var display = document.getElementById('coll-add-selected-card'); + if (display) { + var imgUrl = (collAddSelectedCard.image_uris && collAddSelectedCard.image_uris.normal) || ''; + display.innerHTML = imgUrl + ? '' + escapeHtml(collAddSelectedCard.name) + '' + : '
📷
' + escapeHtml(collAddSelectedCard.name) + '
'; + } + updateAddPriceDisplay(); +} + +function addToCollection() { + if (!collAddSelectedCard) return; + var qty = parseInt((document.getElementById('coll-add-qty') || {}).value) || 1; + var card = collAddSelectedCard; + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var entry = { + scryfallId: card.id, + name: card.name, + set: card.set, + setName: card.set_name || card.set, + collectorNumber: card.collector_number, + imageUrl: imgUrl, + typeLine: card.type_line || '', + manaCost: card.mana_cost || '', + colorIdentity: card.color_identity || [], + rarity: card.rarity || '', + cmc: card.cmc || 0, + quantity: qty, + condition: collAddCondition, + foil: collAddFoil, + priceUsd: (card.prices && card.prices.usd) || null, + priceFoilUsd: (card.prices && card.prices.usd_foil) || null, + dateAdded: new Date().toISOString() + }; + var coll = getCollection(); + // Check if already exists with same id+foil+condition + var existing = coll.findIndex(function(c) { + return c.scryfallId === entry.scryfallId && c.foil === entry.foil && c.condition === entry.condition; + }); + if (existing >= 0) { + coll[existing].quantity += qty; + } else { + coll.push(entry); + } + saveCollection(coll); + renderCollectionGrid(); + updateCollStats(); + closeCollectionAddModal(); + showToast('Added ' + card.name + ' to collection'); +} + +function addToWishlist() { + if (!collAddSelectedCard) return; + var card = collAddSelectedCard; + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var entry = { + scryfallId: card.id, + name: card.name, + set: card.set, + setName: card.set_name || card.set, + collectorNumber: card.collector_number, + imageUrl: imgUrl, + typeLine: card.type_line || '', + manaCost: card.mana_cost || '', + colorIdentity: card.color_identity || [], + rarity: card.rarity || '', + cmc: card.cmc || 0, + priceUsd: (card.prices && card.prices.usd) || null, + dateAdded: new Date().toISOString() + }; + var wish = getWishlist(); + var exists = wish.some(function(c) { return c.scryfallId === entry.scryfallId; }); + if (!exists) wish.push(entry); + saveWishlist(wish); + updateWishlistStats(wish); + closeCollectionAddModal(); + showToast('Added ' + card.name + ' to wishlist'); +} + +// --- Collection Detail Modal --- +function openCollDetail(filteredIdx) { + var coll = getFilteredCollection(); + var card = coll[filteredIdx]; + if (!card) return; + + var modal = document.getElementById('coll-detail-modal'); + var nameEl = document.getElementById('coll-detail-name'); + var body = document.getElementById('coll-detail-body'); + if (!modal || !body) return; + + nameEl.textContent = card.name; + + var price = card.foil ? (card.priceFoilUsd || card.priceUsd || '—') : (card.priceUsd || '—'); + var priceNum = parseFloat(price) || 0; + var totalVal = (priceNum * card.quantity).toFixed(2); + var imgUrl = (card.imageUrl || '').replace('/small/', '/normal/'); + + // Find real index in collection for editing + var realColl = getCollection(); + var realIdx = realColl.findIndex(function(c) { + return c.scryfallId === card.scryfallId && c.foil === card.foil && c.condition === card.condition && c.dateAdded === card.dateAdded; + }); + + body.innerHTML = '
' + + (imgUrl ? '' + escapeHtml(card.name) + '' : '
📷
') + + '
' + + '
' + + '
' + escapeHtml(card.setName || card.set) + ' #' + escapeHtml(card.collectorNumber || '') + '
' + + '
' + escapeHtml(card.typeLine || '') + '
' + + '
' + (card.cmc || 0) + '
' + + '
' + escapeHtml(card.rarity || '') + '
' + + '
' + card.condition + '
' + + '
' + (card.foil ? '✦ Yes' : 'No') + '
' + + '
' + + '' + + ' ' + card.quantity + ' ' + + '' + + '
' + + '
$' + (priceNum > 0 ? priceNum.toFixed(2) : '—') + '
' + + '
$' + (priceNum > 0 ? totalVal : '—') + '
' + + '
' + new Date(card.dateAdded).toLocaleDateString() + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + modal.classList.remove('hidden'); +} + +function closeCollDetailModal() { + var modal = document.getElementById('coll-detail-modal'); + if (modal) modal.classList.add('hidden'); +} + +function adjustCollDetailQty(idx, delta) { + var coll = getCollection(); + if (!coll[idx]) return; + coll[idx].quantity = Math.max(1, Math.min(99, (coll[idx].quantity || 1) + delta)); + saveCollection(coll); + renderCollectionGrid(); + updateCollStats(); +} + +function removeFromCollection(idx) { + var coll = getCollection(); + if (idx < 0 || idx >= coll.length) return; + var name = coll[idx].name; + coll.splice(idx, 1); + saveCollection(coll); + renderCollectionGrid(); + updateCollStats(); + closeCollDetailModal(); + showToast('Removed ' + name + ' from collection'); +} + +// --- Wishlist Detail --- +function openWishDetail(idx) { + var wish = getWishlist(); + var card = wish[idx]; + if (!card) return; + var modal = document.getElementById('coll-detail-modal'); + var nameEl = document.getElementById('coll-detail-name'); + var body = document.getElementById('coll-detail-body'); + if (!modal || !body) return; + nameEl.textContent = card.name + ' (Wishlist)'; + var price = parseFloat(card.priceUsd) || 0; + var imgUrl = (card.imageUrl || '').replace('/small/', '/normal/'); + body.innerHTML = '
' + + (imgUrl ? '' + escapeHtml(card.name) + '' : '') + + '
' + + '
' + + '
' + escapeHtml(card.setName || card.set || '') + '
' + + '
' + escapeHtml(card.typeLine || '') + '
' + + '
$' + (price > 0 ? price.toFixed(2) : '—') + '
' + + '
' + new Date(card.dateAdded).toLocaleDateString() + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + modal.classList.remove('hidden'); +} + +function removeFromWishlist(idx) { + var wish = getWishlist(); + if (idx < 0 || idx >= wish.length) return; + var name = wish[idx].name; + wish.splice(idx, 1); + saveWishlist(wish); + renderWishlistGrid(); + closeCollDetailModal(); + showToast('Removed ' + name + ' from wishlist'); +} + +function moveWishToCollection(idx) { + var wish = getWishlist(); + var card = wish[idx]; + if (!card) return; + var collEntry = Object.assign({}, card, { + quantity: 1, + condition: 'NM', + foil: false, + priceFoilUsd: null, + dateAdded: new Date().toISOString() + }); + var coll = getCollection(); + coll.push(collEntry); + saveCollection(coll); + wish.splice(idx, 1); + saveWishlist(wish); + renderCollectionGrid(); + renderWishlistGrid(); + updateCollStats(); + closeCollDetailModal(); + showToast('Moved ' + card.name + ' to collection'); +} + +// ============================================================ +// COLLECTION IMPORT / EXPORT +// ============================================================ + +var collExportFormat = 'csv'; +var collImportFormat = 'text'; +var collImportParsedCards = []; + +// --- Export --- + +function openCollExportModal() { + var modal = document.getElementById('coll-export-modal'); + if (modal) modal.classList.remove('hidden'); + collExportFormat = 'csv'; + switchExportTab('csv'); +} + +function closeCollExportModal() { + var modal = document.getElementById('coll-export-modal'); + if (modal) modal.classList.add('hidden'); +} + +function switchExportTab(tab) { + collExportFormat = tab; + document.querySelectorAll('.coll-ie-tab[id^="exp-tab-"]').forEach(function(t) { t.classList.remove('active'); }); + var el = document.getElementById('exp-tab-' + tab); + if (el) el.classList.add('active'); + + var hintEl = document.getElementById('coll-export-hint'); + var textarea = document.getElementById('coll-export-textarea'); + var coll = getCollection(); + + if (tab === 'csv') { + if (hintEl) hintEl.textContent = 'CSV format \u2014 compatible with spreadsheets, Moxfield, Archidekt.'; + textarea.value = exportCollectionCSV(coll); + } else if (tab === 'json') { + if (hintEl) hintEl.textContent = 'JSON format \u2014 full backup with all card data. Re-import without Scryfall lookups.'; + textarea.value = exportCollectionJSON(coll); + } else if (tab === 'text') { + if (hintEl) hintEl.textContent = 'Simple text list \u2014 compatible with most MTG tools and deck builders.'; + textarea.value = exportCollectionText(coll); + } +} + +function exportCollectionCSV(coll) { + var headers = ['Quantity','Name','Set','Set Name','Collector Number','Condition','Foil','Rarity','Mana Cost','CMC','Type','Color Identity','Price USD','Price Foil USD','Scryfall ID','Date Added']; + var rows = [headers.join(',')]; + coll.forEach(function(c) { + var row = [ + c.quantity || 1, + csvEscape(c.name || ''), + csvEscape(c.set || ''), + csvEscape(c.setName || ''), + csvEscape(c.collectorNumber || ''), + csvEscape(c.condition || 'NM'), + c.foil ? 'Yes' : 'No', + csvEscape(c.rarity || ''), + csvEscape(c.manaCost || ''), + c.cmc || 0, + csvEscape(c.typeLine || ''), + csvEscape((c.colorIdentity || []).join('')), + c.priceUsd || '', + c.priceFoilUsd || '', + csvEscape(c.scryfallId || ''), + c.dateAdded || '' + ]; + rows.push(row.join(',')); + }); + return rows.join('\n'); +} + +function csvEscape(str) { + str = String(str); + if (str.indexOf(',') >= 0 || str.indexOf('"') >= 0 || str.indexOf('\n') >= 0) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +function exportCollectionJSON(coll) { + return JSON.stringify(coll, null, 2); +} + +function exportCollectionText(coll) { + return coll.map(function(c) { + var qty = c.quantity || 1; + var set = c.set ? ' (' + c.set.toUpperCase() + ')' : ''; + var foil = c.foil ? ' *F*' : ''; + return qty + 'x ' + c.name + set + foil; + }).join('\n'); +} + +function copyExportToClipboard() { + var textarea = document.getElementById('coll-export-textarea'); + if (!textarea || !textarea.value) { showToast('Nothing to copy'); return; } + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(textarea.value).then(function() { + showToast('Copied to clipboard'); + }); + } else { + textarea.select(); + document.execCommand('copy'); + showToast('Copied to clipboard'); + } + } catch(e) { + textarea.select(); + document.execCommand('copy'); + showToast('Copied to clipboard'); + } +} + +function downloadExportFile() { + var textarea = document.getElementById('coll-export-textarea'); + if (!textarea || !textarea.value) { showToast('Nothing to export'); return; } + var ext = collExportFormat === 'json' ? '.json' : collExportFormat === 'csv' ? '.csv' : '.txt'; + var mime = collExportFormat === 'json' ? 'application/json' : 'text/plain'; + var blob = new Blob([textarea.value], { type: mime }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'mtg-collection-' + new Date().toISOString().slice(0,10) + ext; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast('Download started'); +} + +// --- Import --- + +function openCollImportModal() { + var modal = document.getElementById('coll-import-modal'); + if (modal) modal.classList.remove('hidden'); + collImportFormat = 'text'; + collImportParsedCards = []; + switchImportTab('text'); + var textarea = document.getElementById('coll-import-textarea'); + if (textarea) { textarea.value = ''; } + hideImportPreview(); + hideImportProgress(); +} + +function closeCollImportModal() { + var modal = document.getElementById('coll-import-modal'); + if (modal) modal.classList.add('hidden'); + collImportParsedCards = []; +} + +function switchImportTab(tab) { + collImportFormat = tab; + document.querySelectorAll('.coll-ie-tab[id^="imp-tab-"]').forEach(function(t) { t.classList.remove('active'); }); + var el = document.getElementById('imp-tab-' + tab); + if (el) el.classList.add('active'); + + var hintEl = document.getElementById('coll-import-hint'); + var textarea = document.getElementById('coll-import-textarea'); + var fileArea = document.getElementById('coll-import-file-area'); + + // Show/hide textarea vs file upload + if (textarea) textarea.classList.toggle('hidden', tab === 'file'); + if (fileArea) fileArea.classList.toggle('hidden', tab !== 'file'); + + if (tab === 'text') { + if (hintEl) hintEl.innerHTML = 'Paste one card per line: 1x Card Name or 1 Card Name (SET)'; + if (textarea) textarea.placeholder = '1x Sol Ring (C21)\n2x Lightning Bolt\n1x Rhystic Study (PCY) *F*'; + } else if (tab === 'csv') { + if (hintEl) hintEl.innerHTML = 'Paste CSV data with headers. Must include Name column; Quantity optional.'; + if (textarea) textarea.placeholder = 'Quantity,Name,Set,Condition,Foil\n1,Sol Ring,C21,NM,No\n2,Lightning Bolt,2XM,LP,No'; + } else if (tab === 'json') { + if (hintEl) hintEl.innerHTML = 'Paste JSON array from a previous export.'; + if (textarea) textarea.placeholder = '[{"name":"Sol Ring","quantity":1,"set":"C21",...}]'; + } else if (tab === 'file') { + if (hintEl) hintEl.textContent = 'Upload a .csv, .json, or .txt file.'; + } + hideImportPreview(); +} + +function handleImportFile(input) { + if (!input.files || !input.files[0]) return; + var file = input.files[0]; + var reader = new FileReader(); + reader.onload = function(e) { + var content = e.target.result; + // Auto-detect format from extension + var name = file.name.toLowerCase(); + if (name.endsWith('.json')) { + collImportFormat = 'json'; + } else if (name.endsWith('.csv')) { + collImportFormat = 'csv'; + } else { + collImportFormat = 'text'; + } + // Put content in textarea and switch to that tab + var textarea = document.getElementById('coll-import-textarea'); + if (textarea) { textarea.value = content; textarea.classList.remove('hidden'); } + var fileArea = document.getElementById('coll-import-file-area'); + if (fileArea) fileArea.classList.add('hidden'); + // Activate correct tab + document.querySelectorAll('.coll-ie-tab[id^="imp-tab-"]').forEach(function(t) { t.classList.remove('active'); }); + var tabEl = document.getElementById('imp-tab-' + collImportFormat); + if (tabEl) tabEl.classList.add('active'); + showToast('Loaded ' + file.name); + }; + reader.readAsText(file); +} + +function hideImportPreview() { + var el = document.getElementById('coll-import-preview'); + if (el) el.classList.add('hidden'); +} + +function hideImportProgress() { + var el = document.getElementById('coll-import-progress'); + if (el) el.classList.add('hidden'); +} + +function showImportProgress(current, total, text) { + var el = document.getElementById('coll-import-progress'); + if (el) el.classList.remove('hidden'); + var fill = document.getElementById('coll-import-progress-fill'); + if (fill) fill.style.width = Math.round((current / total) * 100) + '%'; + var txt = document.getElementById('coll-import-progress-text'); + if (txt) txt.textContent = text || ('Processing ' + current + ' of ' + total + '...'); +} + +// --- Parse import data --- + +function parseImportText(text) { + var lines = text.split('\n').map(function(l) { return l.trim(); }).filter(function(l) { return l && !l.startsWith('//') && !l.startsWith('#'); }); + var cards = []; + lines.forEach(function(line) { + // Patterns: "1x Card Name (SET) *F*", "1 Card Name", "Card Name" + var foil = false; + if (/\*F\*$/i.test(line)) { + foil = true; + line = line.replace(/\s*\*F\*$/i, '').trim(); + } + if (/\bfoil$/i.test(line)) { + foil = true; + line = line.replace(/\s+foil$/i, '').trim(); + } + var setCode = ''; + var setMatch = line.match(/\(([A-Za-z0-9]+)\)\s*$/); + if (setMatch) { + setCode = setMatch[1].toLowerCase(); + line = line.replace(/\s*\([A-Za-z0-9]+\)\s*$/, '').trim(); + } + var qty = 1; + var qtyMatch = line.match(/^(\d+)x?\s+/i); + if (qtyMatch) { + qty = parseInt(qtyMatch[1]) || 1; + line = line.replace(/^\d+x?\s+/i, '').trim(); + } + var name = line; + if (name) { + cards.push({ name: name, quantity: qty, set: setCode, foil: foil, condition: 'NM' }); + } + }); + return cards; +} + +function parseImportCSV(csvText) { + var lines = csvText.split('\n'); + if (lines.length < 2) return []; + + // Parse header + var headers = parseCSVRow(lines[0]).map(function(h) { return h.toLowerCase().trim(); }); + var nameIdx = headers.indexOf('name'); + if (nameIdx < 0) nameIdx = headers.indexOf('card name'); + if (nameIdx < 0) nameIdx = headers.indexOf('card'); + if (nameIdx < 0) { showToast('CSV must have a Name column'); return []; } + + var qtyIdx = findHeaderIndex(headers, ['quantity', 'qty', 'count', 'amount']); + var setIdx = findHeaderIndex(headers, ['set', 'set code', 'edition']); + var setNameIdx = findHeaderIndex(headers, ['set name', 'set_name', 'setname', 'edition name']); + var condIdx = findHeaderIndex(headers, ['condition', 'cond']); + var foilIdx = findHeaderIndex(headers, ['foil']); + var rarityIdx = findHeaderIndex(headers, ['rarity']); + var manaIdx = findHeaderIndex(headers, ['mana cost', 'mana_cost', 'manacost']); + var cmcIdx = findHeaderIndex(headers, ['cmc', 'mana value', 'mv']); + var typeIdx = findHeaderIndex(headers, ['type', 'type line', 'type_line', 'typeline']); + var colorIdx = findHeaderIndex(headers, ['color identity', 'color_identity', 'coloridentity', 'colors']); + var priceIdx = findHeaderIndex(headers, ['price usd', 'price_usd', 'priceusd', 'price']); + var priceFoilIdx = findHeaderIndex(headers, ['price foil usd', 'price_foil_usd', 'pricefoilusd']); + var sidIdx = findHeaderIndex(headers, ['scryfall id', 'scryfall_id', 'scryfallid']); + var collNumIdx = findHeaderIndex(headers, ['collector number', 'collector_number', 'collectornumber', 'number']); + var dateIdx = findHeaderIndex(headers, ['date added', 'date_added', 'dateadded', 'date']); + + var cards = []; + for (var i = 1; i < lines.length; i++) { + if (!lines[i].trim()) continue; + var vals = parseCSVRow(lines[i]); + var name = (vals[nameIdx] || '').trim(); + if (!name) continue; + + var foilVal = foilIdx >= 0 ? (vals[foilIdx] || '').trim().toLowerCase() : ''; + var isFoil = foilVal === 'yes' || foilVal === 'true' || foilVal === '1' || foilVal === 'foil'; + var colorId = colorIdx >= 0 ? (vals[colorIdx] || '').trim() : ''; + var colorArr = colorId ? colorId.split('').filter(function(c) { return 'WUBRGC'.indexOf(c) >= 0; }) : []; + + cards.push({ + name: name, + quantity: qtyIdx >= 0 ? (parseInt(vals[qtyIdx]) || 1) : 1, + set: setIdx >= 0 ? (vals[setIdx] || '').trim().toLowerCase() : '', + setName: setNameIdx >= 0 ? (vals[setNameIdx] || '').trim() : '', + collectorNumber: collNumIdx >= 0 ? (vals[collNumIdx] || '').trim() : '', + condition: condIdx >= 0 ? (vals[condIdx] || 'NM').trim() : 'NM', + foil: isFoil, + rarity: rarityIdx >= 0 ? (vals[rarityIdx] || '').trim().toLowerCase() : '', + manaCost: manaIdx >= 0 ? (vals[manaIdx] || '').trim() : '', + cmc: cmcIdx >= 0 ? (parseFloat(vals[cmcIdx]) || 0) : 0, + typeLine: typeIdx >= 0 ? (vals[typeIdx] || '').trim() : '', + colorIdentity: colorArr, + priceUsd: priceIdx >= 0 ? (vals[priceIdx] || '').trim() || null : null, + priceFoilUsd: priceFoilIdx >= 0 ? (vals[priceFoilIdx] || '').trim() || null : null, + scryfallId: sidIdx >= 0 ? (vals[sidIdx] || '').trim() : '', + dateAdded: dateIdx >= 0 ? (vals[dateIdx] || '').trim() : new Date().toISOString() + }); + } + return cards; +} + +function findHeaderIndex(headers, aliases) { + for (var i = 0; i < aliases.length; i++) { + var idx = headers.indexOf(aliases[i]); + if (idx >= 0) return idx; + } + return -1; +} + +function parseCSVRow(line) { + var result = []; + var current = ''; + var inQuotes = false; + for (var i = 0; i < line.length; i++) { + var ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += ch; + } + } else { + if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + result.push(current); + current = ''; + } else { + current += ch; + } + } + } + result.push(current); + return result; +} + +function parseImportJSON(jsonText) { + try { + var data = JSON.parse(jsonText); + if (!Array.isArray(data)) { + showToast('JSON must be an array of card objects'); + return []; + } + return data.map(function(c) { + return { + scryfallId: c.scryfallId || '', + name: c.name || '', + set: c.set || '', + setName: c.setName || '', + collectorNumber: c.collectorNumber || '', + imageUrl: c.imageUrl || '', + typeLine: c.typeLine || '', + manaCost: c.manaCost || '', + colorIdentity: c.colorIdentity || [], + rarity: c.rarity || '', + cmc: c.cmc || 0, + quantity: c.quantity || 1, + condition: c.condition || 'NM', + foil: !!c.foil, + priceUsd: c.priceUsd || null, + priceFoilUsd: c.priceFoilUsd || null, + dateAdded: c.dateAdded || new Date().toISOString() + }; + }).filter(function(c) { return c.name; }); + } catch(e) { + showToast('Invalid JSON: ' + e.message); + return []; + } +} + +// --- Preview --- + +function previewCollImport() { + var textarea = document.getElementById('coll-import-textarea'); + var text = textarea ? textarea.value.trim() : ''; + if (!text) { showToast('Nothing to preview \u2014 paste or upload data first'); return; } + + var cards; + if (collImportFormat === 'csv') { + cards = parseImportCSV(text); + } else if (collImportFormat === 'json') { + cards = parseImportJSON(text); + } else { + cards = parseImportText(text); + } + + if (!cards.length) { showToast('No valid cards found'); return; } + collImportParsedCards = cards; + + // Show preview + var previewEl = document.getElementById('coll-import-preview'); + var countEl = document.getElementById('coll-import-count'); + var listEl = document.getElementById('coll-import-preview-list'); + if (!previewEl || !listEl) return; + + var totalQty = cards.reduce(function(sum, c) { return sum + (c.quantity || 1); }, 0); + if (countEl) countEl.textContent = totalQty + ' cards, ' + cards.length + ' unique'; + + var html = ''; + cards.slice(0, 100).forEach(function(c) { + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + if (cards.length > 100) { + html += ''; + } + html += '
QtyNameSetConditionFoil
' + (c.quantity || 1) + '' + escapeHtml(c.name) + '' + escapeHtml((c.set || '').toUpperCase()) + '' + escapeHtml(c.condition || 'NM') + '' + (c.foil ? '\u2726' : '') + '
...and ' + (cards.length - 100) + ' more
'; + listEl.innerHTML = html; + previewEl.classList.remove('hidden'); +} + +// --- Execute Import --- + +async function executeCollImport() { + var textarea = document.getElementById('coll-import-textarea'); + var text = textarea ? textarea.value.trim() : ''; + if (!text) { showToast('Nothing to import \u2014 paste or upload data first'); return; } + + // Parse if not already previewed + if (!collImportParsedCards.length) { + if (collImportFormat === 'csv') { + collImportParsedCards = parseImportCSV(text); + } else if (collImportFormat === 'json') { + collImportParsedCards = parseImportJSON(text); + } else { + collImportParsedCards = parseImportText(text); + } + } + + if (!collImportParsedCards.length) { showToast('No valid cards found'); return; } + + var cards = collImportParsedCards; + var merge = document.getElementById('coll-import-merge')?.checked !== false; + var doLookup = document.getElementById('coll-import-lookup')?.checked !== false; + var importBtn = document.getElementById('coll-import-btn'); + if (importBtn) importBtn.disabled = true; + + // Determine which cards need Scryfall lookup + // Cards from text import or CSV without scryfallId need lookup + var needsLookup = doLookup && cards.some(function(c) { return !c.scryfallId && !c.imageUrl; }); + + if (needsLookup) { + // Batch lookup via Scryfall /cards/collection (max 75 per request) + var cardsToLookup = cards.filter(function(c) { return !c.scryfallId || !c.imageUrl; }); + var batches = []; + for (var i = 0; i < cardsToLookup.length; i += 75) { + batches.push(cardsToLookup.slice(i, i + 75)); + } + + showImportProgress(0, cardsToLookup.length, 'Looking up card data from Scryfall...'); + + var lookupMap = {}; // name_set -> scryfall data + var processed = 0; + + for (var b = 0; b < batches.length; b++) { + var batch = batches[b]; + var identifiers = batch.map(function(c) { + if (c.set && c.collectorNumber) { + return { set: c.set, collector_number: c.collectorNumber }; + } + if (c.set) { + return { name: c.name, set: c.set }; + } + return { name: c.name }; + }); + + try { + var resp = await fetch('https://api.scryfall.com/cards/collection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifiers: identifiers }) + }); + var result = await resp.json(); + + if (result.data) { + result.data.forEach(function(sf) { + var key = sf.name.toLowerCase(); + lookupMap[key] = sf; + if (sf.set) lookupMap[key + '_' + sf.set] = sf; + }); + } + } catch(e) { + console.error('Scryfall batch lookup failed:', e); + } + + processed += batch.length; + showImportProgress(processed, cardsToLookup.length, 'Looking up ' + processed + ' of ' + cardsToLookup.length + '...'); + + // Scryfall rate limit: ~100ms between requests + if (b < batches.length - 1) { + await new Promise(function(r) { setTimeout(r, 120); }); + } + } + + // Enrich cards with Scryfall data + cards.forEach(function(c) { + var key = c.name.toLowerCase(); + var sf = lookupMap[key + '_' + c.set] || lookupMap[key]; + if (sf) { + c.scryfallId = c.scryfallId || sf.id; + c.imageUrl = c.imageUrl || ((sf.image_uris && sf.image_uris.small) || ''); + c.typeLine = c.typeLine || sf.type_line || ''; + c.manaCost = c.manaCost || sf.mana_cost || ''; + c.colorIdentity = (c.colorIdentity && c.colorIdentity.length) ? c.colorIdentity : (sf.color_identity || []); + c.rarity = c.rarity || sf.rarity || ''; + c.cmc = c.cmc || sf.cmc || 0; + c.set = c.set || sf.set || ''; + c.setName = c.setName || sf.set_name || ''; + c.collectorNumber = c.collectorNumber || sf.collector_number || ''; + c.priceUsd = c.priceUsd || ((sf.prices && sf.prices.usd) || null); + c.priceFoilUsd = c.priceFoilUsd || ((sf.prices && sf.prices.usd_foil) || null); + } + }); + } + + // Add dateAdded to any card missing it + var now = new Date().toISOString(); + cards.forEach(function(c) { if (!c.dateAdded) c.dateAdded = now; }); + + // Merge into collection + var coll = getCollection(); + var added = 0, merged = 0; + + showImportProgress(0, cards.length, 'Adding cards to collection...'); + + cards.forEach(function(c, idx) { + if (merge) { + // Find existing by scryfallId+foil+condition, or name+set+foil+condition + var existIdx = coll.findIndex(function(ex) { + if (c.scryfallId && ex.scryfallId === c.scryfallId && ex.foil === c.foil && ex.condition === c.condition) return true; + return ex.name.toLowerCase() === c.name.toLowerCase() && ex.foil === c.foil && ex.condition === c.condition + && (!c.set || ex.set === c.set); + }); + if (existIdx >= 0) { + coll[existIdx].quantity += (c.quantity || 1); + merged++; + } else { + coll.push(c); + added++; + } + } else { + coll.push(c); + added++; + } + }); + + saveCollection(coll); + renderCollectionGrid(); + updateCollStats(); + hideImportProgress(); + + var msg = 'Imported ' + (added + merged) + ' entries'; + if (merged > 0) msg += ' (' + added + ' new, ' + merged + ' merged)'; + showToast(msg); + + if (importBtn) importBtn.disabled = false; + collImportParsedCards = []; + closeCollImportModal(); +} + +// --- File drop zone --- +function initCollImportDropzone() { + var dropzone = document.getElementById('coll-import-dropzone'); + if (!dropzone) return; + dropzone.onclick = function() { + document.getElementById('coll-import-file')?.click(); + }; + dropzone.ondragover = function(e) { + e.preventDefault(); + dropzone.classList.add('drag-over'); + }; + dropzone.ondragleave = function() { + dropzone.classList.remove('drag-over'); + }; + dropzone.ondrop = function(e) { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + var fakeInput = { files: e.dataTransfer.files }; + handleImportFile(fakeInput); + } + }; +} + +// ============================================================ +// DECK BUILDER +// ============================================================ + +function focusDbSearch() { + var el = document.getElementById('db-search-input'); + if (el) el.focus(); +} + +// Debounced search for deck builder +function debouncedDbSearch() { + clearTimeout(dbSearchTimer); + var input = document.getElementById('db-search-input'); + if (!input) return; + var q = input.value.trim(); + var status = document.getElementById('db-search-status'); + if (q.length < 2) { + if (status) status.textContent = 'Type to search...'; + var grid = document.getElementById('db-results-grid'); + if (grid) grid.innerHTML = '
Search for cards above to start building.
'; + return; + } + if (status) status.textContent = 'Searching...'; + dbSearchTimer = setTimeout(function() { searchScryfallForDb(q); }, 300); +} + +async function searchScryfallForDb(query) { + var status = document.getElementById('db-search-status'); + var grid = document.getElementById('db-results-grid'); + if (!grid) return; + + var cacheKey = 'db_' + query; + if (sfCollCache[cacheKey]) { + dbSearchResults = sfCollCache[cacheKey]; + renderDbResultGrid(dbSearchResults); + if (status) status.textContent = dbSearchResults.length + ' results'; + return; + } + + try { + var url = 'https://api.scryfall.com/cards/search?q=' + encodeURIComponent(query) + '&unique=cards&order=name&limit=40'; + var resp = await fetch(url); + if (!resp.ok) { + if (status) status.textContent = 'No results'; + grid.innerHTML = '
No cards found.
'; + return; + } + var data = await resp.json(); + dbSearchResults = data.data || []; + sfCollCache[cacheKey] = dbSearchResults; + renderDbResultGrid(dbSearchResults); + if (status) status.textContent = dbSearchResults.length + ' results'; + } catch(e) { + if (status) status.textContent = 'Error'; + grid.innerHTML = '
Search error. Try again.
'; + } +} + +function renderDbResultGrid(cards) { + var grid = document.getElementById('db-results-grid'); + if (!grid) return; + if (!cards || !cards.length) { + grid.innerHTML = '
No results.
'; + return; + } + var html = ''; + cards.forEach(function(card, i) { + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var inDeck = dbCurrentDeck.cards.find(function(c) { return c.scryfallId === card.id; }); + var inDeckQty = inDeck ? inDeck.quantity : 0; + html += '
' + + (imgUrl ? '' + escapeHtml(card.name) + '' : '
📷
' + escapeHtml(card.name) + '
') + + (inDeckQty > 0 ? '
×' + inDeckQty + '
' : '') + + '' + + '
'; + }); + grid.innerHTML = html; +} + +function dbAddCard(searchIdx) { + var card = dbSearchResults[searchIdx]; + if (!card) return; + addCardToDeck(card); +} + +function addCardToDeck(card) { + var isBasicLand = BASIC_LANDS.includes(card.name); + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var existing = dbCurrentDeck.cards.find(function(c) { return c.scryfallId === card.id; }); + if (existing) { + if (isBasicLand) { + existing.quantity += 1; + } else if (existing.quantity < 1) { + existing.quantity = 1; + } else { + // Singleton: already have 1, show toast + showToast(card.name + ' is already in the deck (singleton rule)'); + return; + } + } else { + dbCurrentDeck.cards.push({ + scryfallId: card.id, + name: card.name, + imageUrl: imgUrl, + typeLine: card.type_line || '', + manaCost: card.mana_cost || '', + cmc: card.cmc || 0, + quantity: 1, + colorIdentity: card.color_identity || [], + legality: (card.legalities && card.legalities.commander) || null, + keywords: card.keywords || [], + oracleText: card.oracle_text || '' + }); + } + dbCurrentDeck.dateModified = new Date().toISOString(); + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); + showToast('Added ' + card.name); +} + +function dbCtxOpen(e, searchIdx) { + dbCtxCard = dbSearchResults[searchIdx]; + var menu = document.getElementById('db-ctx-menu'); + if (!menu) return; + menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px'; + menu.classList.remove('hidden'); + + // Close on click elsewhere + setTimeout(function() { + document.addEventListener('click', closeDbCtxMenu, { once: true }); + }, 0); +} + +function closeDbCtxMenu() { + var menu = document.getElementById('db-ctx-menu'); + if (menu) menu.classList.add('hidden'); +} + +function dbCtxAddToDeck() { + if (dbCtxCard) addCardToDeck(dbCtxCard); + closeDbCtxMenu(); +} + +function dbCtxSetCommander() { + if (!dbCtxCard) return; + setDbCommander(dbCtxCard); + closeDbCtxMenu(); +} + +function dbCtxAddToCollection() { + if (!dbCtxCard) return; + var card = dbCtxCard; + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var coll = getCollection(); + var existing = coll.findIndex(function(c) { return c.scryfallId === card.id && !c.foil && c.condition === 'NM'; }); + if (existing >= 0) { + coll[existing].quantity += 1; + } else { + coll.push({ + scryfallId: card.id, name: card.name, set: card.set, + setName: card.set_name || card.set, collectorNumber: card.collector_number, + imageUrl: imgUrl, typeLine: card.type_line || '', manaCost: card.mana_cost || '', + colorIdentity: card.color_identity || [], rarity: card.rarity || '', + cmc: card.cmc || 0, quantity: 1, condition: 'NM', foil: false, + priceUsd: (card.prices && card.prices.usd) || null, priceFoilUsd: null, + dateAdded: new Date().toISOString() + }); + } + saveCollection(coll); + showToast('Added ' + card.name + ' to collection'); + closeDbCtxMenu(); +} + +function dbCtxAddToWishlist() { + if (!dbCtxCard) return; + var card = dbCtxCard; + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var wish = getWishlist(); + if (!wish.some(function(c) { return c.scryfallId === card.id; })) { + wish.push({ + scryfallId: card.id, name: card.name, set: card.set, + setName: card.set_name || card.set, collectorNumber: card.collector_number, + imageUrl: imgUrl, typeLine: card.type_line || '', manaCost: card.mana_cost || '', + colorIdentity: card.color_identity || [], rarity: card.rarity || '', + cmc: card.cmc || 0, priceUsd: (card.prices && card.prices.usd) || null, + dateAdded: new Date().toISOString() + }); + saveWishlist(wish); + showToast('Added ' + card.name + ' to wishlist'); + } else { + showToast(card.name + ' already in wishlist'); + } + closeDbCtxMenu(); +} + +function setDbCommander(card) { + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + dbCurrentDeck.commander = { + scryfallId: card.id, + name: card.name, + imageUrl: imgUrl, + typeLine: card.type_line || '', + colorIdentity: card.color_identity || [], + legality: (card.legalities && card.legalities.commander) || null, + keywords: card.keywords || [], + oracleText: card.oracle_text || '' + }; + + // Remove from main deck if present + dbCurrentDeck.cards = dbCurrentDeck.cards.filter(function(c) { return c.scryfallId !== card.id; }); + + renderDbCommanderSlot(); + renderDbList(); + renderDbStats(); + checkDbLegality(); + showToast('Set ' + card.name + ' as commander'); +} + +function renderDbCommanderSlot() { + var slot = document.getElementById('db-commander-display'); + if (!slot) return; + var cmd = dbCurrentDeck.commander; + if (!cmd) { + slot.outerHTML = '
No commander set — search and right-click a card to set as commander
'; + return; + } + var imgUrl = (cmd.imageUrl || '').replace('/small/', '/normal/'); + slot.outerHTML = '
' + + (cmd.imageUrl ? '' + escapeHtml(cmd.name) + '' : '') + + '
' + + '
' + escapeHtml(cmd.name) + '
' + + '
' + escapeHtml(cmd.typeLine || '') + '
' + + '
Color Identity: ' + ((cmd.colorIdentity || []).join(', ') || 'C') + '
' + + '
' + + '' + + '
'; +} + +function removeDbCommander() { + dbCurrentDeck.commander = null; + renderDbCommanderSlot(); + checkDbLegality(); +} + +// Deck list rendering +var DB_TYPE_ORDER = [ + { key: 'Creatures', test: function(t) { return /creature/i.test(t); } }, + { key: 'Planeswalkers', test: function(t) { return /planeswalker/i.test(t); } }, + { key: 'Instants', test: function(t) { return /instant/i.test(t); } }, + { key: 'Sorceries', test: function(t) { return /sorcery/i.test(t); } }, + { key: 'Artifacts', test: function(t) { return /artifact/i.test(t) && !/creature/i.test(t); } }, + { key: 'Enchantments', test: function(t) { return /enchantment/i.test(t) && !/creature/i.test(t); } }, + { key: 'Lands', test: function(t) { return /land/i.test(t); } }, + { key: 'Other', test: function(t) { return true; } } +]; + +function getCardGroup(typeLine) { + for (var i = 0; i < DB_TYPE_ORDER.length; i++) { + if (DB_TYPE_ORDER[i].test(typeLine || '')) return DB_TYPE_ORDER[i].key; + } + return 'Other'; +} + +function renderDbList() { + var listEl = document.getElementById('db-list'); + if (!listEl) return; + + var cards = dbCurrentDeck.cards; + if (!cards.length) { + listEl.innerHTML = '
Search for cards on the right to build your deck.
'; + updateDbCountDisplay(); + return; + } + + // Group by type + var groups = {}; + DB_TYPE_ORDER.forEach(function(g) { groups[g.key] = []; }); + cards.forEach(function(card, idx) { + var group = getCardGroup(card.typeLine); + groups[group].push({ card: card, idx: idx }); + }); + + var html = ''; + DB_TYPE_ORDER.forEach(function(g) { + var group = groups[g.key]; + if (!group.length) return; + var groupQty = group.reduce(function(s, item) { return s + item.card.quantity; }, 0); + html += '
' + + '
' + + '' + g.key + '' + + '' + groupQty + '' + + '
' + + '
'; + group.forEach(function(item) { + var card = item.card; + var idx = item.idx; + html += '
' + + '
' + + '' + + '' + card.quantity + '' + + '' + + '
' + + '' + + '' + escapeHtml(card.name) + '' + + '' + escapeHtml(card.manaCost || '') + '' + + '' + + '
'; + }); + html += '
'; + }); + + listEl.innerHTML = html; + updateDbCountDisplay(); +} + +function updateDbCountDisplay() { + var total = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0); + if (dbCurrentDeck.commander) total += 1; + var numEl = document.getElementById('db-count-num'); + var countEl = document.getElementById('db-card-count'); + if (numEl) numEl.textContent = total; + if (countEl) { + countEl.classList.toggle('over-limit', total > 100); + } +} + +function toggleDbGroup(headerEl) { + var cards = headerEl.nextElementSibling; + if (!cards) return; + var isCollapsed = cards.style.display === 'none'; + cards.style.display = isCollapsed ? '' : 'none'; +} + +function dbChangeQty(idx, delta) { + if (!dbCurrentDeck.cards[idx]) return; + var card = dbCurrentDeck.cards[idx]; + var isBasicLand = BASIC_LANDS.includes(card.name); + var newQty = card.quantity + delta; + if (newQty <= 0) { + dbCurrentDeck.cards.splice(idx, 1); + } else if (!isBasicLand && newQty > 1) { + // Singleton rule: cap at 1 for non-basics + card.quantity = 1; + } else { + card.quantity = newQty; + } + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); +} + +function dbRemoveCard(idx) { + if (!dbCurrentDeck.cards[idx]) return; + dbCurrentDeck.cards.splice(idx, 1); + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); +} + +function clearCurrentDeck() { + if (!confirm('Clear the current deck? This cannot be undone.')) return; + dbCurrentDeck.cards = []; + dbCurrentDeck.commander = null; + renderDbCommanderSlot(); + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); +} + +// --- Mana Curve & Stats --- +function toggleDbStats() { + dbStatsOpen = !dbStatsOpen; + var panel = document.getElementById('db-stats-panel'); + var arrow = document.getElementById('db-stats-arrow'); + if (panel) panel.style.display = dbStatsOpen ? '' : 'none'; + if (arrow) arrow.textContent = dbStatsOpen ? '▾' : '▸'; + if (dbStatsOpen) renderDbStats(); +} + +function renderDbStats() { + if (!dbStatsOpen) return; + renderDbCurve(); + renderDbColors(); + renderDbTypes(); +} + +function renderDbCurve() { + var el = document.getElementById('db-curve'); + if (!el) return; + var buckets = [0, 0, 0, 0, 0, 0, 0, 0]; // 0,1,2,3,4,5,6,7+ + dbCurrentDeck.cards.forEach(function(c) { + var cmc = Math.min(7, Math.floor(c.cmc || 0)); + buckets[cmc] += c.quantity; + }); + var max = Math.max.apply(null, buckets) || 1; + var cmcLabels = ['0','1','2','3','4','5','6','7+']; + var colors = ['#8C8C8C','#F9E48B','#5b9bd5','#8B4513','#D3202A','#00733E','#9B59B6','#C8A843']; + var html = ''; + buckets.forEach(function(count, i) { + var pct = Math.round((count / max) * 100); + html += '
' + + '
' + + '
' + cmcLabels[i] + '
' + + '
'; + }); + el.innerHTML = html; +} + +function renderDbColors() { + var el = document.getElementById('db-colors'); + if (!el) return; + var colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; + var colorNames = { W: '#F9E48B', U: '#5b9bd5', B: '#aaaaaa', R: '#D3202A', G: '#00733E', C: '#8C8C8C' }; + dbCurrentDeck.cards.forEach(function(c) { + var ci = c.colorIdentity || []; + if (!ci.length) colorCounts.C += c.quantity; + ci.forEach(function(col) { if (colorCounts[col] !== undefined) colorCounts[col] += c.quantity; }); + }); + var max = Math.max.apply(null, Object.values(colorCounts)) || 1; + var html = ''; + Object.keys(colorCounts).forEach(function(col) { + var count = colorCounts[col]; + if (!count) return; + var pct = Math.round((count / max) * 100); + html += '
' + + '
' + + '
' + + '' + count + '' + + '
'; + }); + el.innerHTML = html || 'No cards'; +} + +function renderDbTypes() { + var el = document.getElementById('db-types'); + if (!el) return; + var typeCounts = { Creatures: 0, Instants: 0, Sorceries: 0, Artifacts: 0, Enchantments: 0, Planeswalkers: 0, Lands: 0, Other: 0 }; + dbCurrentDeck.cards.forEach(function(c) { + var group = getCardGroup(c.typeLine); + typeCounts[group] = (typeCounts[group] || 0) + c.quantity; + }); + var max = Math.max.apply(null, Object.values(typeCounts)) || 1; + var html = ''; + Object.keys(typeCounts).forEach(function(type) { + var count = typeCounts[type]; + if (!count) return; + var pct = Math.round((count / max) * 100); + html += '
' + + '' + type + '' + + '
' + + '' + count + '' + + '
'; + }); + el.innerHTML = html || 'No cards'; +} + +// --- Legality Check --- +function checkDbLegality() { + // Support both old banner and new panel + var banner = document.getElementById('db-legality-banner'); + var panel = document.getElementById('db-legality-panel'); + + var total = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0); + if (dbCurrentDeck.commander) total += 1; + + // Hide both if deck is empty + if (total === 0) { + if (banner) banner.style.display = 'none'; + if (panel) panel.style.display = 'none'; + return; + } + + var errors = []; + var warnings = []; + var info = []; + + // ---- ERRORS ---- + + // 1. Card count check + if (total !== 100) { + if (total >= 98 && total <= 99) { + warnings.push('Deck has ' + total + ' cards — almost there! (need 100)'); + } else if (total >= 101 && total <= 102) { + warnings.push('Deck has ' + total + ' cards — just over 100'); + } else { + errors.push('Deck has ' + total + ' cards (must be exactly 100)'); + } + } + + // 2. No commander + if (!dbCurrentDeck.commander) { + errors.push('No commander set'); + } + + // 3. Commander type validation + if (dbCurrentDeck.commander) { + var cmd = dbCurrentDeck.commander; + var tl = (cmd.typeLine || '').toLowerCase(); + var ot = (cmd.oracleText || '').toLowerCase(); + var isLegendaryCreature = /legendary/.test(tl) && /creature/.test(tl); + var isLegendaryPlaneswalker = /legendary/.test(tl) && /planeswalker/.test(tl); + var canBeCommander = /can be your commander/.test(ot); + if (!isLegendaryCreature && !canBeCommander && !isLegendaryPlaneswalker) { + errors.push(cmd.name + ' is not a Legendary Creature and cannot be your commander'); + } + } + + // 4. Banned/not legal cards (Scryfall legality first, fallback to COMMANDER_BANNED) + var allCards = dbCurrentDeck.cards.slice(); + if (dbCurrentDeck.commander) allCards.push(dbCurrentDeck.commander); + allCards.forEach(function(c) { + if (c.legality === 'banned') { + errors.push(c.name + ' is banned in Commander'); + } else if (c.legality === 'not_legal') { + errors.push(c.name + ' is not legal in Commander'); + } else if (!c.legality && COMMANDER_BANNED.includes(c.name)) { + // Fallback for cards without stored legality + errors.push(c.name + ' is banned in Commander'); + } + }); + + // 5. Color identity violations + if (dbCurrentDeck.commander) { + var cmdCI = dbCurrentDeck.commander.colorIdentity || []; + dbCurrentDeck.cards.forEach(function(c) { + var cardCI = c.colorIdentity || []; + var violations = cardCI.filter(function(col) { return !cmdCI.includes(col); }); + if (violations.length) { + errors.push(c.name + ' has color(s) ' + violations.join(', ') + ' outside commander identity'); + } + }); + } + + // 6. Singleton violations + dbCurrentDeck.cards.forEach(function(c) { + if (!BASIC_LANDS.includes(c.name) && c.quantity > 1) { + var ot2 = (c.oracleText || '').toLowerCase(); + if (!/a deck can have any number of cards named/.test(ot2)) { + errors.push(c.name + ': only 1 copy allowed (singleton rule)'); + } + } + }); + + // 7. Partner validation + if (dbCurrentDeck.commander) { + var cmdKw = dbCurrentDeck.commander.keywords || []; + var hasPartnerWith = cmdKw.some(function(k) { return /^partner with/i.test(k); }); + if (hasPartnerWith) { + warnings.push(dbCurrentDeck.commander.name + ' has Partner With — make sure the correct partner is set as a second commander'); + } + } + + // ---- WARNINGS ---- + + // High average CMC + var cmcCards = dbCurrentDeck.cards.filter(function(c) { return !/land/i.test(c.typeLine || ''); }); + if (cmcCards.length > 0) { + var totalCmc = cmcCards.reduce(function(s, c) { return s + (c.cmc * c.quantity); }, 0); + var totalCmcQty = cmcCards.reduce(function(s, c) { return s + c.quantity; }, 0); + var avgCmc = totalCmcQty > 0 ? totalCmc / totalCmcQty : 0; + if (avgCmc > 4.0) { + warnings.push('Average CMC is ' + avgCmc.toFixed(2) + ' — mana curve is high'); + } + } + + // Low land count + var landCount = dbCurrentDeck.cards.reduce(function(s, c) { + return s + (/land/i.test(c.typeLine || '') ? c.quantity : 0); + }, 0); + if (landCount < 30 && total >= 90) { + warnings.push('Only ' + landCount + ' lands — consider running at least 30'); + } + + // No card draw + var hasCardDraw = dbCurrentDeck.cards.some(function(c) { + return /draw/i.test(c.oracleText || ''); + }); + if (!hasCardDraw && dbCurrentDeck.cards.length > 10) { + warnings.push('No obvious card draw detected — consider adding some'); + } + + // No removal/interaction + var hasRemoval = dbCurrentDeck.cards.some(function(c) { + var ot3 = (c.oracleText || '').toLowerCase(); + return /destroy|exile|counter/.test(ot3); + }); + if (!hasRemoval && dbCurrentDeck.cards.length > 10) { + warnings.push('No obvious removal/interaction detected'); + } + + // No ramp + var hasRamp = dbCurrentDeck.cards.some(function(c) { + var ot4 = (c.oracleText || '').toLowerCase(); + return /search your library for a.*land|add.*mana/.test(ot4); + }); + if (!hasRamp && dbCurrentDeck.cards.length > 10) { + warnings.push('No obvious ramp detected — consider adding mana acceleration'); + } + + // ---- INFO ---- + + // Commander color identity summary + if (dbCurrentDeck.commander) { + var ci = dbCurrentDeck.commander.colorIdentity || []; + var ciNames = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green' }; + var ciStr = ci.length ? ci.map(function(x) { return ciNames[x] || x; }).join(', ') : 'Colorless'; + info.push('Commander color identity: ' + ciStr); + } + + // Companion candidate + var companions = dbCurrentDeck.cards.filter(function(c) { + var kw = c.keywords || []; + return kw.some(function(k) { return /^companion$/i.test(k); }); + }); + if (companions.length) { + companions.forEach(function(c) { + info.push(c.name + ' has the Companion keyword — it could be used as a companion'); + }); + } + + // Deck composition breakdown + var composition = { Creatures: 0, Planeswalkers: 0, Instants: 0, Sorceries: 0, Artifacts: 0, Enchantments: 0, Lands: 0, Other: 0 }; + dbCurrentDeck.cards.forEach(function(c) { + var g = getCardGroup(c.typeLine || ''); + composition[g] = (composition[g] || 0) + c.quantity; + }); + var compParts = []; + Object.keys(composition).forEach(function(k) { if (composition[k] > 0) compParts.push(k + ': ' + composition[k]); }); + if (compParts.length) info.push('Deck: ' + compParts.join(' | ')); + + // ---- UPDATE UI ---- + + // Update old banner (hide it since we have the new panel) + if (banner) banner.style.display = 'none'; + + // Update new panel + if (!panel) return; + panel.style.display = ''; + + var statusIcon = document.getElementById('dlp-status-icon'); + var statusText = document.getElementById('dlp-status-text'); + var errorsEl = document.getElementById('dlp-errors'); + var warningsEl = document.getElementById('dlp-warnings'); + var infoEl = document.getElementById('dlp-info'); + var compositionEl = document.getElementById('dlp-composition'); + + // Header status + if (errors.length) { + if (statusIcon) statusIcon.textContent = '✕'; + if (statusIcon) statusIcon.className = 'dlp-status-icon dlp-icon-error'; + if (statusText) statusText.textContent = errors.length + ' Error' + (errors.length !== 1 ? 's' : '') + (warnings.length ? ', ' + warnings.length + ' Warning' + (warnings.length !== 1 ? 's' : '') : ''); + panel.className = 'db-legality-panel dlp-has-errors'; + } else if (warnings.length) { + if (statusIcon) statusIcon.textContent = '⚠'; + if (statusIcon) statusIcon.className = 'dlp-status-icon dlp-icon-warning'; + if (statusText) statusText.textContent = warnings.length + ' Warning' + (warnings.length !== 1 ? 's' : ''); + panel.className = 'db-legality-panel dlp-has-warnings'; + } else { + if (statusIcon) statusIcon.textContent = '✓'; + if (statusIcon) statusIcon.className = 'dlp-status-icon dlp-icon-ok'; + if (statusText) statusText.textContent = 'Legal — ' + total + ' cards'; + panel.className = 'db-legality-panel dlp-is-ok'; + } + + // Errors section + if (errorsEl) { + if (errors.length) { + errorsEl.style.display = ''; + errorsEl.innerHTML = '
Errors
' + + errors.map(function(e) { return '
✕' + escapeHtml(e) + '
'; }).join(''); + } else { + errorsEl.style.display = 'none'; + } + } + + // Warnings section + if (warningsEl) { + if (warnings.length) { + warningsEl.style.display = ''; + warningsEl.innerHTML = '
Warnings
' + + warnings.map(function(w) { return '
⚠' + escapeHtml(w) + '
'; }).join(''); + } else { + warningsEl.style.display = 'none'; + } + } + + // Info section + if (infoEl) { + if (info.length) { + infoEl.style.display = ''; + infoEl.innerHTML = '
Info
' + + info.map(function(i2) { return '
ℹ' + escapeHtml(i2) + '
'; }).join(''); + } else { + infoEl.style.display = 'none'; + } + } + + // Composition bar + if (compositionEl) { + var typeColors = { + Creatures: '#2ecc71', Planeswalkers: '#f39c12', Instants: '#3498db', + Sorceries: '#e74c3c', Artifacts: '#95a5a6', Enchantments: '#9b59b6', + Lands: '#8B7355', Other: '#555' + }; + var totalNonCmd = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0); + if (totalNonCmd > 0) { + var barHtml = '
'; + var labelHtml = '
'; + DB_TYPE_ORDER.forEach(function(g) { + var count = composition[g.key] || 0; + if (count > 0) { + var pct = (count / totalNonCmd * 100).toFixed(1); + var color = typeColors[g.key] || '#555'; + barHtml += '
'; + labelHtml += '' + g.key + ' ' + count + ''; + } + }); + barHtml += '
'; + labelHtml += '
'; + compositionEl.style.display = ''; + compositionEl.innerHTML = labelHtml + barHtml; + } else { + compositionEl.style.display = 'none'; + } + } + + // Also update per-card indicators + updateCardLegalityDots(); +} + +// --- Save / Load Decks --- + +// --- Legality Panel helpers --- +function toggleLegalityPanel() { + var body = document.getElementById('dlp-body'); + var arrow = document.querySelector('.dlp-toggle-arrow'); + if (!body) return; + var collapsed = body.style.display === 'none'; + body.style.display = collapsed ? '' : 'none'; + if (arrow) arrow.textContent = collapsed ? '\u25bc' : '\u25b6'; + try { _mtgStore.setItem('mtg_dlp_collapsed', collapsed ? '0' : '1'); } catch(e) {} +} + +function initLegalityPanelState() { + var body = document.getElementById('dlp-body'); + var arrow = document.querySelector('.dlp-toggle-arrow'); + if (!body) return; + try { + var pref = _mtgStore.getItem('mtg_dlp_collapsed'); + if (pref === '1') { + body.style.display = 'none'; + if (arrow) arrow.textContent = '\u25b6'; + } + } catch(e) {} +} + +async function fullLegalityAudit() { + var btn = document.querySelector('.dlp-validate-btn'); + if (btn) { btn.textContent = '\u23f3 Auditing...'; btn.disabled = true; } + + var allCards = dbCurrentDeck.cards.slice(); + if (dbCurrentDeck.commander) allCards.push(dbCurrentDeck.commander); + + var cardIds = allCards + .filter(function(c) { return c.scryfallId; }) + .map(function(c) { return { id: c.scryfallId }; }); + + if (!cardIds.length) { + showToast('No cards to audit'); + if (btn) { btn.textContent = '\ud83d\udd0d Full Audit (re-check via Scryfall)'; btn.disabled = false; } + return; + } + + // Batch in groups of 75 + var batches = []; + for (var i = 0; i < cardIds.length; i += 75) { + batches.push(cardIds.slice(i, i + 75)); + } + + var legalityMap = {}; + for (var b = 0; b < batches.length; b++) { + try { + var resp = await fetch('https://api.scryfall.com/cards/collection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifiers: batches[b] }) + }); + var result = await resp.json(); + if (result.data) { + result.data.forEach(function(sf) { + legalityMap[sf.id] = { + legality: (sf.legalities && sf.legalities.commander) || null, + keywords: sf.keywords || [], + oracleText: sf.oracle_text || '' + }; + }); + } + } catch(e) { + console.error('Audit batch failed:', e); + } + if (b < batches.length - 1) { + await new Promise(function(r) { setTimeout(r, 120); }); + } + } + + // Update card legality data + dbCurrentDeck.cards.forEach(function(c) { + var fresh = legalityMap[c.scryfallId]; + if (fresh) { + c.legality = fresh.legality; + c.keywords = fresh.keywords; + c.oracleText = fresh.oracleText; + } + }); + if (dbCurrentDeck.commander) { + var freshCmd = legalityMap[dbCurrentDeck.commander.scryfallId]; + if (freshCmd) { + dbCurrentDeck.commander.legality = freshCmd.legality; + dbCurrentDeck.commander.keywords = freshCmd.keywords; + dbCurrentDeck.commander.oracleText = freshCmd.oracleText; + } + } + + if (btn) { btn.textContent = '\ud83d\udd0d Full Audit (re-check via Scryfall)'; btn.disabled = false; } + + checkDbLegality(); + + // Count after fresh check + var errCount = 0; + var warnCount = 0; + // Re-run minimal count for toast + var totalCards = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0); + if (dbCurrentDeck.commander) totalCards += 1; + dbCurrentDeck.cards.forEach(function(c) { + if (c.legality === 'banned' || c.legality === 'not_legal') errCount++; + }); + if (dbCurrentDeck.commander && (dbCurrentDeck.commander.legality === 'banned' || dbCurrentDeck.commander.legality === 'not_legal')) errCount++; + showToast('Audit complete: ' + errCount + ' legality issue' + (errCount !== 1 ? 's' : '') + ' found'); +} + +function getCardLegalityStatus(card) { + // Returns 'error', 'warning', or 'ok' + if (!card) return 'ok'; + // Check banned/not_legal + if (card.legality === 'banned' || card.legality === 'not_legal') return 'error'; + if (!card.legality && COMMANDER_BANNED.includes(card.name)) return 'error'; + // Check singleton + if (!BASIC_LANDS.includes(card.name) && card.quantity > 1) return 'error'; + // Check color identity + if (dbCurrentDeck.commander) { + var cmdCI = dbCurrentDeck.commander.colorIdentity || []; + var cardCI = card.colorIdentity || []; + var hasViolation = cardCI.some(function(col) { return !cmdCI.includes(col); }); + if (hasViolation) return 'error'; + } + return 'ok'; +} + +function updateCardLegalityDots() { + // Update per-card dot indicators already in the rendered list + var dots = document.querySelectorAll('.dlp-card-dot'); + dots.forEach(function(dot) { + var idx = parseInt(dot.getAttribute('data-idx'), 10); + if (isNaN(idx)) return; + var card = dbCurrentDeck.cards[idx]; + if (!card) return; + var status = getCardLegalityStatus(card); + dot.className = 'dlp-card-dot dlp-dot-' + status; + dot.title = status === 'error' ? 'Legality issue' : status === 'warning' ? 'Warning' : 'Legal'; + }); +} + +function saveCurrentDeck() { + var nameInput = document.getElementById('db-deck-name'); + if (nameInput) dbCurrentDeck.name = nameInput.value.trim() || 'Untitled Deck'; + if (!dbCurrentDeck.id) dbCurrentDeck.id = 'deck_' + Date.now(); + dbCurrentDeck.dateModified = new Date().toISOString(); + if (!dbCurrentDeck.dateCreated) dbCurrentDeck.dateCreated = dbCurrentDeck.dateModified; + + var decks = getDecks(); + var existingIdx = decks.findIndex(function(d) { return d.id === dbCurrentDeck.id; }); + if (existingIdx >= 0) { + decks[existingIdx] = JSON.parse(JSON.stringify(dbCurrentDeck)); + } else { + decks.push(JSON.parse(JSON.stringify(dbCurrentDeck))); + } + saveDecks(decks); + renderDbSavedDecks(); + showToast('Deck "' + dbCurrentDeck.name + '" saved!'); +} + +function loadDeckIntoBuilder(deckId) { + var decks = getDecks(); + var deck = decks.find(function(d) { return d.id === deckId; }); + if (!deck) return; + dbCurrentDeck = JSON.parse(JSON.stringify(deck)); + var nameInput = document.getElementById('db-deck-name'); + if (nameInput) nameInput.value = dbCurrentDeck.name; + renderDbCommanderSlot(); + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); + showToast('Loaded "' + deck.name + '"'); +} + +function duplicateDeck(deckId) { + var decks = getDecks(); + var deck = decks.find(function(d) { return d.id === deckId; }); + if (!deck) return; + var copy = JSON.parse(JSON.stringify(deck)); + copy.id = 'deck_' + Date.now(); + copy.name = deck.name + ' (Copy)'; + copy.dateCreated = new Date().toISOString(); + copy.dateModified = copy.dateCreated; + decks.push(copy); + saveDecks(decks); + renderDbSavedDecks(); + showToast('Duplicated "' + deck.name + '"'); +} + +function deleteSavedDeck(deckId) { + if (!confirm('Delete this deck? This cannot be undone.')) return; + var decks = getDecks().filter(function(d) { return d.id !== deckId; }); + saveDecks(decks); + if (dbCurrentDeck.id === deckId) { + dbCurrentDeck = { id: null, name: 'Untitled Deck', commander: null, cards: [], dateCreated: null, dateModified: null }; + var nameInput = document.getElementById('db-deck-name'); + if (nameInput) nameInput.value = 'Untitled Deck'; + renderDbCommanderSlot(); + renderDbList(); + } + renderDbSavedDecks(); + showToast('Deck deleted'); +} + +function toggleSavedDecks() { + dbSavedOpen = !dbSavedOpen; + var panel = document.getElementById('db-saved-panel'); + if (panel) panel.style.display = dbSavedOpen ? '' : 'none'; + if (dbSavedOpen) renderDbSavedDecks(); +} + +function renderDbSavedDecks() { + var listEl = document.getElementById('db-saved-list'); + if (!listEl) return; + var decks = getDecks(); + if (!decks.length) { + listEl.innerHTML = '
No saved decks.
'; + return; + } + var html = ''; + decks.forEach(function(deck) { + var cardCount = (deck.cards || []).reduce(function(s, c) { return s + c.quantity; }, 0) + (deck.commander ? 1 : 0); + var ci = (deck.commander && deck.commander.colorIdentity) || []; + var colorPips = ci.map(function(c) { + var colors = { W: '#F9E48B', U: '#5b9bd5', B: '#aaaaaa', R: '#D3202A', G: '#00733E' }; + return ''; + }).join(''); + html += '
' + + '
' + escapeHtml(deck.name) + '
' + + '
' + colorPips + ' ' + cardCount + ' cards
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }); + listEl.innerHTML = html; +} + +// --- Import / Export --- +function openDeckBuilderImport() { + var modal = document.getElementById('db-import-modal'); + if (modal) modal.classList.remove('hidden'); + switchDbImportTab('import'); + var textarea = document.getElementById('db-import-textarea'); + if (textarea) textarea.value = ''; + var status = document.getElementById('db-import-status'); + if (status) status.textContent = ''; + var progress = document.getElementById('db-import-progress'); + if (progress) progress.style.display = 'none'; + dbImportCancelFlag = false; +} + +function closeDbImportModal() { + var modal = document.getElementById('db-import-modal'); + if (modal) modal.classList.add('hidden'); + dbImportCancelFlag = true; +} + +function switchDbImportTab(tab) { + var importPanel = document.getElementById('db-imp-panel-import'); + var exportPanel = document.getElementById('db-imp-panel-export'); + var importTab = document.getElementById('db-imp-tab-import'); + var exportTab = document.getElementById('db-imp-tab-export'); + if (tab === 'import') { + if (importPanel) importPanel.style.display = ''; + if (exportPanel) exportPanel.classList.add('hidden'); + if (importTab) importTab.classList.add('active'); + if (exportTab) exportTab.classList.remove('active'); + } else { + if (importPanel) importPanel.style.display = 'none'; + if (exportPanel) exportPanel.classList.remove('hidden'); + if (importTab) importTab.classList.remove('active'); + if (exportTab) exportTab.classList.add('active'); + generateDbExport(); + } +} + +async function importDeckBuilder() { + var textarea = document.getElementById('db-import-textarea'); + if (!textarea || !textarea.value.trim()) return; + var lines = textarea.value.split('\n'); + var status = document.getElementById('db-import-status'); + var progressEl = document.getElementById('db-import-progress'); + var progressBar = document.getElementById('db-import-progress-bar'); + var progressText = document.getElementById('db-import-progress-text'); + var importBtn = document.getElementById('db-import-btn'); + + dbImportCancelFlag = false; + if (importBtn) importBtn.textContent = '⏸ Cancel'; + if (importBtn) importBtn.onclick = function() { dbImportCancelFlag = true; }; + + // Parse lines + var parsedCards = []; + var isCommanderSection = false; + lines.forEach(function(line) { + line = line.trim(); + if (!line) return; + if (/^\/\/\s*commander/i.test(line)) { isCommanderSection = true; return; } + if (/^\/\//.test(line)) { isCommanderSection = false; return; } + var match = line.match(/^(\d+)\s+(.+)$/); + if (!match) return; + parsedCards.push({ qty: parseInt(match[1]), name: match[2].trim(), isCommander: isCommanderSection }); + isCommanderSection = false; + }); + + if (!parsedCards.length) { + if (status) status.textContent = 'No valid card lines found.'; + return; + } + + if (progressEl) progressEl.style.display = ''; + if (status) status.textContent = 'Importing...'; + + // Clear current deck + dbCurrentDeck.cards = []; + dbCurrentDeck.commander = null; + + var total = parsedCards.length; + var done = 0; + var errors = []; + + for (var i = 0; i < parsedCards.length; i++) { + if (dbImportCancelFlag) { + if (status) status.textContent = 'Import cancelled.'; + break; + } + var item = parsedCards[i]; + try { + var url = 'https://api.scryfall.com/cards/named?exact=' + encodeURIComponent(item.name); + var resp = await fetch(url); + if (resp.ok) { + var card = await resp.json(); + var imgUrl = (card.image_uris && card.image_uris.small) || ''; + var cardEntry = { + scryfallId: card.id, + name: card.name, + imageUrl: imgUrl, + typeLine: card.type_line || '', + manaCost: card.mana_cost || '', + cmc: card.cmc || 0, + quantity: item.qty, + colorIdentity: card.color_identity || [], + legality: (card.legalities && card.legalities.commander) || null, + keywords: card.keywords || [], + oracleText: card.oracle_text || '' + }; + if (item.isCommander || (item.qty === 1 && /legendary.*creature/i.test(card.type_line || '') && !dbCurrentDeck.commander)) { + if (item.isCommander) { + dbCurrentDeck.commander = { scryfallId: card.id, name: card.name, imageUrl: imgUrl, typeLine: card.type_line || '', colorIdentity: card.color_identity || [], legality: (card.legalities && card.legalities.commander) || null, keywords: card.keywords || [], oracleText: card.oracle_text || '' }; + } else { + dbCurrentDeck.cards.push(cardEntry); + } + } else { + dbCurrentDeck.cards.push(cardEntry); + } + } else { + errors.push(item.name); + } + } catch(e) { + errors.push(item.name); + } + done++; + var pct = Math.round((done / total) * 100); + if (progressBar) progressBar.style.width = pct + '%'; + if (progressText) progressText.textContent = done + ' / ' + total; + // Rate limit + await new Promise(function(r) { setTimeout(r, 100); }); + } + + if (!dbImportCancelFlag) { + var msg = 'Import complete! ' + (dbCurrentDeck.cards.length + (dbCurrentDeck.commander ? 1 : 0)) + ' cards loaded.'; + if (errors.length) msg += ' (' + errors.length + ' not found: ' + errors.slice(0,3).join(', ') + (errors.length > 3 ? '...' : '') + ')'; + if (status) status.textContent = msg; + } + + if (importBtn) { importBtn.textContent = '📥 Import'; importBtn.onclick = importDeckBuilder; } + + var nameInput = document.getElementById('db-deck-name'); + if (nameInput && dbCurrentDeck.commander) nameInput.value = dbCurrentDeck.commander.name + ' Commander'; + dbCurrentDeck.name = (nameInput && nameInput.value) || 'Imported Deck'; + + renderDbCommanderSlot(); + renderDbList(); + renderDbStats(); + renderDbResultGrid(dbSearchResults); + checkDbLegality(); +} + +function generateDbExport() { + var lines = []; + if (dbCurrentDeck.commander) { + lines.push('// Commander'); + lines.push('1 ' + dbCurrentDeck.commander.name); + lines.push(''); + } + lines.push('// Deck'); + var sorted = dbCurrentDeck.cards.slice().sort(function(a, b) { return a.name.localeCompare(b.name); }); + sorted.forEach(function(c) { + lines.push(c.quantity + ' ' + c.name); + }); + var textarea = document.getElementById('db-export-textarea'); + if (textarea) textarea.value = lines.join('\n'); +} + +function exportDeckBuilderList() { + openDeckBuilderImport(); + setTimeout(function() { switchDbImportTab('export'); }, 50); +} + +function copyDbExport() { + var textarea = document.getElementById('db-export-textarea'); + if (!textarea) return; + textarea.select(); + try { + document.execCommand('copy'); + showToast('Deck list copied to clipboard!'); + } catch(e) { + showToast('Copy failed — please select and copy manually'); + } +} + +// ============================================================ +// TOAST NOTIFICATIONS +// ============================================================ +function showToast(message) { + var existing = document.getElementById('app-toast'); + if (existing) existing.remove(); + + var toast = document.createElement('div'); + toast.id = 'app-toast'; + toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(30,30,50,0.95);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 20px;color:#e0e0e0;font-size:14px;font-family:Inter,sans-serif;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);pointer-events:none;transition:opacity 0.3s;max-width:400px;text-align:center;'; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(function() { + toast.style.opacity = '0'; + setTimeout(function() { toast.remove(); }, 300); + }, 2500); +} + +// ============================================================ +// ESCAPE HTML HELPER +// (Define only if not already defined elsewhere) +// ============================================================ +if (typeof escapeHtml !== 'function') { + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +// ============================================================ +// INIT — call on DOMContentLoaded +// ============================================================ +function initDeckBuilderAndCollection() { + // Ensure play content is visible by default + var playContent = document.getElementById('mode-play-content'); + if (playContent) playContent.style.display = ''; + + // Ensure collection and deckbuilder are hidden + var collScreen = document.getElementById('collection-screen'); + var dbScreen = document.getElementById('deckbuilder-screen'); + if (collScreen) collScreen.classList.add('hidden'); + if (dbScreen) dbScreen.classList.add('hidden'); + + // Close ctx menu when clicking outside + document.addEventListener('click', function(e) { + var menu = document.getElementById('db-ctx-menu'); + if (menu && !menu.contains(e.target)) { + menu.classList.add('hidden'); + } + }); + + // Initialize import dropzone + initCollImportDropzone(); + + // Initialize legality panel collapse state + initLegalityPanelState(); +} + +// ============================================ +// HOW TO PLAY GUIDE MODAL +// ============================================ +function openGuideModal() { + var modal = document.getElementById('guide-modal'); + if (modal) { + modal.style.display = 'flex'; + // Scroll body to top of modal content + var body = modal.querySelector('.guide-modal-body'); + if (body) body.scrollTop = 0; + } +} + +function closeGuideModal() { + var modal = document.getElementById('guide-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +// ============================================ +// FLOATING GAME LOG (right-side panel) +// ============================================ +var _floatingLogMinimized = false; + +function toggleFloatingLog() { + _floatingLogMinimized = !_floatingLogMinimized; + var panel = document.getElementById('floating-game-log'); + var btn = document.getElementById('fgl-minimize-btn'); + if (panel) { + panel.classList.toggle('minimized', _floatingLogMinimized); + } + if (btn) { + btn.innerHTML = _floatingLogMinimized ? '➕' : '➖'; + btn.title = _floatingLogMinimized ? 'Expand' : 'Minimize'; + } +} + +function addFloatingLogEntry(text, category) { + var container = document.getElementById('fgl-entries'); + if (!container) return; + // Clear the placeholder + var empty = container.querySelector('.fgl-empty'); + if (empty) empty.remove(); + + var cat = category || 'general'; + var catInfo = (typeof LOG_CATEGORY_ICONS !== 'undefined' && LOG_CATEGORY_ICONS[cat]) ? LOG_CATEGORY_ICONS[cat] : { icon: '>', color: '#888' }; + var turnNum = (gameState && gameState.turnNumber) ? gameState.turnNumber : 1; + var now = new Date(); + var time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0'); + + var entry = document.createElement('div'); + entry.className = 'fgl-entry'; + entry.innerHTML = + '' + catInfo.icon + '' + + '[T' + turnNum + ' ' + time + '] ' + + '' + text + ''; + container.insertBefore(entry, container.firstChild); + + // Keep max 60 entries + while (container.children.length > 60) container.removeChild(container.lastChild); +} + +// ============================================================ +// RULES ENGINE EXPANSION — CLEANUP STEP +// ============================================================ + +/** + * Cleanup step: runs at end of each turn after end step. + * - Clears damage counters on all creatures + * - Removes 'until end of turn' effects (if tracked) + * - Checks state-based actions one more time + */ +function performCleanupStep() { + if (!gameState) return; + // Clear damage counters on all creatures + var clearedCount = 0; + gameState.battlefieldCards.forEach(function(card) { + if (card.damageThisTurn && card.damageThisTurn > 0) { + card.damageThisTurn = 0; + clearedCount++; + updateDamageBadge(card); + } + }); + if (clearedCount > 0) { + addLogEntry('\u2728 Cleanup: damage cleared from ' + clearedCount + ' creature' + (clearedCount > 1 ? 's' : '') + '.'); + } + + // Remove 'until end of turn' temporary effects + var removedEffects = 0; + gameState.battlefieldCards.forEach(function(card) { + if (card.tempEffects && card.tempEffects.length > 0) { + card.tempEffects = card.tempEffects.filter(function(e) { + if (e.until === 'end_of_turn') { + removedEffects++; + return false; + } + return true; + }); + } + }); + if (removedEffects > 0) { + addLogEntry('\u2728 Cleanup: ' + removedEffects + ' temporary effect' + (removedEffects > 1 ? 's' : '') + ' expired.'); + } + + // Run SBAs after cleanup + if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) { + checkStateBasedActions(); + } +} + +// ============================================================ +// RULES ENGINE EXPANSION — MONARCH MECHANIC +// ============================================================ + +/** + * Become the Monarch. The Monarch draws an extra card at end step. + * If the Monarch is dealt combat damage by a creature, that creature's controller + * becomes the new Monarch. + * @param {number} playerIndex + */ +function becomeMonarch(playerIndex) { + if (!gameState) return; + var oldMonarch = gameState.monarchPlayerIndex; + gameState.monarchPlayerIndex = playerIndex; + var player = gameState.players[playerIndex]; + if (!player) return; + addLogEntry('\ud83d\udc51 ' + player.name + ' becomes the Monarch!'); + if (oldMonarch !== undefined && oldMonarch !== null && oldMonarch !== playerIndex) { + var old = gameState.players[oldMonarch]; + if (old) { + addLogEntry('\ud83d\udc51 ' + old.name + ' is no longer the Monarch.'); + } + } + renderPlayerPanels(); + updateMonarchBadges(); +} + +/** + * Check if combat damage to the Monarch should transfer monarchy. + * Called after combat damage is dealt to a player. + */ +function checkMonarchCombatDamage(attackerCard, targetPlayerId) { + if (!gameState) return; + if (gameState.monarchPlayerIndex === undefined || gameState.monarchPlayerIndex === null) return; + if (targetPlayerId !== gameState.monarchPlayerIndex) return; + // The attacker's controller becomes the new Monarch + if (attackerCard && attackerCard.ownerId !== undefined) { + becomeMonarch(attackerCard.ownerId); + } +} + +/** + * Update visual monarch badges on player panels. + */ +function updateMonarchBadges() { + if (!gameState) return; + // Remove all existing monarch badges + document.querySelectorAll('.monarch-badge').forEach(function(el) { el.remove(); }); + // Add badge to current monarch's panel + if (gameState.monarchPlayerIndex !== undefined && gameState.monarchPlayerIndex !== null) { + var panel = document.getElementById('player-panel-' + gameState.monarchPlayerIndex); + if (panel) { + var badge = document.createElement('div'); + badge.className = 'monarch-badge'; + badge.title = 'The Monarch \u2014 draws an extra card at end step'; + badge.textContent = '\ud83d\udc51'; + panel.appendChild(badge); + } + } +} + +// ============================================================ +// RULES ENGINE EXPANSION — LETHAL DAMAGE SBA +// ============================================================ + +/** + * Enhanced SBA: check creatures with accumulated damage >= toughness. + * This catches damage from non-combat sources (e.g. burn spells). + * Called from checkStateBasedActions(). + */ +function checkLethalDamageSBA(alerts) { + if (!gameState) return; + gameState.battlefieldCards.forEach(function(card) { + if (!card.typeLine || !/creature/i.test(card.typeLine)) return; + if (!card.damageThisTurn || card.damageThisTurn <= 0) return; + if (hasKeyword(card, 'indestructible')) return; + + var toughness = getCreatureToughness(card); + // Also account for any -1/-1 counters reducing effective toughness + if (card.counters && card.counters['-1/-1']) { + toughness -= card.counters['-1/-1']; + } + if (card.damageThisTurn >= toughness) { + alerts.push({ + type: 'lethal_damage', + player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown', + playerIdx: card.ownerIndex, + cardId: card.id, + cardName: card.name, + msg: card.name + ' has ' + card.damageThisTurn + ' damage (toughness ' + toughness + ') \u2014 should be destroyed', + }); + } + }); +} + + +// ============================================================ +// VISUAL POLISH: KEYBOARD SHORTCUTS CHEAT SHEET +// ============================================================ + +function openShortcutsModal() { + var modal = document.getElementById('shortcuts-modal'); + if (!modal) return; + renderShortcutsContent(); + modal.classList.add('visible'); +} + +function closeShortcutsModal() { + var modal = document.getElementById('shortcuts-modal'); + if (modal) modal.classList.remove('visible'); +} + +function renderShortcutsContent() { + var body = document.getElementById('shortcuts-modal-body'); + if (!body) return; + body.innerHTML = '' + + '
' + + '
Game Flow
' + + '
Next PhaseSpace
' + + '
Next TurnN
' + + '
Pass Priority (when open)Space
' + + '
' + + '
' + + '
Actions
' + + '
Untap AllU
' + + '
Roll Dice / Flip CoinD
' + + '
Trigger StackT
' + + '
Toggle Mana PoolM
' + + '
' + + '
' + + '
Panels & Tools
' + + '
Save / Load GameS
' + + '
Keyword ReferenceK
' + + '
Keyboard Shortcuts?
' + + '
Undo HistoryH
' + + '
Close Any PanelEsc
' + + '
' + + '
' + + '
Edit
' + + '
UndoCtrl + Z
' + + '
RedoCtrl + Y
' + + '
' + + '
' + + '
Cards
' + + '
Tap / UntapDouble-click card
' + + '
Context MenuRight-click card
' + + '
Move CardClick + drag
' + + '
'; +} + +// ============================================================ +// VISUAL POLISH: UNDO HISTORY VIEWER +// ============================================================ + +// undoHistoryVisible declared in ui.js + +function toggleUndoHistory() { + undoHistoryVisible = !undoHistoryVisible; + var panel = document.getElementById('undo-history-panel'); + if (!panel) return; + if (undoHistoryVisible) { + panel.classList.add('visible'); + renderUndoHistory(); + } else { + panel.classList.remove('visible'); + } +} + +function closeUndoHistory() { + undoHistoryVisible = false; + var panel = document.getElementById('undo-history-panel'); + if (panel) panel.classList.remove('visible'); +} + +function renderUndoHistory() { + var list = document.getElementById('undo-history-list'); + var countEl = document.getElementById('undo-history-count'); + if (!list) return; + + if (typeof undoStack === 'undefined' || undoStack.length === 0) { + list.innerHTML = '
No actions to undo.
'; + if (countEl) countEl.textContent = '0 steps'; + return; + } + + if (countEl) countEl.textContent = undoStack.length + ' step' + (undoStack.length !== 1 ? 's' : ''); + + var html = ''; + // Show most recent first + for (var i = undoStack.length - 1; i >= 0; i--) { + var snap = undoStack[i]; + var desc = snap._description || 'Action'; + var num = undoStack.length - i; + html += '
' + + '' + num + '' + + '' + desc + '' + + 'Undo to here' + + '
'; + } + list.innerHTML = html; +} + +function undoToStep(targetIndex) { + if (typeof undoStack === 'undefined') return; + // Undo repeatedly until we reach the target + var steps = undoStack.length - 1 - targetIndex; + for (var s = 0; s < steps; s++) { + if (undoStack.length === 0) break; + var current = deepCloneState(); + if (current) redoStack.push(current); + undoStack.pop(); + } + // Now do the final undo + if (undoStack.length > 0) { + var current2 = deepCloneState(); + if (current2) redoStack.push(current2); + var prev = undoStack.pop(); + restoreFromSnapshot(prev); + addLogEntry('\u21A9 Undo (' + (steps + 1) + ' steps)'); + updateUndoRedoButtons(); + } + renderUndoHistory(); +} + +// ============================================================ +// VISUAL POLISH: ENHANCED COMBAT DAMAGE ANIMATIONS +// ============================================================ + +function triggerDamageFlash(elementId, type) { + var el = document.getElementById(elementId); + if (!el) return; + var cls = type === 'heal' ? 'heal-flash' : (type === 'death' ? 'death-flash' : 'damage-flash'); + el.classList.remove(cls); + // Force reflow to restart animation + void el.offsetWidth; + el.classList.add(cls); + setTimeout(function() { el.classList.remove(cls); }, 800); +} + +function triggerScreenShake() { + var gameBoard = document.getElementById('game-board'); + if (!gameBoard) return; + gameBoard.classList.remove('screen-shake'); + void gameBoard.offsetWidth; + gameBoard.classList.add('screen-shake'); + setTimeout(function() { gameBoard.classList.remove('screen-shake'); }, 500); +} + +// ============================================================ +// VISUAL POLISH: CARD ENTER ANIMATION +// ============================================================ + +function animateCardEnter(cardId) { + var el = document.getElementById('bc-' + cardId); + if (!el) return; + el.classList.add('card-entering'); + setTimeout(function() { el.classList.remove('card-entering'); }, 500); +} + +// ============================================================ +// VISUAL POLISH: CARD EXIT ANIMATION +// ============================================================ + +function animateCardExit(cardEl, zone) { + if (!cardEl) return; + var cls = 'card-exit-graveyard'; + if (zone === 'exile') cls = 'card-exit-exile'; + else if (zone === 'hand' || zone === 'library') cls = 'card-exit-hand'; + cardEl.classList.add(cls); + return 350; // duration ms +} + +// ============================================================ +// VISUAL POLISH: BATTLEFIELD ZONES LAYOUT +// ============================================================ + +var bfZonesVisible = false; + +function toggleBattlefieldZones() { + bfZonesVisible = !bfZonesVisible; + var btn = document.getElementById('bf-zones-toggle'); + if (btn) btn.classList.toggle('active', bfZonesVisible); + renderBattlefieldZoneLabels(); +} + +function renderBattlefieldZoneLabels() { + // Add or remove zone labels from each section area + if (!gameState) return; + for (var pi = 0; pi < gameState.players.length; pi++) { + var area = document.getElementById('bf-section-area-' + pi); + if (!area) continue; + + // Remove existing zone labels + var existing = area.querySelector('.bf-zone-labels'); + if (existing) existing.remove(); + + if (!bfZonesVisible) continue; + + var labelsDiv = document.createElement('div'); + labelsDiv.className = 'bf-zone-labels'; + labelsDiv.innerHTML = + 'Creatures' + + 'Artifacts / Enchantments' + + 'Lands' + + 'Instants / Sorceries' + + '
' + + '
' + + '
'; + area.insertBefore(labelsDiv, area.firstChild); + } +} + +// ============================================================ +// HAND CARD HOVER PREVIEW +// ============================================================ + +function setupHandCardPreview() { + // Delegate mouseover/mouseout on hand-cards container + var handCardsEl = document.getElementById('hand-cards'); + if (!handCardsEl) return; + + handCardsEl.addEventListener('mouseover', function(e) { + var cardEl = e.target.closest('.hand-card'); + if (!cardEl || !gameState) return; + var playerId = 0; // Player 1 hand + var idx = Array.from(handCardsEl.querySelectorAll('.hand-card')).indexOf(cardEl); + if (idx < 0) return; + var player = gameState.players[playerId]; + if (!player) return; + var card = player.zones.hand[idx]; + if (!card) return; + var img = card.imageUrl || ''; + if (img) { + showFloatingCardPreview(img, e, { + name: card.name, + typeLine: card.typeLine, + oracleText: card.oracleText, + manaCost: card.manaCost || '', + power: card.power, + toughness: card.toughness, + pt: card.pt, + loyalty: card.loyalty + }); + var preview = document.getElementById('card-preview'); + if (preview) preview.classList.add('hand-preview-mode'); + } + }); + + handCardsEl.addEventListener('mouseout', function(e) { + var cardEl = e.target.closest('.hand-card'); + if (cardEl) { + hideFloatingCardPreview(); + var preview = document.getElementById('card-preview'); + if (preview) preview.classList.remove('hand-preview-mode'); + } + }); + + handCardsEl.addEventListener('mousemove', function(e) { + var cardEl = e.target.closest('.hand-card'); + if (cardEl) positionFloatingPreview(e); + }); +} + + +// ============================================================ +// AI SELF-PLAY TRAINING ENGINE (Heuristic Hill-Climbing) +// ============================================================ + +var aiTrainingState = { + running: false, + generation: 0, + totalGens: 0, + bestScore: -Infinity, + bestWeights: null, + currentPhase: 'combat', // 'combat', 'stack', 'politics' + log: [], + gamesPerEval: 6, + improvementsFound: 0, + startTime: 0, + cancelled: false, +}; + +// --- Headless (DOM-free) game simulation for fast self-play --- + +function headlessCreatePlayer(name, deckCards, idx) { + var hand = []; + var library = []; + deckCards.forEach(function(c, i) { + library.push({ + id: 90000 + idx * 1000 + i, + name: c.name || 'Card ' + i, + typeLine: c.typeLine || '', + oracleText: c.oracleText || '', + manaCost: c.manaCost || '', + cmc: c.cmc || 0, + pt: c.pt || '', + isRamp: c.isRamp || false, + isRemoval: c.isRemoval || false, + isBoardWipe: c.isBoardWipe || false, + isCommander: false, + }); + }); + // Shuffle + for (var i = library.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = library[i]; library[i] = library[j]; library[j] = tmp; + } + // Draw 7 + for (var d = 0; d < 7 && library.length > 0; d++) { + hand.push(library.pop()); + } + return { + name: name, + life: 40, + isAI: true, + eliminated: false, + color: idx === 0 ? '#e8a04a' : idx === 1 ? '#6ba3e8' : idx === 2 ? '#6be86b' : '#e86b6b', + zones: { hand: hand, library: library, graveyard: [], exile: [], command: [] }, + manaPool: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }, + commanderDamageDealt: {}, + commanderCastCount: 0, + }; +} + +function headlessSimGame(weightsA, weightsB, deckA, deckB) { + // Minimal game state for evaluation + var sim = { + players: [ + headlessCreatePlayer('Player A', deckA, 0), + headlessCreatePlayer('Player B', deckB, 1), + ], + battlefieldCards: [], + turn: 0, + maxTurns: 20, + currentPlayerIndex: 0, + combat: { attackers: [], blockers: [] }, + nextCardId: 80000, + }; + + var allWeights = [weightsA, weightsB]; + + for (var turn = 0; turn < sim.maxTurns; turn++) { + for (var pi = 0; pi < 2; pi++) { + sim.currentPlayerIndex = pi; + var p = sim.players[pi]; + if (p.eliminated) continue; + var w = allWeights[pi]; + + // Draw a card + if (p.zones.library.length > 0) { + p.zones.hand.push(p.zones.library.pop()); + } + + // Simulate land drop (find land in hand, add to battlefield as mana source) + var landIdx = -1; + for (var li = 0; li < p.zones.hand.length; li++) { + if (p.zones.hand[li].typeLine && p.zones.hand[li].typeLine.toLowerCase().indexOf('land') !== -1) { + landIdx = li; + break; + } + } + if (landIdx !== -1) { + var landCard = p.zones.hand.splice(landIdx, 1)[0]; + landCard.ownerId = pi; + landCard.tapped = false; + landCard.id = sim.nextCardId++; + sim.battlefieldCards.push(landCard); + } + + // Count available mana (untapped lands) + var availableMana = 0; + sim.battlefieldCards.forEach(function(c) { + if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) { + availableMana++; + } + }); + + // Score and play best affordable non-land card + var playable = []; + for (var hi = 0; hi < p.zones.hand.length; hi++) { + var card = p.zones.hand[hi]; + if (card.typeLine && card.typeLine.toLowerCase().indexOf('land') !== -1) continue; + var cmc = card.cmc || 0; + if (cmc <= availableMana) { + playable.push({ card: card, idx: hi, score: headlessScoreCard(card, w) }); + } + } + playable.sort(function(a, b) { return b.score - a.score; }); + + // Play up to 2 cards per turn + var played = 0; + while (playable.length > 0 && played < 2) { + var best = playable.shift(); + var handPos = p.zones.hand.indexOf(best.card); + if (handPos === -1) continue; + var playedCard = p.zones.hand.splice(handPos, 1)[0]; + playedCard.ownerId = pi; + playedCard.tapped = false; + playedCard.id = sim.nextCardId++; + playedCard.turnPlayed = turn; + + // Tap lands to pay + var manaNeeded = playedCard.cmc || 0; + sim.battlefieldCards.forEach(function(c) { + if (manaNeeded <= 0) return; + if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) { + c.tapped = true; + manaNeeded--; + } + }); + + // Handle removal: destroy strongest opponent creature + if (playedCard.isRemoval) { + var oppCreatures = sim.battlefieldCards.filter(function(c) { + return c.ownerId !== pi && c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1; + }); + if (oppCreatures.length > 0) { + oppCreatures.sort(function(a, b) { + return headlessScoreCard(b, w) - headlessScoreCard(a, w); + }); + var killed = oppCreatures[0]; + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== killed.id; }); + sim.players[killed.ownerId].zones.graveyard.push(killed); + } + p.zones.graveyard.push(playedCard); + } else if (playedCard.isBoardWipe) { + var surviving = []; + sim.battlefieldCards.forEach(function(c) { + if (c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1) { + sim.players[c.ownerId].zones.graveyard.push(c); + } else { + surviving.push(c); + } + }); + sim.battlefieldCards = surviving; + p.zones.graveyard.push(playedCard); + } else { + sim.battlefieldCards.push(playedCard); + } + played++; + // Recalculate available mana + availableMana = 0; + sim.battlefieldCards.forEach(function(c) { + if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) { + availableMana++; + } + }); + } + + // --- COMBAT PHASE --- + var myCreatures = sim.battlefieldCards.filter(function(c) { + return c.ownerId === pi && + c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1 && + !c.tapped && + (turn > 0 || c.turnPlayed !== turn); // Pseudo summoning sickness + }); + + var oppIdx = 1 - pi; + var opp = sim.players[oppIdx]; + if (opp.eliminated || myCreatures.length === 0) { + // Untap + sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; }); + continue; + } + + var oppBlockers = sim.battlefieldCards.filter(function(c) { + return c.ownerId === oppIdx && + c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1 && + !c.tapped; + }); + + // Decide which creatures to attack with + var attackers = []; + myCreatures.forEach(function(atk) { + var aPow = headlessGetPower(atk); + var aTou = headlessGetToughness(atk); + var hasFlying = headlessHasKw(atk, 'flying'); + var hasTrample = headlessHasKw(atk, 'trample'); + + // Check if any blocker can kill us + var canDie = oppBlockers.some(function(b) { + if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false; + return headlessGetPower(b) >= aTou || headlessHasKw(b, 'deathtouch'); + }); + + if (!canDie || hasFlying || hasTrample || aPow >= 4) { + attackers.push(atk); + atk.tapped = true; + } + }); + + // Simple blocking: opponent assigns biggest blocker to biggest attacker + var totalDamage = 0; + var usedBlockers = {}; + attackers.forEach(function(atk) { + var aPow = headlessGetPower(atk); + var aTou = headlessGetToughness(atk); + var hasFlying = headlessHasKw(atk, 'flying'); + + var validBlockers = oppBlockers.filter(function(b) { + if (usedBlockers[b.id]) return false; + if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false; + return true; + }); + + if (validBlockers.length > 0 && !headlessHasKw(atk, 'menace')) { + // Block with creature that can survive, or chump if life is low + var blocker = validBlockers.find(function(b) { return headlessGetToughness(b) > aPow; }); + if (!blocker && opp.life <= aPow * 2) blocker = validBlockers[0]; // chump + if (blocker) { + usedBlockers[blocker.id] = true; + // Assign damage + var bPow = headlessGetPower(blocker); + if (aPow >= headlessGetToughness(blocker) || headlessHasKw(atk, 'deathtouch')) { + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== blocker.id; }); + sim.players[oppIdx].zones.graveyard.push(blocker); + } + if (bPow >= aTou || headlessHasKw(blocker, 'deathtouch')) { + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== atk.id; }); + sim.players[pi].zones.graveyard.push(atk); + } + if (headlessHasKw(atk, 'trample') && aPow > headlessGetToughness(blocker)) { + totalDamage += aPow - headlessGetToughness(blocker); + } + return; + } + } + // Unblocked + totalDamage += aPow; + if (headlessHasKw(atk, 'lifelink')) p.life += aPow; + }); + + opp.life -= totalDamage; + if (opp.life <= 0) opp.eliminated = true; + + // Untap all our stuff for next turn + sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; }); + } + + // Check if game is over + var alive = sim.players.filter(function(p) { return !p.eliminated; }); + if (alive.length <= 1) break; + } + + // Evaluate result + var pA = sim.players[0]; + var pB = sim.players[1]; + + // Score: higher is better for player A + var scoreA = 0; + if (pA.eliminated && !pB.eliminated) scoreA = -100; + else if (!pA.eliminated && pB.eliminated) scoreA = 100; + else { + // Both alive: score by life difference and board state + scoreA = (pA.life - pB.life); + var aBoard = sim.battlefieldCards.filter(function(c) { return c.ownerId === 0; }).length; + var bBoard = sim.battlefieldCards.filter(function(c) { return c.ownerId === 1; }).length; + scoreA += (aBoard - bBoard) * 2; + } + + return scoreA; +} + +function headlessScoreCard(card, w) { + var cmc = card.cmc || 0; + var score = 10 - cmc * (w.card_baseCmcPenalty || 1); + if (card.pt) { + var pts = card.pt.split('/'); + score += ((parseInt(pts[0]) || 0) + (parseInt(pts[1]) || 0)) * (w.card_ptBonus || 1); + } + var text = (card.oracleText || '').toLowerCase(); + if (text.indexOf('flying') !== -1) score += (w.kw_flying || 2.5); + if (text.indexOf('deathtouch') !== -1) score += (w.kw_deathtouch || 2.5); + if (text.indexOf('haste') !== -1) score += (w.kw_haste || 2.5); + if (text.indexOf('trample') !== -1) score += (w.kw_trample || 1.5); + if (text.indexOf('lifelink') !== -1) score += (w.kw_lifelink || 1.5); + if (card.isRamp) score += (w.spell_ramp || 6); + if (card.isRemoval) score += (w.spell_destroy || 5); + return score; +} + +function headlessGetPower(card) { + if (!card.pt) return 0; + return parseInt(card.pt.split('/')[0]) || 0; +} +function headlessGetToughness(card) { + if (!card.pt) return 0; + return parseInt(card.pt.split('/')[1]) || 0; +} +function headlessHasKw(card, kw) { + var text = (card.oracleText || '').toLowerCase(); + return text.indexOf(kw) !== -1; +} + +// --- Generate a synthetic deck for training --- +function generateTrainingDeck() { + var deck = []; + // 37 lands + for (var i = 0; i < 37; i++) { + deck.push({ name: 'Forest', typeLine: 'Basic Land - Forest', oracleText: '{T}: Add {G}', cmc: 0, pt: '' }); + } + // 20 creatures of varying sizes + var creatures = [ + { name: 'Llanowar Elves', typeLine: 'Creature - Elf Druid', oracleText: '{T}: Add {G}', cmc: 1, pt: '1/1', isRamp: true }, + { name: 'Elvish Mystic', typeLine: 'Creature - Elf Druid', oracleText: '{T}: Add {G}', cmc: 1, pt: '1/1', isRamp: true }, + { name: 'Grizzly Bears', typeLine: 'Creature - Bear', oracleText: '', cmc: 2, pt: '2/2' }, + { name: 'Runeclaw Bear', typeLine: 'Creature - Bear', oracleText: '', cmc: 2, pt: '2/2' }, + { name: 'Centaur Courser', typeLine: 'Creature - Centaur', oracleText: '', cmc: 3, pt: '3/3' }, + { name: 'Alpine Grizzly', typeLine: 'Creature - Bear', oracleText: '', cmc: 3, pt: '4/2' }, + { name: 'Garruk\'s Companion', typeLine: 'Creature - Beast', oracleText: 'Trample', cmc: 2, pt: '3/2' }, + { name: 'Leatherback Baloth', typeLine: 'Creature - Beast', oracleText: '', cmc: 3, pt: '4/5' }, + { name: 'Territorial Scythecat', typeLine: 'Creature - Cat', oracleText: 'Trample', cmc: 3, pt: '2/2' }, + { name: 'Charging Badger', typeLine: 'Creature - Badger', oracleText: 'Trample', cmc: 1, pt: '1/1' }, + { name: 'Sentinel Spider', typeLine: 'Creature - Spider', oracleText: 'Vigilance, reach', cmc: 5, pt: '4/4' }, + { name: 'Vorstclaw', typeLine: 'Creature - Elemental', oracleText: '', cmc: 6, pt: '7/7' }, + { name: 'Pelakka Wurm', typeLine: 'Creature - Wurm', oracleText: 'Trample. When this enters the battlefield, you gain 7 life.', cmc: 7, pt: '7/7' }, + { name: 'Typhoid Rats', typeLine: 'Creature - Rat', oracleText: 'Deathtouch', cmc: 1, pt: '1/1' }, + { name: 'Vampire Nighthawk', typeLine: 'Creature - Vampire', oracleText: 'Flying, deathtouch, lifelink', cmc: 3, pt: '2/3' }, + { name: 'Serra Angel', typeLine: 'Creature - Angel', oracleText: 'Flying, vigilance', cmc: 5, pt: '4/4' }, + { name: 'Air Elemental', typeLine: 'Creature - Elemental', oracleText: 'Flying', cmc: 5, pt: '4/4' }, + { name: 'Giant Spider', typeLine: 'Creature - Spider', oracleText: 'Reach', cmc: 4, pt: '2/4' }, + { name: 'Kalonian Tusker', typeLine: 'Creature - Beast', oracleText: '', cmc: 2, pt: '3/3' }, + { name: 'Baloth Woodcrasher', typeLine: 'Creature - Beast', oracleText: 'Trample', cmc: 6, pt: '4/4' }, + ]; + creatures.forEach(function(c) { deck.push(c); }); + + // 6 removal spells + for (var r = 0; r < 3; r++) { + deck.push({ name: 'Murder', typeLine: 'Instant', oracleText: 'Destroy target creature.', cmc: 3, pt: '', isRemoval: true }); + deck.push({ name: 'Beast Within', typeLine: 'Instant', oracleText: 'Destroy target permanent.', cmc: 3, pt: '', isRemoval: true }); + } + + // 2 board wipes + deck.push({ name: 'Wrath of God', typeLine: 'Sorcery', oracleText: 'Destroy all creatures.', cmc: 4, pt: '', isBoardWipe: true }); + deck.push({ name: 'Damnation', typeLine: 'Sorcery', oracleText: 'Destroy all creatures.', cmc: 4, pt: '', isBoardWipe: true }); + + // 3 draw spells + deck.push({ name: 'Harmonize', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, pt: '' }); + deck.push({ name: 'Divination', typeLine: 'Sorcery', oracleText: 'Draw two cards.', cmc: 3, pt: '' }); + deck.push({ name: 'Concentrate', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, pt: '' }); + + // 2 ramp + deck.push({ name: 'Rampant Growth', typeLine: 'Sorcery', oracleText: 'Search your library for a basic land card and put it onto the battlefield tapped.', cmc: 2, pt: '', isRamp: true }); + deck.push({ name: 'Cultivate', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards.', cmc: 3, pt: '', isRamp: true }); + + return deck; +} + +// --- Mutate weights: small random perturbation --- +function mutateWeights(weights, mutationRate) { + var mutated = JSON.parse(JSON.stringify(weights)); + var keys = Object.keys(mutated); + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + if (typeof mutated[k] === 'boolean') continue; // Skip booleans + if (Math.random() < mutationRate) { + // Perturb by up to +/- 30% + var delta = (Math.random() - 0.5) * 0.6 * Math.abs(mutated[k] || 1); + mutated[k] = Math.round((mutated[k] + delta) * 100) / 100; + } + } + return mutated; +} + +// --- Run one generation of self-play training --- +function runTrainingGeneration(callback) { + var st = aiTrainingState; + if (st.cancelled) { + st.running = false; + renderTrainingUI(); + return; + } + + st.generation++; + var deckA = generateTrainingDeck(); + var deckB = generateTrainingDeck(); + var candidateWeights = mutateWeights(st.bestWeights || aiWeights, 0.3); + + // Play multiple games and average the score + var totalScore = 0; + for (var g = 0; g < st.gamesPerEval; g++) { + // Alternate who goes first + if (g % 2 === 0) { + totalScore += headlessSimGame(candidateWeights, st.bestWeights || aiWeights, deckA, deckB); + } else { + totalScore -= headlessSimGame(st.bestWeights || aiWeights, candidateWeights, deckA, deckB); + } + } + + var avgScore = totalScore / st.gamesPerEval; + + // Hill climbing: accept if better + if (avgScore > st.bestScore) { + st.bestScore = avgScore; + st.bestWeights = candidateWeights; + st.improvementsFound++; + st.log.push('Gen ' + st.generation + ': IMPROVED (score: ' + avgScore.toFixed(1) + ')'); + } else { + st.log.push('Gen ' + st.generation + ': no improvement (score: ' + avgScore.toFixed(1) + ')'); + } + + // Keep log manageable + if (st.log.length > 100) st.log = st.log.slice(-50); + + renderTrainingUI(); + + if (st.generation < st.totalGens && !st.cancelled) { + // Use setTimeout to avoid blocking the UI + setTimeout(function() { runTrainingGeneration(callback); }, 1); + } else { + st.running = false; + if (callback) callback(); + renderTrainingUI(); + } +} + +function startAiTraining(numGenerations, phase) { + var st = aiTrainingState; + if (st.running) return; + + st.running = true; + st.cancelled = false; + st.generation = 0; + st.totalGens = numGenerations || 50; + st.bestScore = -Infinity; + st.bestWeights = JSON.parse(JSON.stringify(aiWeights)); + st.currentPhase = phase || 'combat'; + st.improvementsFound = 0; + st.startTime = Date.now(); + st.log = ['Training started: Phase ' + st.currentPhase + ', ' + st.totalGens + ' generations']; + + renderTrainingUI(); + + setTimeout(function() { + runTrainingGeneration(function() { + st.log.push('Training complete! ' + st.improvementsFound + ' improvements found in ' + + ((Date.now() - st.startTime) / 1000).toFixed(1) + 's'); + renderTrainingUI(); + }); + }, 10); +} + +function stopAiTraining() { + aiTrainingState.cancelled = true; + aiTrainingState.log.push('Training stopped by user at gen ' + aiTrainingState.generation); +} + +function applyTrainedWeights() { + if (!aiTrainingState.bestWeights) return; + aiWeights = JSON.parse(JSON.stringify(aiTrainingState.bestWeights)); + saveAiWeights(); + aiTrainingState.log.push('Trained weights applied and saved!'); + renderTrainingUI(); +} + +// ============================================================ +// TRAINING UI — Renders inside the AI Settings tab +// ============================================================ + +function renderTrainingUI() { + var container = document.getElementById('ai-training-panel') || document.getElementById('ai-training-modal-panel'); + if (!container) return; + // Also update the modal panel if it exists + var modalPanel = document.getElementById('ai-training-modal-panel'); + var settingsPanel = document.getElementById('ai-training-panel'); + + var st = aiTrainingState; + var html = ''; + + // Progress bar + if (st.running) { + var pct = st.totalGens > 0 ? Math.round((st.generation / st.totalGens) * 100) : 0; + html += '
'; + html += '
'; + html += 'Generation ' + st.generation + ' / ' + st.totalGens + ''; + html += '' + st.improvementsFound + ' improvements'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + } + + // Controls + html += '
'; + if (!st.running) { + html += ''; + html += ''; + html += ''; + } else { + html += ''; + } + html += '
'; + + // Apply / Reset + if (st.bestWeights && !st.running) { + html += '
'; + html += ''; + html += ''; + html += '
'; + } + + // Weight diff summary + if (st.bestWeights) { + var diffCount = 0; + var diffs = []; + var keys = Object.keys(AI_DEFAULT_WEIGHTS); + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + if (typeof AI_DEFAULT_WEIGHTS[k] === 'boolean') continue; + var def = AI_DEFAULT_WEIGHTS[k]; + var cur = st.bestWeights[k]; + if (cur !== undefined && Math.abs(cur - def) > 0.01) { + diffCount++; + var pctChange = def !== 0 ? Math.round(((cur - def) / Math.abs(def)) * 100) : 0; + var arrow = pctChange > 0 ? '+' : ''; + diffs.push(k.replace(/_/g, ' ') + ': ' + def + ' -> ' + cur + ' (' + arrow + pctChange + '%)'); + } + } + if (diffCount > 0) { + html += '
' + diffCount + ' weights changed:
'; + html += '
'; + html += diffs.slice(0, 20).join('
'); + if (diffs.length > 20) html += '
...and ' + (diffs.length - 20) + ' more'; + html += '
'; + } + } + + // Log + if (st.log.length > 0) { + html += '
Training Log:
'; + html += '
'; + var recentLogs = st.log.slice(-20); + for (var li = 0; li < recentLogs.length; li++) { + var logLine = recentLogs[li]; + if (logLine.indexOf('IMPROVED') !== -1) { + html += '
' + logLine + '
'; + } else { + html += '
' + logLine + '
'; + } + } + html += '
'; + } + + // Current weight status + var isCustom = false; + var ks = Object.keys(AI_DEFAULT_WEIGHTS); + for (var ci = 0; ci < ks.length; ci++) { + if (typeof AI_DEFAULT_WEIGHTS[ks[ci]] === 'boolean') continue; + if (Math.abs(aiWeights[ks[ci]] - AI_DEFAULT_WEIGHTS[ks[ci]]) > 0.01) { isCustom = true; break; } + } + html += '
'; + html += 'Active weights: ' + (isCustom ? 'Custom (trained)' : 'Default'); + html += '
'; + + container.innerHTML = html; + // Write to both panels if both exist + if (modalPanel && modalPanel !== container) modalPanel.innerHTML = html; + if (settingsPanel && settingsPanel !== container) settingsPanel.innerHTML = html; +} + +// --- Modal open/close for setup screen access --- +function openAiTrainingModal() { + var modal = document.getElementById('ai-training-modal'); + if (modal) { + modal.style.display = 'flex'; + renderTrainingUI(); + } +} + +function closeAiTrainingModal() { + var modal = document.getElementById('ai-training-modal'); + if (modal) modal.style.display = 'none'; +} + +// ============================================================ +// DECK TESTER — Monte Carlo simulation against precon decks +// ============================================================ + +var deckTesterState = { + running: false, + cancelled: false, + results: null, + progress: 0, + total: 0, + currentMatchup: '', + // Configuration + gamesPerMatchup: 50, + maxTurns: 25, + selectedPrecons: [], // indexes into PRECON_DATA + customDeck: null, // { name, commander, cards[] } +}; + +// --- Parse a precon decklist string into card objects --- +function parseDecklistToCards(decklistStr) { + if (!decklistStr) return []; + var cards = []; + var lines = decklistStr.split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line || line.indexOf('//') === 0) continue; + var match = line.match(/^(\d+)\s+(.+)$/); + if (match) { + var qty = parseInt(match[1]) || 1; + var name = match[2].trim(); + for (var q = 0; q < qty; q++) { + cards.push({ name: name, typeLine: '', oracleText: '', cmc: 0, pt: '', manaCost: '' }); + } + } + } + return cards; +} + +// --- Enrich cards with type/cost guesses for headless sim --- +function enrichCardForSim(card) { + var name = (card.name || '').toLowerCase(); + var typeLine = (card.typeLine || '').toLowerCase(); + var oracleText = (card.oracleText || '').toLowerCase(); + + // Already has data from Scryfall + if (card.cmc > 0 || typeLine.length > 5) return card; + + // Guess from name patterns for precon cards without data + var c = JSON.parse(JSON.stringify(card)); + + // Basic lands + if (/^(plains|island|swamp|mountain|forest)$/i.test(c.name)) { + var landColors = { plains: 'W', island: 'U', swamp: 'B', mountain: 'R', forest: 'G' }; + var cl = landColors[c.name.toLowerCase()] || 'C'; + c.typeLine = 'Basic Land - ' + c.name; + c.oracleText = '{T}: Add {' + cl + '}'; + c.cmc = 0; + return c; + } + + // Common artifacts + if (/sol ring/i.test(c.name)) { + c.typeLine = 'Artifact'; c.cmc = 1; c.oracleText = '{T}: Add {C}{C}'; c.isRamp = true; return c; + } + if (/arcane signet|fellwar stone|talisman|signet/i.test(c.name)) { + c.typeLine = 'Artifact'; c.cmc = 2; c.oracleText = '{T}: Add one mana'; c.isRamp = true; return c; + } + if (/commander.?s sphere/i.test(c.name)) { + c.typeLine = 'Artifact'; c.cmc = 3; c.oracleText = '{T}: Add one mana'; c.isRamp = true; return c; + } + + // Common instants / removal + if (/swords to plowshares|path to exile/i.test(c.name)) { + c.typeLine = 'Instant'; c.cmc = 1; c.oracleText = 'Exile target creature.'; c.isRemoval = true; return c; + } + if (/beast within|chaos warp|generous gift/i.test(c.name)) { + c.typeLine = 'Instant'; c.cmc = 3; c.oracleText = 'Destroy target permanent.'; c.isRemoval = true; return c; + } + if (/murder|go for the throat|hero.?s downfall/i.test(c.name)) { + c.typeLine = 'Instant'; c.cmc = 3; c.oracleText = 'Destroy target creature.'; c.isRemoval = true; return c; + } + if (/counterspell|negate|arcane denial/i.test(c.name)) { + c.typeLine = 'Instant'; c.cmc = 2; c.oracleText = 'Counter target spell.'; c.isRemoval = true; return c; + } + + // Common board wipes + if (/wrath of god|damnation|blasphemous act|day of judgment|farewell|toxic deluge/i.test(c.name)) { + c.typeLine = 'Sorcery'; c.cmc = 4; c.oracleText = 'Destroy all creatures.'; c.isBoardWipe = true; return c; + } + + // Common ramp + if (/rampant growth|nature.?s lore/i.test(c.name)) { + c.typeLine = 'Sorcery'; c.cmc = 2; c.oracleText = 'Search your library for a basic land.'; c.isRamp = true; return c; + } + if (/cultivate|kodama.?s reach/i.test(c.name)) { + c.typeLine = 'Sorcery'; c.cmc = 3; c.oracleText = 'Search your library for two basic lands.'; c.isRamp = true; return c; + } + if (/farseek|three visits/i.test(c.name)) { + c.typeLine = 'Sorcery'; c.cmc = 2; c.oracleText = 'Search your library for a land.'; c.isRamp = true; return c; + } + + // Common draw + if (/harmonize|concentrate/i.test(c.name)) { + c.typeLine = 'Sorcery'; c.cmc = 4; c.oracleText = 'Draw three cards.'; return c; + } + + // Default: treat as a creature with CMC based on position in list + if (!c.typeLine || c.typeLine.length < 3) { + // Estimate CMC from how "big" the card name sounds + var estimatedCmc = Math.floor(Math.random() * 5) + 2; + var estimatedPow = Math.max(1, estimatedCmc - 1); + c.typeLine = 'Creature'; + c.cmc = estimatedCmc; + c.pt = estimatedPow + '/' + estimatedPow; + c.power = String(estimatedPow); + c.toughness = String(estimatedPow); + } + + return c; +} + +// --- Build a simulation-ready deck from dbCurrentDeck --- +function buildSimDeckFromDeckBuilder() { + var deck = []; + var db = dbCurrentDeck; + if (!db || !db.cards || db.cards.length === 0) return null; + + // Add commander first (as a card in deck for sim purposes) + if (db.commander) { + var cmd = enrichCardForSim({ + name: db.commander.name || db.commander, + typeLine: db.commander.typeLine || 'Legendary Creature', + oracleText: db.commander.oracleText || '', + cmc: db.commander.cmc || 5, + pt: db.commander.pt || '4/4', + manaCost: db.commander.manaCost || '', + isCommander: true + }); + deck.push(cmd); + } + + db.cards.forEach(function(c) { + var qty = c.quantity || 1; + for (var q = 0; q < qty; q++) { + deck.push(enrichCardForSim({ + name: c.name, + typeLine: c.typeLine || '', + oracleText: c.oracleText || '', + cmc: c.cmc || 0, + pt: c.pt || '', + manaCost: c.manaCost || '', + power: c.power || '', + toughness: c.toughness || '', + colorIdentity: c.colorIdentity, + isRamp: c.isRamp || false, + isRemoval: c.isRemoval || false, + isBoardWipe: c.isBoardWipe || false + })); + } + }); + + return deck; +} + +// --- Build a simulation-ready deck from a precon --- +function buildSimDeckFromPrecon(preconIdx) { + var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA[preconIdx] : null; + if (!precon) return null; + var rawCards = parseDecklistToCards(precon.decklist || ''); + var deck = rawCards.map(function(c) { return enrichCardForSim(c); }); + return deck; +} + +// --- Headless create player (reuse from training engine) --- +function dtCreatePlayer(name, deck, idx) { + var shuffled = deck.slice(); + for (var i = shuffled.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var t = shuffled[i]; shuffled[i] = shuffled[j]; shuffled[j] = t; + } + var hand = shuffled.splice(0, 7); + return { + name: name, + life: 40, + eliminated: false, + ownerId: idx, + zones: { library: shuffled, hand: hand, graveyard: [], exile: [] }, + stats: { + cardsDrawn: 7, // starting hand + landsPlayed: 0, + spellsCast: 0, + creaturesPlayed: 0, + removalUsed: 0, + boardWipesUsed: 0, + rampPlayed: 0, + damageDealt: 0, + damageReceived: 0, + maxBoardSize: 0, + turnsAlive: 0, + manaSpent: 0, + } + }; +} + +// --- Enhanced headless sim for deck testing (richer stats tracking) --- +function dtSimGame(deckA, deckB, nameA, nameB) { + var w = (typeof aiWeights !== 'undefined') ? aiWeights : AI_DEFAULT_WEIGHTS; + + var sim = { + players: [ + dtCreatePlayer(nameA, deckA, 0), + dtCreatePlayer(nameB, deckB, 1), + ], + battlefieldCards: [], + turn: 0, + maxTurns: deckTesterState.maxTurns || 25, + nextCardId: 90000, + }; + + var turnLog = []; + + for (var turn = 0; turn < sim.maxTurns; turn++) { + for (var pi = 0; pi < 2; pi++) { + var p = sim.players[pi]; + if (p.eliminated) continue; + p.stats.turnsAlive++; + + // Draw + if (p.zones.library.length > 0) { + p.zones.hand.push(p.zones.library.pop()); + p.stats.cardsDrawn++; + } + + // Land drop + var landIdx = -1; + for (var li = 0; li < p.zones.hand.length; li++) { + if (p.zones.hand[li].typeLine && /land/i.test(p.zones.hand[li].typeLine)) { + landIdx = li; break; + } + } + if (landIdx !== -1) { + var landCard = p.zones.hand.splice(landIdx, 1)[0]; + landCard.ownerId = pi; landCard.tapped = false; landCard.id = sim.nextCardId++; + sim.battlefieldCards.push(landCard); + p.stats.landsPlayed++; + } + + // Available mana + var availableMana = 0; + sim.battlefieldCards.forEach(function(c) { + if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) availableMana++; + }); + + // Play spells + var playable = []; + for (var hi = 0; hi < p.zones.hand.length; hi++) { + var card = p.zones.hand[hi]; + if (card.typeLine && /land/i.test(card.typeLine)) continue; + var cmc = card.cmc || 0; + if (cmc <= availableMana) { + playable.push({ card: card, idx: hi, score: headlessScoreCard(card, w) }); + } + } + playable.sort(function(a, b) { return b.score - a.score; }); + + var played = 0; + while (playable.length > 0 && played < 2) { + var best = playable.shift(); + var handPos = p.zones.hand.indexOf(best.card); + if (handPos === -1) continue; + var playedCard = p.zones.hand.splice(handPos, 1)[0]; + playedCard.ownerId = pi; playedCard.tapped = false; + playedCard.id = sim.nextCardId++; playedCard.turnPlayed = turn; + + // Pay mana + var manaNeeded = playedCard.cmc || 0; + sim.battlefieldCards.forEach(function(c) { + if (manaNeeded <= 0) return; + if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) { + c.tapped = true; manaNeeded--; + } + }); + p.stats.manaSpent += (playedCard.cmc || 0); + p.stats.spellsCast++; + + // Handle removal + if (playedCard.isRemoval) { + p.stats.removalUsed++; + var oppCreatures = sim.battlefieldCards.filter(function(c) { + return c.ownerId !== pi && c.typeLine && /creature/i.test(c.typeLine); + }); + if (oppCreatures.length > 0) { + oppCreatures.sort(function(a, b) { return headlessScoreCard(b, w) - headlessScoreCard(a, w); }); + var killed = oppCreatures[0]; + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== killed.id; }); + sim.players[killed.ownerId].zones.graveyard.push(killed); + } + p.zones.graveyard.push(playedCard); + } else if (playedCard.isBoardWipe) { + p.stats.boardWipesUsed++; + var surviving = []; + sim.battlefieldCards.forEach(function(c) { + if (c.typeLine && /creature/i.test(c.typeLine)) { + sim.players[c.ownerId].zones.graveyard.push(c); + } else { + surviving.push(c); + } + }); + sim.battlefieldCards = surviving; + p.zones.graveyard.push(playedCard); + } else { + sim.battlefieldCards.push(playedCard); + if (playedCard.typeLine && /creature/i.test(playedCard.typeLine)) p.stats.creaturesPlayed++; + if (playedCard.isRamp) p.stats.rampPlayed++; + } + played++; + + // Recalculate mana + availableMana = 0; + sim.battlefieldCards.forEach(function(c) { + if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) availableMana++; + }); + } + + // Track board size + var boardSize = sim.battlefieldCards.filter(function(c) { return c.ownerId === pi; }).length; + if (boardSize > p.stats.maxBoardSize) p.stats.maxBoardSize = boardSize; + + // --- COMBAT --- + var myCreatures = sim.battlefieldCards.filter(function(c) { + return c.ownerId === pi && c.typeLine && /creature/i.test(c.typeLine) && !c.tapped && (turn > 0 || c.turnPlayed !== turn); + }); + + var oppIdx = 1 - pi; + var opp = sim.players[oppIdx]; + if (opp.eliminated || myCreatures.length === 0) { + sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; }); + continue; + } + + var oppBlockers = sim.battlefieldCards.filter(function(c) { + return c.ownerId === oppIdx && c.typeLine && /creature/i.test(c.typeLine) && !c.tapped; + }); + + // Decide attackers + var attackers = []; + myCreatures.forEach(function(atk) { + var aPow = headlessGetPower(atk); + var aTou = headlessGetToughness(atk); + var hasFlying = headlessHasKw(atk, 'flying'); + var hasTrample = headlessHasKw(atk, 'trample'); + var canDie = oppBlockers.some(function(b) { + if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false; + return headlessGetPower(b) >= aTou || headlessHasKw(b, 'deathtouch'); + }); + if (!canDie || hasFlying || hasTrample || aPow >= 4) { + attackers.push(atk); atk.tapped = true; + } + }); + + // Blocking and damage + var totalDamage = 0; + var usedBlockers = {}; + attackers.forEach(function(atk) { + var aPow = headlessGetPower(atk); + var aTou = headlessGetToughness(atk); + var hasFlying = headlessHasKw(atk, 'flying'); + var validBlockers = oppBlockers.filter(function(b) { + if (usedBlockers[b.id]) return false; + if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false; + return true; + }); + + if (validBlockers.length > 0 && !headlessHasKw(atk, 'menace')) { + var blocker = validBlockers.find(function(b) { return headlessGetToughness(b) > aPow; }); + if (!blocker && opp.life <= aPow * 2) blocker = validBlockers[0]; + if (blocker) { + usedBlockers[blocker.id] = true; + var bPow = headlessGetPower(blocker); + if (aPow >= headlessGetToughness(blocker) || headlessHasKw(atk, 'deathtouch')) { + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== blocker.id; }); + sim.players[oppIdx].zones.graveyard.push(blocker); + } + if (bPow >= aTou || headlessHasKw(blocker, 'deathtouch')) { + sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== atk.id; }); + sim.players[pi].zones.graveyard.push(atk); + } + if (headlessHasKw(atk, 'trample') && aPow > headlessGetToughness(blocker)) { + totalDamage += aPow - headlessGetToughness(blocker); + } + if (headlessHasKw(atk, 'lifelink')) p.life += Math.min(aPow, headlessGetToughness(blocker)); + return; + } + } + totalDamage += aPow; + if (headlessHasKw(atk, 'lifelink')) p.life += aPow; + }); + + opp.life -= totalDamage; + p.stats.damageDealt += totalDamage; + opp.stats.damageReceived += totalDamage; + if (opp.life <= 0) opp.eliminated = true; + + // Untap + sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; }); + } + + // Check game over + var alive = sim.players.filter(function(p) { return !p.eliminated; }); + if (alive.length <= 1) break; + } + + // Determine winner + var winner = -1; + if (sim.players[0].eliminated && !sim.players[1].eliminated) winner = 1; + else if (!sim.players[0].eliminated && sim.players[1].eliminated) winner = 0; + else { + // Both alive or both dead: compare life + winner = sim.players[0].life >= sim.players[1].life ? 0 : 1; + } + + return { + winner: winner, + turns: Math.min(turn + 1, sim.maxTurns), + playerA: { name: nameA, life: sim.players[0].life, eliminated: sim.players[0].eliminated, stats: sim.players[0].stats }, + playerB: { name: nameB, life: sim.players[1].life, eliminated: sim.players[1].eliminated, stats: sim.players[1].stats } + }; +} + +// --- Run full deck test suite --- +function runDeckTester() { + var st = deckTesterState; + if (st.running) return; + + // Build custom deck + var customDeck = buildSimDeckFromDeckBuilder(); + if (!customDeck || customDeck.length < 20) { + var container = document.getElementById('dt-results'); + if (container) container.innerHTML = '
Deck needs at least 20 cards to test. Add more cards in the Deck Builder.
'; + return; + } + st.customDeck = customDeck; + + // Get selected precons + var preconCheckboxes = document.querySelectorAll('.dt-precon-cb:checked'); + st.selectedPrecons = []; + preconCheckboxes.forEach(function(cb) { + st.selectedPrecons.push(parseInt(cb.value)); + }); + if (st.selectedPrecons.length === 0) { + var container = document.getElementById('dt-results'); + if (container) container.innerHTML = '
Select at least one precon deck to test against.
'; + return; + } + + // Get games per matchup + var gpmInput = document.getElementById('dt-games-per'); + st.gamesPerMatchup = gpmInput ? parseInt(gpmInput.value) || 50 : 50; + + st.running = true; + st.cancelled = false; + st.progress = 0; + st.total = st.selectedPrecons.length * st.gamesPerMatchup; + st.results = { + deckName: dbCurrentDeck.name || 'Custom Deck', + deckSize: customDeck.length, + matchups: [], + aggregate: { wins: 0, losses: 0, draws: 0, totalGames: 0 } + }; + + renderDeckTesterProgress(); + + // Run matchups async + var matchupIdx = 0; + function runNextMatchup() { + if (st.cancelled || matchupIdx >= st.selectedPrecons.length) { + st.running = false; + renderDeckTesterResults(); + return; + } + + var preconIdx = st.selectedPrecons[matchupIdx]; + var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA[preconIdx] : null; + if (!precon) { matchupIdx++; runNextMatchup(); return; } + + var preconDeck = buildSimDeckFromPrecon(preconIdx); + if (!preconDeck || preconDeck.length < 10) { matchupIdx++; runNextMatchup(); return; } + + st.currentMatchup = precon.name; + var matchupResult = { + preconName: precon.name, + preconSet: precon.set || '', + preconColors: precon.colors || '', + preconTheme: precon.theme || '', + wins: 0, losses: 0, draws: 0, + avgTurns: 0, avgLifeDiff: 0, + avgDamageDealt: 0, avgDamageReceived: 0, + avgSpellsCast: 0, avgCreaturesPlayed: 0, + avgRemovalUsed: 0, avgRampPlayed: 0, + avgCardsDrawn: 0, avgBoardSize: 0, + avgManaSpent: 0, + winByTurn: {}, // turn# -> count of wins on that turn + games: [] + }; + + var gameIdx = 0; + function runNextGame() { + if (st.cancelled || gameIdx >= st.gamesPerMatchup) { + // Finalize matchup + var g = matchupResult.wins + matchupResult.losses + matchupResult.draws; + if (g > 0) { + matchupResult.avgTurns = Math.round(matchupResult.avgTurns / g * 10) / 10; + matchupResult.avgLifeDiff = Math.round(matchupResult.avgLifeDiff / g * 10) / 10; + matchupResult.avgDamageDealt = Math.round(matchupResult.avgDamageDealt / g * 10) / 10; + matchupResult.avgDamageReceived = Math.round(matchupResult.avgDamageReceived / g * 10) / 10; + matchupResult.avgSpellsCast = Math.round(matchupResult.avgSpellsCast / g * 10) / 10; + matchupResult.avgCreaturesPlayed = Math.round(matchupResult.avgCreaturesPlayed / g * 10) / 10; + matchupResult.avgRemovalUsed = Math.round(matchupResult.avgRemovalUsed / g * 10) / 10; + matchupResult.avgRampPlayed = Math.round(matchupResult.avgRampPlayed / g * 10) / 10; + matchupResult.avgCardsDrawn = Math.round(matchupResult.avgCardsDrawn / g * 10) / 10; + matchupResult.avgBoardSize = Math.round(matchupResult.avgBoardSize / g * 10) / 10; + matchupResult.avgManaSpent = Math.round(matchupResult.avgManaSpent / g * 10) / 10; + } + st.results.matchups.push(matchupResult); + st.results.aggregate.wins += matchupResult.wins; + st.results.aggregate.losses += matchupResult.losses; + st.results.aggregate.draws += matchupResult.draws; + st.results.aggregate.totalGames += g; + + matchupIdx++; + renderDeckTesterProgress(); + setTimeout(runNextMatchup, 1); + return; + } + + var result = dtSimGame(st.customDeck.slice(), preconDeck.slice(), st.results.deckName, precon.name); + + if (result.winner === 0) { + matchupResult.wins++; + var wt = result.turns; + matchupResult.winByTurn[wt] = (matchupResult.winByTurn[wt] || 0) + 1; + } else if (result.winner === 1) { + matchupResult.losses++; + } else { + matchupResult.draws++; + } + + var myStats = result.playerA.stats; + matchupResult.avgTurns += result.turns; + matchupResult.avgLifeDiff += (result.playerA.life - result.playerB.life); + matchupResult.avgDamageDealt += myStats.damageDealt; + matchupResult.avgDamageReceived += myStats.damageReceived; + matchupResult.avgSpellsCast += myStats.spellsCast; + matchupResult.avgCreaturesPlayed += myStats.creaturesPlayed; + matchupResult.avgRemovalUsed += myStats.removalUsed; + matchupResult.avgRampPlayed += myStats.rampPlayed; + matchupResult.avgCardsDrawn += myStats.cardsDrawn; + matchupResult.avgBoardSize += myStats.maxBoardSize; + matchupResult.avgManaSpent += myStats.manaSpent; + + st.progress++; + gameIdx++; + + // Update progress every 10 games + if (gameIdx % 10 === 0) { + renderDeckTesterProgress(); + setTimeout(runNextGame, 1); + } else { + runNextGame(); + } + } + + runNextGame(); + } + + runNextMatchup(); +} + +function cancelDeckTester() { + deckTesterState.cancelled = true; +} + +// --- Render progress bar --- +function renderDeckTesterProgress() { + var st = deckTesterState; + var container = document.getElementById('dt-results'); + if (!container) return; + + var pct = st.total > 0 ? Math.round(st.progress / st.total * 100) : 0; + container.innerHTML = + '
' + + '
Simulating: ' + (st.currentMatchup || '...') + '
' + + '
' + + '
' + st.progress + ' / ' + st.total + ' games (' + pct + '%)
' + + '' + + '
'; +} + +// --- Render full results dashboard --- +function renderDeckTesterResults() { + var st = deckTesterState; + var container = document.getElementById('dt-results'); + if (!container || !st.results) return; + + var r = st.results; + var agg = r.aggregate; + var winRate = agg.totalGames > 0 ? Math.round(agg.wins / agg.totalGames * 100) : 0; + + var html = ''; + + // --- Overall Summary --- + html += '
'; + html += '
' + escapeHtml(r.deckName) + ' (' + r.deckSize + ' cards)
'; + html += '
'; + html += '
'; + html += '
' + winRate + '%
'; + html += '
Win Rate
'; + html += '
'; + html += '
'; + html += '
' + agg.wins + '
'; + html += '
Wins
'; + html += '
'; + html += '
'; + html += '
' + agg.losses + '
'; + html += '
Losses
'; + html += '
'; + html += '
'; + html += '
' + agg.totalGames + '
'; + html += '
Games
'; + html += '
'; + html += '
'; // overall-stats + html += '
'; // summary + + // --- Win Rate Bar Chart --- + html += '
'; + html += '
Win Rate by Matchup
'; + html += '
'; + r.matchups.forEach(function(m) { + var total = m.wins + m.losses + m.draws; + var wr = total > 0 ? Math.round(m.wins / total * 100) : 0; + var barClass = wr >= 60 ? 'dt-bar-great' : wr >= 45 ? 'dt-bar-ok' : 'dt-bar-bad'; + html += '
'; + html += '
'; + html += '' + escapeHtml(m.preconColors) + ' ' + escapeHtml(m.preconName); + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + wr + '% (' + m.wins + '-' + m.losses + ')
'; + html += '
'; + }); + html += '
'; + + // --- Detailed Stats Table --- + html += '
'; + html += '
Performance Metrics (Averages per Game)
'; + html += '
'; + html += ''; + html += ''; + r.matchups.forEach(function(m) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
MatchupTurnsDmg DealtDmg TakenSpellsCreaturesRemovalRampCards DrawnMax Board
' + escapeHtml(m.preconName) + '' + m.avgTurns + '' + m.avgDamageDealt + '' + m.avgDamageReceived + '' + m.avgSpellsCast + '' + m.avgCreaturesPlayed + '' + m.avgRemovalUsed + '' + m.avgRampPlayed + '' + m.avgCardsDrawn + '' + m.avgBoardSize + '
'; + html += '
'; + + // --- Aggregate Stats Summary --- + var totalMatchups = r.matchups.length; + var avgDmg = 0, avgRcv = 0, avgSpells = 0, avgCreatures = 0, avgRemoval = 0, avgRamp = 0, avgDraw = 0, avgBoard = 0, avgTurns = 0; + r.matchups.forEach(function(m) { + avgDmg += m.avgDamageDealt; avgRcv += m.avgDamageReceived; + avgSpells += m.avgSpellsCast; avgCreatures += m.avgCreaturesPlayed; + avgRemoval += m.avgRemovalUsed; avgRamp += m.avgRampPlayed; + avgDraw += m.avgCardsDrawn; avgBoard += m.avgBoardSize; + avgTurns += m.avgTurns; + }); + if (totalMatchups > 0) { + avgDmg = Math.round(avgDmg / totalMatchups * 10) / 10; + avgRcv = Math.round(avgRcv / totalMatchups * 10) / 10; + avgSpells = Math.round(avgSpells / totalMatchups * 10) / 10; + avgCreatures = Math.round(avgCreatures / totalMatchups * 10) / 10; + avgRemoval = Math.round(avgRemoval / totalMatchups * 10) / 10; + avgRamp = Math.round(avgRamp / totalMatchups * 10) / 10; + avgDraw = Math.round(avgDraw / totalMatchups * 10) / 10; + avgBoard = Math.round(avgBoard / totalMatchups * 10) / 10; + avgTurns = Math.round(avgTurns / totalMatchups * 10) / 10; + } + + html += '
'; + html += '
Deck Profile (Across All Matchups)
'; + html += '
'; + var profileCards = [ + { label: 'Avg Game Length', value: avgTurns + ' turns', icon: '⏳' }, + { label: 'Damage Output', value: avgDmg + ' / game', icon: '⚔' }, + { label: 'Damage Taken', value: avgRcv + ' / game', icon: '🛡' }, + { label: 'Spells Cast', value: avgSpells + ' / game', icon: '🃏' }, + { label: 'Creatures Played', value: avgCreatures + ' / game', icon: '🐲' }, + { label: 'Interaction', value: avgRemoval + ' / game', icon: '☠' }, + { label: 'Ramp Spells', value: avgRamp + ' / game', icon: '🌱' }, + { label: 'Cards Drawn', value: avgDraw + ' / game', icon: '📃' }, + { label: 'Peak Board Size', value: avgBoard + ' permanents', icon: '🏠' }, + ]; + profileCards.forEach(function(pc) { + html += '
'; + html += '
' + pc.icon + '
'; + html += '
' + pc.value + '
'; + html += '
' + pc.label + '
'; + html += '
'; + }); + html += '
'; + + // --- Action buttons --- + html += '
'; + html += ''; + html += ''; + html += '
'; + + container.innerHTML = html; +} + +// --- Export results as text --- +function exportDeckTesterResults() { + var st = deckTesterState; + if (!st.results) return; + var r = st.results; + var agg = r.aggregate; + var winRate = agg.totalGames > 0 ? Math.round(agg.wins / agg.totalGames * 100) : 0; + + var text = '=== DECK TESTER RESULTS ===\n'; + text += 'Deck: ' + r.deckName + ' (' + r.deckSize + ' cards)\n'; + text += 'Overall: ' + winRate + '% win rate (' + agg.wins + 'W - ' + agg.losses + 'L in ' + agg.totalGames + ' games)\n\n'; + + r.matchups.forEach(function(m) { + var total = m.wins + m.losses + m.draws; + var wr = total > 0 ? Math.round(m.wins / total * 100) : 0; + text += 'vs ' + m.preconName + ' (' + m.preconColors + '): ' + wr + '% (' + m.wins + '-' + m.losses + ')\n'; + text += ' Avg turns: ' + m.avgTurns + ', Dmg: ' + m.avgDamageDealt + ', Spells: ' + m.avgSpellsCast + ', Creatures: ' + m.avgCreaturesPlayed + '\n'; + }); + + // Copy to clipboard + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text); + } + var btn = document.querySelector('.dt-actions .dt-btn:last-child'); + if (btn) { + var orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = orig; }, 1500); + } +} + +// --- Open / Close --- +function openDeckTester() { + var modal = document.getElementById('deck-tester-modal'); + if (modal) { + modal.style.display = 'flex'; + renderDeckTesterSetup(); + } +} + +function closeDeckTester() { + var modal = document.getElementById('deck-tester-modal'); + if (modal) modal.style.display = 'none'; + if (deckTesterState.running) cancelDeckTester(); +} + +// --- Render setup panel with precon selection --- +function renderDeckTesterSetup() { + var container = document.getElementById('dt-results'); + if (!container) return; + + var deckName = (dbCurrentDeck && dbCurrentDeck.name) ? dbCurrentDeck.name : 'No deck loaded'; + var cardCount = (dbCurrentDeck && dbCurrentDeck.cards) ? dbCurrentDeck.cards.reduce(function(s, c) { return s + (c.quantity || 1); }, 0) : 0; + var cmdName = (dbCurrentDeck && dbCurrentDeck.commander) ? (dbCurrentDeck.commander.name || dbCurrentDeck.commander) : 'None'; + + var html = ''; + + // Deck info + html += '
'; + html += '
' + escapeHtml(deckName) + '
'; + html += '
Commander: ' + escapeHtml(String(cmdName)) + ' | ' + cardCount + ' cards
'; + html += '
'; + + // Config + html += '
'; + html += ''; + html += '
'; + + // Precon selection + html += '
'; + html += '
'; + html += 'Select opponents:'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + if (typeof PRECON_DATA !== 'undefined') { + PRECON_DATA.forEach(function(p, i) { + html += ''; + }); + } + + html += '
'; + + // Run button + html += '
'; + html += ''; + html += '
'; + + container.innerHTML = html; +} + +function dtSelectAll(checked) { + document.querySelectorAll('.dt-precon-cb').forEach(function(cb) { cb.checked = checked; }); +} + +// ============================================================ +// ENGINE-TO-UI BRIDGE +// These are stub implementations that ui.js overrides. +// The engine calls these; the UI provides real DOM implementations. +// ============================================================ + +// Logging — ui.js overrides this +if (typeof addLogEntry !== 'function') { + window.addLogEntry = function(text, category) { + if (typeof window._engineLog === 'undefined') window._engineLog = []; + window._engineLog.push({ text: text, category: category, ts: Date.now() }); + if (window._uiLogCallback) window._uiLogCallback(text, category); + }; +} + +// These DOM functions are called by engine logic that mixes concerns. +// ui.js overrides them with real implementations. +function _safeRender(fn, ...args) { + if (typeof window[fn] === 'function') { + try { window[fn](...args); } catch(e) {} + } +} diff --git a/lan-client/index.html b/lan-client/index.html new file mode 100644 index 0000000..4de27b7 --- /dev/null +++ b/lan-client/index.html @@ -0,0 +1,643 @@ + + + + + +MTG Commander Battlefield + + + + + +
+ +
+ + + + + +
+ + +
+ Card Preview +
+
+
+
+
+
+
+
+ + +
+
+
+ Player Zones + +
+
+ +
+
+ +
+
+
+ + +
+
+
+ Dice & Coins + +
+
—
+
Choose a die
+
+ + + + + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ Trigger Stack + +
+
+
No pending triggers.
+
+
+ + +
+
+
+ + +
+ +
+ +
+
+ + + + +
+
+ + +
+ Waiting for priority... + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + diff --git a/lan-client/player-panels.js b/lan-client/player-panels.js new file mode 100644 index 0000000..cce9fc0 --- /dev/null +++ b/lan-client/player-panels.js @@ -0,0 +1,270 @@ +// player-panels.js — Enhanced Player Panel Component +// Provides collapsible panels with life, commander damage, counters, and emblems. + +'use strict'; + +/** + * 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'); + panel.className = 'enhanced-player-panel' + + (isActive ? ' active-turn' : '') + + (player.eliminated ? ' eliminated' : ''); + panel.id = 'enhanced-panel-' + player.id; + panel.setAttribute('data-player-id', player.id); + + // Track collapse state + var collapseKey = 'panel-collapsed-' + player.id; + var isCollapsed = localStorage.getItem(collapseKey) === 'true'; + + // ── Header Row ────────────────────────────────────────── + var headerHtml = + '
' + + '' + (isCollapsed ? '\u25b8' : '\u25be') + '' + + '
' + + '' + player.name + '' + + (player.isAI ? 'AI' : '') + + '' + + '\u2665 ' + player.life + + (player.life < player.startingLife + ? ' / ' + player.startingLife + : '') + + '' + + '
'; + + // ── Life Counter Row ──────────────────────────────────── + var lifeHtml = + '
' + + '' + + '
' + + player.life + + '
' + + '' + + '
'; + + // ── Commander Damage Section ──────────────────────────── + var otherPlayers = gameState.players.filter(function(p) { + return p.id !== player.id && !p.eliminated; + }); + + var cmdDmgHtml = + '
' + + '' + + '
'; + + otherPlayers.forEach(function(op) { + var dmg = player.commanderDamage[op.id] || 0; + var pct = Math.round((dmg / 21) * 100); + var dangerClass = dmg >= 18 ? 'critical' : dmg >= 10 ? 'warning' : ''; + + cmdDmgHtml += + '
' + + '
' + + '
' + + '' + op.name + '' + + '' + dmg + '/21' + + '' + + '' + + '
'; + }); + + cmdDmgHtml += '
'; + + // ── Counters Section ──────────────────────────────────── + var poison = player.poison || 0; + var energy = player.energy || 0; + var experience = player.experience || 0; + var rad = player.rad || 0; + + var countersHtml = + '
' + + '' + + '
' + + _buildCounterWidget('Poison', '\u2620', poison, 10, + 'adjustPoison(' + player.id + ',1)', + 'adjustPoison(' + player.id + ',-1)', + poison >= 8 ? 'critical' : poison >= 5 ? 'warning' : '') + + _buildCounterWidget('Energy', '\u269b', energy, 0, + 'adjustEnergy(' + player.id + ',1)', + 'adjustEnergy(' + player.id + ',-1)', '') + + _buildCounterWidget('Experience', '\u2605', experience, 0, + 'adjustExperience(' + player.id + ',1)', + 'adjustExperience(' + player.id + ',-1)', '') + + _buildCounterWidget('Rad', '\u2622', rad, 0, + 'adjustRad(' + player.id + ',1)', + 'adjustRad(' + player.id + ',-1)', '') + + '
' + + '
'; + + // ── Emblems Section ───────────────────────────────────── + var emblems = player.emblems || []; + var emblemsHtml = + '
' + + '' + + '
'; + + if (emblems.length === 0) { + emblemsHtml += 'None'; + } else { + emblems.forEach(function(emb, idx) { + emblemsHtml += + '
' + + '' + emb.name + '' + + '' + + '
'; + }); + } + + emblemsHtml += + '' + + '
' + + '
'; + + // ── Assemble Panel ────────────────────────────────────── + var bodyHtml = + '
' + + lifeHtml + cmdDmgHtml + countersHtml + emblemsHtml + + '
'; + + panel.innerHTML = headerHtml + bodyHtml; + return panel; +} + +function _buildCounterWidget(label, icon, value, threshold, onInc, onDec, dangerClass) { + return '
' + + '
' + icon + '
' + + '
' + + '' + value + '' + + '' + label + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; +} + +function togglePlayerPanel(playerId) { + var body = document.getElementById('epp-body-' + playerId); + var panel = document.getElementById('enhanced-panel-' + playerId); + if (!body || !panel) return; + + var isCollapsed = body.style.display === 'none'; + body.style.display = isCollapsed ? '' : 'none'; + + var arrow = panel.querySelector('.epp-collapse-arrow'); + if (arrow) arrow.textContent = isCollapsed ? '\u25be' : '\u25b8'; + + localStorage.setItem('panel-collapsed-' + playerId, !isCollapsed); +} + +// Counter adjustment functions +function adjustEnergy(playerId, amount) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + player.energy = Math.max(0, (player.energy || 0) + amount); + addLogEntry('' + player.name + ' energy: ' + player.energy); + renderEnhancedPanels(); +} + +function adjustExperience(playerId, amount) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + player.experience = Math.max(0, (player.experience || 0) + amount); + addLogEntry('' + player.name + ' experience: ' + player.experience); + renderEnhancedPanels(); +} + +function adjustRad(playerId, amount) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + player.rad = Math.max(0, (player.rad || 0) + amount); + addLogEntry('' + player.name + ' rad counters: ' + player.rad); + renderEnhancedPanels(); +} + +function showAddEmblemDialog(playerId) { + var name = prompt('Enter emblem name (e.g., "Sorin, Vengeful Bloodlord"):'); + if (!name || !name.trim()) return; + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player) return; + if (!player.emblems) player.emblems = []; + player.emblems.push({ name: name.trim() }); + addLogEntry('' + player.name + ' gained emblem: ' + name.trim()); + renderEnhancedPanels(); +} + +function removeEmblem(playerId, idx) { + if (!gameState) return; + var player = gameState.players[playerId]; + if (!player || !player.emblems) return; + var removed = player.emblems.splice(idx, 1); + if (removed.length > 0) { + addLogEntry('' + player.name + ' lost emblem: ' + removed[0].name); + } + renderEnhancedPanels(); +} + +/** + * Re-render all enhanced player panels in their quadrant positions. + */ +function renderEnhancedPanels() { + if (!gameState) return; + + for (var i = 0; i < gameState.players.length; i++) { + var player = gameState.players[i]; + var quadrant = i + 1; + var areaEl = document.getElementById('player-area-' + quadrant); + if (!areaEl) continue; + + // Find or create the enhanced panel container + var container = areaEl.querySelector('.epp-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'epp-container'; + // Insert before the zone row + var zoneRow = areaEl.querySelector('.zone-row'); + if (zoneRow) { + areaEl.insertBefore(container, zoneRow); + } else { + areaEl.appendChild(container); + } + } + + var isActive = gameState.currentPlayerIndex === i; + var panel = createEnhancedPlayerPanel(player, quadrant, isActive); + container.innerHTML = ''; + container.appendChild(panel); + + // Hide the old life counter (we have our own now) + var oldLifeCounter = areaEl.querySelector('.life-counter'); + if (oldLifeCounter) oldLifeCounter.style.display = 'none'; + + // Hide old poison badge (we have counters section) + var oldPoison = areaEl.querySelector('.poison-badge'); + if (oldPoison) oldPoison.style.display = 'none'; + } +} diff --git a/lan-client/style.css b/lan-client/style.css new file mode 100644 index 0000000..a3e3c24 --- /dev/null +++ b/lan-client/style.css @@ -0,0 +1,3430 @@ +/* ============================================================ + MTG Commander Simulator — Battlefield Style + 4-Quadrant Battlefield Layout + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@400;500;600&display=swap'); + +/* ============================================================ + DESIGN TOKENS + ============================================================ */ +:root { + --font-display: 'Cinzel', 'Georgia', serif; + --font-body: 'Inter', system-ui, sans-serif; + + --text-xs: 0.72rem; + --text-sm: 0.8rem; + --text-base: 0.9rem; + --text-lg: 1.1rem; + --text-xl: 1.3rem; + --text-2xl: 1.7rem; + --text-3xl: 2.2rem; + --text-hero: 3rem; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --transition: 180ms cubic-bezier(0.16, 1, 0.3, 1); + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.35); + --shadow-md: 0 4px 12px rgba(0,0,0,0.5); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.6); + + /* MTG mana colors */ + --mtg-white: #F9FAF4; + --mtg-blue: #0E68AB; + --mtg-black: #150B00; + --mtg-red: #D3202A; + --mtg-green: #00733E; + --mtg-gold: #C8A843; + --mtg-colorless: #8C8C8C; + + /* Battlefield theme (felt table) */ + --felt: #2a2a2a; + --felt-light: #333333; + --felt-dark: #1e1e1e; + --felt-line: rgba(255,255,255,0.08); + --felt-line-strong: rgba(255,255,255,0.15); + + /* New battlefield player colors */ + --p1: #c0392b; --p1-soft: rgba(192,57,43,0.15); --p1-glow: rgba(192,57,43,0.35); --p1-text: #e74c3c; + --p2: #2980b9; --p2-soft: rgba(41,128,185,0.15); --p2-glow: rgba(41,128,185,0.35); --p2-text: #3498db; + --p3: #27ae60; --p3-soft: rgba(39,174,96,0.15); --p3-glow: rgba(39,174,96,0.35); --p3-text: #2ecc71; + --p4: #d4930d; --p4-soft: rgba(212,147,13,0.15); --p4-glow: rgba(212,147,13,0.35); --p4-text: #f1c40f; + + /* Card dimensions */ + --card-w: 63px; + --card-h: 88px; + --bf-card-w: 100px; + --bf-card-h: 140px; + --zone-radius: 8px; + + /* Game UI colors */ + --color-bg: #0d0d1a; + --color-surface: #131325; + --color-surface-2: #1a1a2e; + --color-surface-3: #1f1f38; + --color-surface-raised: #252545; + --color-border: rgba(255,255,255,0.08); + --color-border-strong: rgba(255,255,255,0.15); + --color-divider: rgba(255,255,255,0.06); + --color-text: #e8e8f0; + --color-text-muted: #8888aa; + --color-text-faint: #555578; + --color-primary: #5b8dd9; + --color-primary-hover: #7aaae8; + --color-success: #2ecc71; + --color-warning: #f39c12; + --color-error: #e74c3c; + --color-info: #3498db; +} + +/* ============================================================ + RESET & BASE + ============================================================ */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + width: 100%; height: 100%; + overflow: hidden; + font-family: 'Inter', sans-serif; + background: var(--felt-dark); + color: var(--color-text); +} + +button { border: none; cursor: pointer; font-family: inherit; background: transparent; } +input { font-family: inherit; } + +/* ============================================================ + TABLE SURFACE — Felt Battlefield Background + ============================================================ */ +.table-surface { + position: relative; + width: 100vw; + height: 100vh; + background: radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 50%, var(--felt-dark) 100%); + overflow: hidden; +} + +.table-surface::before { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E"); + background-size: 200px 200px; + pointer-events: none; + z-index: 1; + opacity: 0.7; +} + +.table-surface::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.45) 100%); + pointer-events: none; + z-index: 2; +} + +/* ============================================================ + TOP BAR — Phase tracker + controls + ============================================================ */ +#top-bar { + position: absolute; + top: 0; left: 0; right: 0; + z-index: 60; + height: 44px; + background: rgba(10,10,20,0.92); + border-bottom: 1px solid var(--felt-line-strong); + display: flex; + align-items: center; + padding: 0 10px; + gap: 8px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.top-bar-logo { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: var(--mtg-gold); + letter-spacing: 0.1em; + white-space: nowrap; + flex-shrink: 0; +} + +.turn-counter-badge { + background: rgba(255,255,255,0.05); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.turn-counter-badge span { + color: var(--color-text); + font-family: var(--font-display); +} + +.active-player-indicator { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.active-player-dot { + width: 8px; height: 8px; + border-radius: 50%; +} + +.phase-track-wrapper { + flex: 1; + display: flex; + align-items: center; + gap: 2px; + overflow: hidden; + min-width: 0; +} + +.phase-track { + display: flex; + align-items: center; + gap: 0; + flex: 1; + overflow: hidden; +} + +.phase-step { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + padding: 3px 5px; + font-size: 10px; + font-weight: 600; + color: var(--color-text-faint); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s; + position: relative; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.phase-step:hover:not(.active) { + background: rgba(255,255,255,0.05); + color: var(--color-text-muted); +} + +.phase-step.active { color: #fff; } +.phase-step.done { color: var(--color-text-faint); opacity: 0.5; } + +.phase-connector { + width: 8px; height: 2px; + background: rgba(255,255,255,0.08); + flex-shrink: 0; + border-radius: 1px; +} + +.top-bar-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.btn-phase { + padding: 4px 10px; + background: rgba(255,255,255,0.06); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.btn-phase:hover { + background: rgba(91,141,217,0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-phase.btn-next-turn { + border-color: rgba(200,168,67,0.4); + color: var(--mtg-gold); + background: rgba(200,168,67,0.08); +} + +.btn-phase.btn-next-turn:hover { + background: rgba(200,168,67,0.18); +} + +.top-bar-utils { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.btn-util { + width: 28px; height: 28px; + border-radius: var(--radius-md); + background: rgba(255,255,255,0.05); + border: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.btn-util:hover { + color: var(--color-text); + background: rgba(255,255,255,0.1); +} + +.btn-util.has-triggers { + background: rgba(200,168,67,0.15); + border-color: rgba(200,168,67,0.5); + color: var(--mtg-gold); +} + +/* ============================================================ + SETUP SCREEN + ============================================================ */ +#setup-screen { + position: absolute; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: var(--felt-dark); +} + +#setup-screen.hidden { + display: none; +} + +.setup-card { + background: rgba(26,26,46,0.97); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 2.5rem; + width: min(640px, 95vw); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +.setup-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: linear-gradient(90deg, var(--p1), var(--p2), var(--p3), var(--p4)); +} + +.setup-title { + font-family: var(--font-display); + font-size: var(--text-3xl); + font-weight: 700; + text-align: center; + margin-bottom: 0.5rem; + color: var(--mtg-gold); + letter-spacing: 0.02em; +} + +.setup-subtitle { + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-sm); + margin-bottom: 2rem; + font-family: var(--font-display); + letter-spacing: 0.05em; +} + +.setup-section { margin-bottom: 1.5rem; } + +.setup-label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.75rem; +} + +.player-count-btns { + display: flex; + gap: 0.5rem; +} + +.player-count-btn { + flex: 1; + padding: 0.75rem 0.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + font-size: var(--text-lg); + font-weight: 600; + font-family: var(--font-display); +} + +.player-count-btn:hover { + border-color: var(--color-primary); + color: var(--color-text); +} + +.player-count-btn.active { + background: rgba(91,141,217,0.2); + border-color: var(--color-primary); + color: #fff; + border-width: 2px; +} + +.player-names-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.player-name-field { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.5rem 0.75rem; +} + +.player-color-dot { + width: 12px; height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.player-name-input { + background: none; + border: none; + outline: none; + color: var(--color-text); + font-size: var(--text-sm); + width: 100%; +} + +.player-name-input::placeholder { color: var(--color-text-faint); } + +.life-total-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.life-adjust-btn { + width: 36px; height: 36px; + border-radius: var(--radius-md); + background: var(--color-surface-3); + border: 1px solid var(--color-border-strong); + color: var(--color-text); + font-size: var(--text-xl); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +#starting-life-display { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-text); + min-width: 3ch; + text-align: center; +} + +.btn-start { + width: 100%; + padding: 1rem 1.5rem; + background: rgba(91,141,217,0.2); + border: 1px solid var(--color-primary); + border-radius: var(--radius-lg); + color: var(--color-primary); + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-top: 1rem; +} + +.btn-start:hover { + background: rgba(91,141,217,0.3); + box-shadow: 0 0 24px rgba(91,141,217,0.35); +} + +/* Deck selection in setup */ +.deck-select-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.deck-select-label { + font-size: var(--text-xs); + color: var(--color-text-faint); + flex-shrink: 0; +} + +.deck-select-btn { + padding: 3px 10px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: var(--text-xs); + font-weight: 600; +} + +.deck-select-btn:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.deck-select-btn.selected { + background: rgba(91,141,217,0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* AI checkbox */ +.player-ai-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +/* ============================================================ + 4-QUADRANT BATTLEFIELD + ============================================================ */ +.battlefield { + position: absolute; + inset: 44px 0 0 0; /* offset for top-bar */ + z-index: 5; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 0; +} + +.divider-h, .divider-v { + position: absolute; + z-index: 10; + pointer-events: none; +} + +.divider-h { + top: calc(50% + 22px); left: 10px; right: 10px; + height: 2px; + transform: translateY(-50%); + background: var(--felt-line-strong); + box-shadow: 0 0 12px rgba(255,255,255,0.04); +} + +.divider-v { + left: 50%; top: 54px; bottom: 10px; + width: 2px; + transform: translateX(-50%); + background: var(--felt-line-strong); + box-shadow: 0 0 12px rgba(255,255,255,0.04); +} + +.center-emblem { + position: absolute; + top: calc(50% + 22px); left: 50%; + transform: translate(-50%, -50%); + z-index: 15; + width: 64px; height: 64px; + border-radius: 50%; + background: radial-gradient(circle, rgba(55,55,55,0.95), rgba(25,25,25,0.98)); + border: 2px solid rgba(255,255,255,0.08); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 30px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.3); +} + +.center-emblem svg { width: 34px; height: 34px; } + +.player-area { + position: relative; + display: flex; + flex-direction: column; + padding: 6px; + overflow: hidden; +} + +.player-area.p1 { grid-area: 1 / 1 / 2 / 2; } +.player-area.p2 { grid-area: 1 / 2 / 2 / 3; } +.player-area.p3 { grid-area: 2 / 1 / 3 / 2; } +.player-area.p4 { grid-area: 2 / 2 / 3 / 3; } + +.p1, .p2 { justify-content: flex-start; } +.p3, .p4 { justify-content: flex-end; } + +/* Active player glow */ +.player-area.active-player .battle-zone { + box-shadow: inset 0 0 20px rgba(255,255,255,0.03); +} +.player-area.p1.active-player { background: rgba(192,57,43,0.04); } +.player-area.p2.active-player { background: rgba(41,128,185,0.04); } +.player-area.p3.active-player { background: rgba(39,174,96,0.04); } +.player-area.p4.active-player { background: rgba(212,147,13,0.04); } + +/* Player labels */ +.player-label { + font-family: 'Cinzel', serif; + font-weight: 700; + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 4px; + position: absolute; + z-index: 20; + white-space: nowrap; +} +.p1 .player-label { top: 6px; left: 6px; color: var(--p1-text); background: var(--p1-soft); } +.p2 .player-label { top: 6px; right: 6px; color: var(--p2-text); background: var(--p2-soft); } +.p3 .player-label { bottom: 6px; left: 6px; color: var(--p3-text); background: var(--p3-soft); } +.p4 .player-label { bottom: 6px; right: 6px; color: var(--p4-text); background: var(--p4-soft); } + +/* Life counters */ +.life-counter { + position: absolute; + z-index: 20; + display: flex; + align-items: center; + gap: 4px; + font-family: 'Cinzel', serif; + font-size: 18px; + font-weight: 700; +} + +.life-counter button { + width: 20px; height: 20px; + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.04); + color: rgba(255,255,255,0.45); + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Inter', sans-serif; + line-height: 1; +} + +.life-counter button:hover { + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.8); +} + +.p1 .life-counter { top: 6px; right: 6px; color: var(--p1-text); } +.p2 .life-counter { top: 6px; left: 6px; color: var(--p2-text); } +.p3 .life-counter { bottom: 6px; right: 6px; color: var(--p3-text); } +.p4 .life-counter { bottom: 6px; left: 6px; color: var(--p4-text); } + +.life-counter-value { + cursor: pointer; + transition: transform 0.15s; +} +.life-counter-value:hover { transform: scale(1.1); } + +/* Poison counter badge */ +.poison-badge { + position: absolute; + z-index: 21; + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + border-radius: var(--radius-full); + cursor: pointer; +} +.p1 .poison-badge { top: 30px; right: 6px; color: #c45fd6; background: rgba(142,68,173,0.2); border: 1px solid rgba(142,68,173,0.4); } +.p2 .poison-badge { top: 30px; left: 6px; color: #c45fd6; background: rgba(142,68,173,0.2); border: 1px solid rgba(142,68,173,0.4); } +.p3 .poison-badge { bottom: 30px; right: 6px; color: #c45fd6; background: rgba(142,68,173,0.2); border: 1px solid rgba(142,68,173,0.4); } +.p4 .poison-badge { bottom: 30px; left: 6px; color: #c45fd6; background: rgba(142,68,173,0.2); border: 1px solid rgba(142,68,173,0.4); } + +/* Zone rows */ +.zone-row { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.p1 .zone-row, .p3 .zone-row { flex-direction: row; } +.p2 .zone-row, .p4 .zone-row { flex-direction: row-reverse; } + +/* Commander row */ +.commander-row { + display: flex; + flex-shrink: 0; + margin: 3px 0; +} +.p1 .commander-row, .p3 .commander-row { justify-content: flex-start; } +.p2 .commander-row, .p4 .commander-row { justify-content: flex-end; } + +/* Battle zone */ +.battle-zone { + flex: 1; + position: relative; + border-radius: var(--zone-radius); + min-height: 40px; + overflow: hidden; +} + +.battle-zone-inner { + position: absolute; + inset: 4px; + border-radius: var(--zone-radius); + border: 1px dashed; + opacity: 0.12; + pointer-events: none; +} +.p1 .battle-zone-inner { border-color: var(--p1); } +.p2 .battle-zone-inner { border-color: var(--p2); } +.p3 .battle-zone-inner { border-color: var(--p3); } +.p4 .battle-zone-inner { border-color: var(--p4); } + +.battle-zone-label { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + font-family: 'Cinzel', serif; + font-size: 10px; + letter-spacing: 4px; + text-transform: uppercase; + opacity: 0.05; + pointer-events: none; + white-space: nowrap; + color: #fff; +} + +/* Zone elements */ +.zone { + position: relative; + border-radius: var(--zone-radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + border: 1px solid rgba(255,255,255,0.05); + background: rgba(0,0,0,0.18); + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s; +} + +.zone:hover { + border-color: rgba(255,255,255,0.15); + background: rgba(0,0,0,0.3); +} + +.zone-icon { + font-size: 14px; + opacity: 0.55; + margin-bottom: 2px; + pointer-events: none; +} + +.zone-name { + font-size: 7px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255,255,255,0.3); + pointer-events: none; +} + +.zone.library, .zone.exile, .zone.graveyard { + width: var(--card-w); + height: var(--card-h); +} + +.zone.hand { + flex: 1; + height: var(--card-h); + min-width: 60px; + flex-direction: row; + gap: 3px; + overflow: hidden; +} + +.zone.hand .zone-icon { margin-bottom: 0; margin-right: 3px; } + +.zone.commander { + width: calc(var(--card-w) + 12px); + height: calc(var(--card-h) + 5px); + border-width: 2px; +} + +/* Commander card image in zone */ +.zone.commander img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: calc(var(--zone-radius) - 2px); +} + +/* Player-colored zone borders */ +.p1 .zone { border-color: rgba(192,57,43,0.12); } +.p1 .zone:hover { border-color: rgba(192,57,43,0.3); box-shadow: 0 0 10px var(--p1-soft); } +.p1 .zone.commander { border-color: rgba(192,57,43,0.3); background: var(--p1-soft); } +.p1 .zone-icon { color: var(--p1-text); } + +.p2 .zone { border-color: rgba(41,128,185,0.12); } +.p2 .zone:hover { border-color: rgba(41,128,185,0.3); box-shadow: 0 0 10px var(--p2-soft); } +.p2 .zone.commander { border-color: rgba(41,128,185,0.3); background: var(--p2-soft); } +.p2 .zone-icon { color: var(--p2-text); } + +.p3 .zone { border-color: rgba(39,174,96,0.12); } +.p3 .zone:hover { border-color: rgba(39,174,96,0.3); box-shadow: 0 0 10px var(--p3-soft); } +.p3 .zone.commander { border-color: rgba(39,174,96,0.3); background: var(--p3-soft); } +.p3 .zone-icon { color: var(--p3-text); } + +.p4 .zone { border-color: rgba(212,147,13,0.12); } +.p4 .zone:hover { border-color: rgba(212,147,13,0.3); box-shadow: 0 0 10px var(--p4-soft); } +.p4 .zone.commander { border-color: rgba(212,147,13,0.3); background: var(--p4-soft); } +.p4 .zone-icon { color: var(--p4-text); } + +/* Card count in zone */ +.card-count { + position: absolute; + top: 3px; right: 3px; + font-size: 8px; + font-weight: 600; + color: rgba(255,255,255,0.35); + background: rgba(0,0,0,0.4); + padding: 1px 4px; + border-radius: 8px; + line-height: 1.2; +} + +/* Hand card thumbnails inside hand zone */ +.hand-thumb { + width: 32px; + height: 45px; + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; + cursor: pointer; + border: 1px solid rgba(255,255,255,0.1); + transition: transform 0.15s, border-color 0.15s; +} + +.hand-thumb:hover { + transform: translateY(-4px); + border-color: rgba(255,255,255,0.3); +} + +.hand-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +/* Zone tooltip */ +.zone[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 5px); + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,0.9); + color: #ccc; + font-size: 10px; + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; + z-index: 50; + pointer-events: none; + font-family: 'Inter', sans-serif; + font-weight: 600; + text-transform: none; +} + +/* P3/P4 tooltip above (at bottom of screen) */ +.p3 .zone[data-tooltip]:hover::after, +.p4 .zone[data-tooltip]:hover::after { + bottom: auto; + top: calc(100% + 5px); +} + +/* Corner accents */ +.corner-accent { + position: absolute; + width: 20px; height: 20px; + z-index: 6; + pointer-events: none; + opacity: 0.12; +} +.corner-accent.tl { top: 50px; left: 6px; border-top: 2px solid var(--p1); border-left: 2px solid var(--p1); } +.corner-accent.tr { top: 50px; right: 6px; border-top: 2px solid var(--p2); border-right: 2px solid var(--p2); } +.corner-accent.bl { bottom: 6px; left: 6px; border-bottom: 2px solid var(--p3); border-left: 2px solid var(--p3); } +.corner-accent.br { bottom: 6px; right: 6px; border-bottom: 2px solid var(--p4); border-right: 2px solid var(--p4); } + +/* ============================================================ + BATTLEFIELD CARDS + ============================================================ */ +.battlefield-card { + position: absolute; + width: var(--bf-card-w); + height: var(--bf-card-h); + border-radius: 6px; + border: 2px solid transparent; + cursor: grab; + z-index: 1; + overflow: hidden; + user-select: none; + transition: box-shadow 0.15s, transform 0.15s; +} + +.battlefield-card:active { cursor: grabbing; z-index: 100; } + +.battlefield-card:hover { + z-index: 50; + box-shadow: var(--shadow-lg); + transform: scale(1.05); +} + +.battlefield-card img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + border-radius: 4px; +} + +.battlefield-card.tapped { + transform: rotate(90deg); +} + +.battlefield-card.tapped:hover { + transform: rotate(90deg) scale(1.05); +} + +.battlefield-card.face-down img { filter: brightness(0.2); } + +.battlefield-card .card-owner-bar { + position: absolute; + bottom: 0; left: 0; right: 0; + height: 3px; +} + +/* Counter badge */ +.card-counter-badge { + position: absolute; + bottom: 4px; right: 4px; + background: rgba(0,0,0,0.8); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + font-size: 9px; + font-weight: 700; + color: #fff; + padding: 1px 4px; + line-height: 1.3; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.card-counter-pip { + display: flex; + align-items: center; + gap: 2px; +} + +/* Damage badge */ +.damage-badge { + position: absolute; + top: 3px; left: 3px; + background: rgba(211,32,42,0.85); + border-radius: 50%; + width: 18px; height: 18px; + font-size: 10px; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +/* Attacker/blocker highlights */ +.battlefield-card.declared-attacker { + border-color: rgba(211,32,42,0.8); + box-shadow: 0 0 12px rgba(211,32,42,0.4); +} + +.battlefield-card.declared-blocker { + border-color: rgba(91,141,217,0.8); + box-shadow: 0 0 12px rgba(91,141,217,0.4); +} + +/* Summoning sick overlay */ +.battlefield-card.summoning-sick::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 3px, + rgba(0,0,0,0.15) 3px, + rgba(0,0,0,0.15) 6px + ); + border-radius: 4px; + pointer-events: none; + z-index: 2; +} + +/* Monarch badge */ +.monarch-badge { + position: absolute; + top: -6px; left: 50%; + transform: translateX(-50%); + font-size: 12px; + z-index: 10; + pointer-events: none; + text-shadow: 0 0 6px rgba(200,168,67,0.8); +} + +/* Protection badge */ +.protection-badge { + position: absolute; + top: 3px; right: 3px; + font-size: 10px; + background: rgba(0,0,0,0.6); + border-radius: 3px; + padding: 1px 3px; + pointer-events: none; +} + +/* Stack badges */ +.stack-badge { + position: absolute; + top: -6px; right: -6px; + background: var(--color-primary); + color: #fff; + font-size: 9px; + font-weight: 700; + padding: 2px 4px; + border-radius: var(--radius-full); + z-index: 10; + pointer-events: none; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +.battlefield-card.stack-leader { + box-shadow: 3px 3px 0 1px rgba(0,0,0,0.45), 6px 6px 0 1px rgba(0,0,0,0.3); +} + +.battlefield-card.stacked-hidden { display: none; } +.battlefield-card.stack-fanned { display: block; opacity: 1; z-index: 60; } + +/* Equip / aura indicators */ +.battlefield-card.attached-equipment::after { + content: 'EQ'; + position: absolute; + top: 2px; right: 2px; + font-size: 8px; + background: rgba(200,168,67,0.8); + color: #000; + border-radius: 3px; + padding: 1px 3px; + font-weight: 700; + pointer-events: none; + z-index: 10; +} + +.battlefield-card.attached-aura { + outline: 2px solid rgba(160,100,220,0.7); +} + +/* Equip pick mode */ +body.equip-pick-mode .battlefield-card { + cursor: crosshair; + outline: 2px dashed rgba(200,168,67,0.6); + outline-offset: 2px; +} + +body.stack-pick-mode .battlefield-card { + cursor: crosshair; + outline: 2px dashed var(--color-primary); + outline-offset: 2px; +} + +/* Floating damage numbers */ +.floating-damage { + position: absolute; + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + pointer-events: none; + z-index: 200; + text-shadow: 0 0 8px currentColor; + animation: float-damage 1.2s ease-out forwards; +} + +@keyframes float-damage { + 0% { opacity: 1; transform: translateY(0) scale(1); } + 100% { opacity: 0; transform: translateY(-50px) scale(0.6); } +} + +/* ============================================================ + COMBAT SVG OVERLAY + ============================================================ */ +#combat-overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 30; +} + +/* ============================================================ + CARD SEARCH SIDEBAR (collapsible, right side) + ============================================================ */ +#card-search-sidebar { + position: absolute; + top: 44px; right: 0; bottom: 0; + width: 260px; + z-index: 40; + background: rgba(10,10,20,0.95); + border-left: 1px solid var(--felt-line-strong); + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.3s var(--ease-out-expo); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +#card-search-sidebar.collapsed { width: 0; } + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.sidebar-title { + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + color: var(--color-text); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sidebar-toggle { + width: 22px; height: 22px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; +} + +.sidebar-toggle:hover { background: rgba(255,255,255,0.08); color: var(--color-text); } + +.search-input-wrap { + padding: 8px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.search-input { + width: 100%; + background: rgba(255,255,255,0.05); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 6px 10px; + color: var(--color-text); + font-size: var(--text-sm); + outline: none; +} + +.search-input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(91,141,217,0.15); +} + +.search-input::placeholder { color: var(--color-text-faint); } + +.search-status { + padding: 4px 10px; + font-size: 10px; + color: var(--color-text-muted); + flex-shrink: 0; +} + +.card-results { + flex: 1; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.search-card-item { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + position: relative; +} + +.search-card-item:hover { + border-color: var(--color-primary); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.search-card-item img { + width: 100%; + aspect-ratio: 63 / 88; + object-fit: cover; + display: block; +} + +.search-card-info { + padding: 5px 8px; +} + +.search-card-name { + font-size: 11px; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-card-type { + font-size: 10px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-card-add-btn { + position: absolute; + top: 6px; right: 6px; + width: 26px; height: 26px; + background: rgba(91,141,217,0.85); + border-radius: var(--radius-sm); + color: white; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + font-weight: 700; +} + +.search-card-item:hover .search-card-add-btn { opacity: 1; } + +/* Sidebar toggle tab */ +.sidebar-edge-tab { + position: absolute; + top: 50%; right: 260px; + transform: translateY(-50%); + z-index: 41; + width: 18px; height: 44px; + background: rgba(10,10,20,0.95); + border: 1px solid var(--felt-line-strong); + border-right: none; + border-radius: 4px 0 0 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-faint); + font-size: 10px; + cursor: pointer; + transition: right 0.3s var(--ease-out-expo), color 0.15s; +} + +.sidebar-edge-tab:hover { color: var(--color-text); } +#card-search-sidebar.collapsed ~ .sidebar-edge-tab { right: 0; } + +/* ============================================================ + CONTEXT MENU + ============================================================ */ +#context-menu { + position: fixed; + background: var(--color-surface-raised); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + padding: 6px; + box-shadow: var(--shadow-lg); + z-index: 1000; + min-width: 175px; + display: none; +} + +#context-menu.visible { display: block; } + +.context-menu-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: var(--radius-md); + font-size: 12px; + color: var(--color-text); + width: 100%; + text-align: left; +} + +.context-menu-item:hover { + background: rgba(91,141,217,0.12); + color: var(--color-primary); +} + +.context-menu-item.danger:hover { + background: rgba(211,32,42,0.12); + color: var(--color-error); +} + +.context-menu-divider { + height: 1px; + background: var(--color-divider); + margin: 4px 0; +} + +.context-menu-counter-row { + display: flex; + gap: 4px; + padding: 4px 10px; +} + +.context-menu-counter-btn { + flex: 1; + padding: 4px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 700; + border: 1px solid var(--color-border); + background: var(--color-surface-3); + color: var(--color-text); + cursor: pointer; +} + +.context-menu-counter-btn.plus { border-color: rgba(46,160,67,0.4); color: var(--color-success); } +.context-menu-counter-btn.plus:hover { background: rgba(46,160,67,0.15); } +.context-menu-counter-btn.minus { border-color: rgba(211,32,42,0.4); color: var(--color-error); } +.context-menu-counter-btn.minus:hover { background: rgba(211,32,42,0.15); } + +/* ============================================================ + FLOATING CARD PREVIEW + ============================================================ */ +#card-preview { + position: fixed; + pointer-events: none; + z-index: 9990; + display: none; + filter: drop-shadow(0 8px 32px rgba(0,0,0,0.7)); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + width: 220px; +} + +#card-preview img { + width: 100%; + display: block; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.card-preview-info { + padding: 6px 8px 8px; +} + +.card-preview-name { + font-family: var(--font-display); + font-size: 12px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 2px; +} + +.card-preview-type { + font-size: 10px; + color: var(--color-text-muted); + margin-bottom: 3px; +} + +.card-preview-oracle { + font-size: 10px; + color: var(--color-text); + line-height: 1.4; + max-height: 100px; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; +} + +.card-preview-pt { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: var(--color-text); + text-align: right; + margin-top: 3px; + padding-top: 3px; + border-top: 1px solid var(--color-border); +} + +.card-preview-pt-overlay { + display: none; + position: absolute; + bottom: 8px; left: 6px; + font-family: var(--font-display); + font-size: 15px; + font-weight: 900; + color: #fff; + background: rgba(0,0,0,0.75); + border: 2px solid rgba(255,255,255,0.3); + border-radius: 5px; + padding: 2px 8px; + z-index: 5; +} + +.mana-symbol { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; height: 14px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + line-height: 1; +} + +/* ============================================================ + TRIGGER STACK PANEL + ============================================================ */ +#trigger-stack-panel { + position: fixed; + top: 50px; right: 0; + width: 400px; + max-width: 90vw; + max-height: calc(100vh - 60px); + z-index: 700; + display: flex; + flex-direction: column; + pointer-events: none; + opacity: 0; + transform: translateX(100%); + transition: opacity 0.3s var(--ease-out-expo), transform 0.3s var(--ease-out-expo); +} + +#trigger-stack-panel.visible { + opacity: 1; + transform: translateX(0); + pointer-events: all; +} + +.trigger-stack-card { + background: var(--color-surface-2); + border: 1px solid rgba(200,168,67,0.3); + border-right: none; + border-radius: var(--radius-xl) 0 0 var(--radius-xl); + display: flex; + flex-direction: column; + max-height: calc(100vh - 60px); + box-shadow: -4px 0 24px rgba(0,0,0,0.5); + overflow: hidden; +} + +.trigger-stack-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid rgba(200,168,67,0.2); + flex-shrink: 0; + background: rgba(200,168,67,0.05); +} + +.trigger-stack-title { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: var(--mtg-gold); + letter-spacing: 0.06em; + text-transform: uppercase; + flex: 1; + margin: 0; +} + +.trigger-stack-body { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 60px; +} + +.trigger-stack-empty { + color: var(--color-text-faint); + font-size: 11px; + text-align: center; + padding: 20px; + font-style: italic; +} + +.trigger-item { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px 10px; + display: flex; + align-items: flex-start; + gap: 8px; + cursor: grab; + position: relative; +} + +.trigger-item:hover { border-color: rgba(200,168,67,0.4); } +.trigger-item:first-child { border-color: rgba(200,168,67,0.5); background: rgba(200,168,67,0.05); } + +.trigger-type-icon { + width: 24px; height: 24px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; +} + +.trigger-item-body { flex: 1; min-width: 0; } + +.trigger-item-title { + font-size: 12px; + font-weight: 700; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 2px; +} + +.trigger-item-type { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-faint); + margin-bottom: 3px; +} + +.trigger-item-text { + font-size: 11px; + color: var(--color-text-muted); + line-height: 1.4; + font-style: italic; +} + +.trigger-item-dismiss { + position: absolute; + top: 4px; right: 4px; + width: 16px; height: 16px; + border-radius: var(--radius-sm); + font-size: 10px; + color: var(--color-text-faint); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; +} + +.trigger-item:hover .trigger-item-dismiss { opacity: 1; } +.trigger-item-dismiss:hover { background: rgba(211,32,42,0.15); color: var(--color-error); } + +.trigger-stack-actions { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid rgba(200,168,67,0.15); + flex-shrink: 0; + flex-wrap: wrap; +} + +.btn-resolve { + flex: 1; + padding: 5px 8px; + background: rgba(200,168,67,0.12); + border: 1px solid rgba(200,168,67,0.35); + border-radius: var(--radius-md); + color: var(--mtg-gold); + font-size: 11px; + font-weight: 700; +} + +.btn-resolve:hover { background: rgba(200,168,67,0.22); } + +.btn-resolve-all { + flex: 1; + padding: 5px 8px; + background: rgba(200,168,67,0.06); + border: 1px solid rgba(200,168,67,0.2); + border-radius: var(--radius-md); + color: rgba(200,168,67,0.7); + font-size: 11px; + font-weight: 700; +} + +.btn-resolve-all:hover { background: rgba(200,168,67,0.15); color: var(--mtg-gold); } + +/* ============================================================ + ZONE MODAL + ============================================================ */ +#zone-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(4px); +} + +#zone-modal.visible { + opacity: 1; + pointer-events: all; +} + +.zone-modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(780px, 94vw); + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); +} + +.zone-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.zone-modal-title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + color: var(--color-text); +} + +.btn-close { + width: 28px; height: 28px; + border-radius: var(--radius-md); + color: var(--color-text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.btn-close:hover { color: var(--color-text); background: var(--color-surface-3); } + +.zone-modal-tabs { + display: flex; + gap: 2px; + padding: 10px 16px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.zone-tab { + padding: 5px 14px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 600; + color: var(--color-text-muted); +} + +.zone-tab:hover { background: var(--color-surface-3); color: var(--color-text); } +.zone-tab.active { background: rgba(91,141,217,0.15); color: var(--color-primary); } + +.zone-modal-body { + flex: 1; + overflow-y: auto; + padding: 14px 18px; +} + +.zone-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} + +.zone-card-thumb { + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--color-border); + cursor: pointer; + position: relative; +} + +.zone-card-thumb:hover { + border-color: var(--color-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.zone-card-thumb img { + width: 100%; + aspect-ratio: 63/88; + object-fit: cover; +} + +.zone-empty { + color: var(--color-text-faint); + font-size: var(--text-sm); + text-align: center; + padding: 32px 0; + font-style: italic; +} + +/* ============================================================ + DICE MODAL + ============================================================ */ +#dice-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + z-index: 600; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(6px); +} + +#dice-modal.visible { opacity: 1; pointer-events: all; } + +.dice-modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 2rem 2.5rem; + text-align: center; + box-shadow: var(--shadow-lg); + min-width: 260px; +} + +.dice-result-display { + font-family: var(--font-display); + font-size: var(--text-hero); + font-weight: 700; + color: var(--color-text); + line-height: 1; + margin: 1rem 0; + transition: transform 0.1s; +} + +.dice-label { + font-family: var(--font-display); + font-size: var(--text-sm); + color: var(--color-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 1.5rem; +} + +.dice-buttons { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.btn-dice { + padding: 8px 16px; + background: var(--color-surface-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + color: var(--color-text); + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 700; +} + +.btn-dice:hover { + background: rgba(91,141,217,0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ============================================================ + TOKEN MODAL + ============================================================ */ +#token-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + z-index: 550; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(4px); +} + +#token-modal.visible { opacity: 1; pointer-events: all; } + +.token-modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(700px, 95vw); + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +/* ============================================================ + SCRY MODAL + ============================================================ */ +#scry-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + z-index: 550; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(4px); +} + +#scry-modal.visible { opacity: 1; pointer-events: all; } + +.scry-modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(640px, 95vw); + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +/* ============================================================ + DECK MODAL + ============================================================ */ +#deck-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + z-index: 600; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(4px); +} + +#deck-modal.visible { opacity: 1; pointer-events: all; } + +.deck-modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(700px, 95vw); + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +/* ============================================================ + COUNTER PICKER + ============================================================ */ +#counter-picker { + position: fixed; + background: var(--color-surface-raised); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 12px; + z-index: 800; + width: 220px; + box-shadow: var(--shadow-lg); + display: none; +} + +#counter-picker.visible { display: block; } + +.counter-picker-header { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; +} + +.counter-picker-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + margin-bottom: 8px; +} + +.counter-picker-btn { + padding: 5px 8px; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 600; + border: 1px solid var(--color-border); + background: var(--color-surface-3); + color: var(--color-text); + text-align: left; + cursor: pointer; +} + +.counter-picker-btn:hover { + background: rgba(91,141,217,0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ============================================================ + MANA COLOR PICKER OVERLAY + ============================================================ */ +.mana-color-picker-overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); +} + +.mana-color-picker-panel { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 1.5rem; + box-shadow: var(--shadow-lg); +} + +.mana-picker-title { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 700; + color: var(--color-text); + text-align: center; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.mana-picker-buttons { + display: flex; + gap: 8px; + justify-content: center; +} + +.mana-picker-btn { + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 700; + border: none; + cursor: pointer; + min-width: 60px; +} + +/* ============================================================ + SBA ALERT PANEL + ============================================================ */ +.sba-alert-panel { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 800; + background: var(--color-surface-2); + border: 1px solid rgba(200,168,67,0.4); + border-radius: var(--radius-xl); + padding: 12px; + box-shadow: var(--shadow-lg), 0 0 20px rgba(200,168,67,0.1); + min-width: 300px; + max-width: 90vw; +} + +.sba-alert-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + font-family: var(--font-display); + font-size: 12px; + font-weight: 700; + color: var(--mtg-gold); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.sba-alert-close { + width: 20px; height: 20px; + border-radius: var(--radius-sm); + color: var(--color-text-faint); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.sba-alert-body { display: flex; flex-direction: column; gap: 8px; } + +.sba-alert-item { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px 10px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.sba-alert-msg { flex: 1; font-size: 11px; color: var(--color-text); } + +.sba-alert-btn { + padding: 3px 10px; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 700; + border: 1px solid; + cursor: pointer; +} + +.sba-alert-btn.eliminate { + background: rgba(211,32,42,0.15); + border-color: rgba(211,32,42,0.4); + color: var(--color-error); +} + +.sba-alert-btn.dismiss { + background: rgba(255,255,255,0.05); + border-color: var(--color-border); + color: var(--color-text-muted); +} + +/* ============================================================ + PRIORITY WINDOW + ============================================================ */ +#priority-banner { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 45; + background: rgba(10,10,20,0.95); + border: 1px solid rgba(91,141,217,0.4); + border-radius: var(--radius-xl); + padding: 8px 16px; + display: none; + align-items: center; + gap: 10px; + box-shadow: var(--shadow-md), 0 0 20px rgba(91,141,217,0.15); + backdrop-filter: blur(8px); + min-width: 280px; +} + +#priority-banner.visible { display: flex; } + +.priority-text { + font-size: 12px; + color: var(--color-text-muted); + flex: 1; +} + +.btn-priority-pass { + padding: 5px 14px; + background: rgba(91,141,217,0.2); + border: 1px solid rgba(91,141,217,0.4); + border-radius: var(--radius-md); + color: var(--color-primary); + font-size: 12px; + font-weight: 700; +} + +.btn-priority-pass:hover { background: rgba(91,141,217,0.35); } + +/* ============================================================ + AI THOUGHT BUBBLE + ============================================================ */ +.ai-thought-bubble { + position: absolute; + z-index: 25; + background: rgba(10,10,20,0.9); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + padding: 6px 10px; + font-size: 10px; + color: var(--color-text-muted); + max-width: 160px; + pointer-events: none; + font-style: italic; + backdrop-filter: blur(4px); +} + +/* ============================================================ + MISC — Toast, Spinners, etc. + ============================================================ */ +.toast-message { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 1500; + background: var(--color-surface-raised); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + padding: 8px 16px; + font-size: 12px; + color: var(--color-text); + box-shadow: var(--shadow-md); + animation: toast-in 0.3s var(--ease-out-expo); +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +.spinner { + width: 18px; height: 18px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.search-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +/* Shortcut hint */ +.shortcut-hint { + position: fixed; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + background: rgba(10,10,20,0.9); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + padding: 5px 14px; + font-size: 11px; + color: var(--color-text-muted); + pointer-events: none; + opacity: 0; + z-index: 60; + backdrop-filter: blur(6px); + white-space: nowrap; +} + +.shortcut-hint.show { opacity: 1; } + +kbd { + background: var(--color-surface-raised); + border: 1px solid var(--color-border-strong); + border-radius: 3px; + padding: 1px 6px; + font-size: 10px; + font-family: monospace; + color: var(--color-text); +} + +/* Floating log entries */ +.floating-log { + position: absolute; + z-index: 45; + bottom: 50px; + left: 8px; + right: 8px; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 3px; + align-items: flex-start; + max-height: 120px; + overflow: hidden; +} + +.floating-log-entry { + background: rgba(10,10,20,0.88); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 3px 8px; + font-size: 10px; + color: var(--color-text-muted); + max-width: 300px; + animation: log-float-in 0.3s var(--ease-out-expo); +} + +@keyframes log-float-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ============================================================ + LARGE MODALS (save/load, collection, deckbuilder, etc.) + ============================================================ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + z-index: 600; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + backdrop-filter: blur(4px); +} + +.modal-overlay.visible { + opacity: 1; + pointer-events: all; +} + +.modal-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(800px, 95vw); + max-height: 88vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.modal-title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + color: var(--color-text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +.modal-footer { + display: flex; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +/* ============================================================ + COMMON BUTTONS + ============================================================ */ +.btn { + padding: 6px 14px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 600; + font-family: var(--font-body); + border: 1px solid; + cursor: pointer; +} + +.btn-primary { + background: rgba(91,141,217,0.2); + border-color: var(--color-primary); + color: var(--color-primary); +} +.btn-primary:hover { background: rgba(91,141,217,0.35); } + +.btn-danger { + background: rgba(211,32,42,0.15); + border-color: rgba(211,32,42,0.4); + color: var(--color-error); +} +.btn-danger:hover { background: rgba(211,32,42,0.3); } + +.btn-gold { + background: rgba(200,168,67,0.15); + border-color: rgba(200,168,67,0.4); + color: var(--mtg-gold); +} +.btn-gold:hover { background: rgba(200,168,67,0.25); } + +.btn-cancel { + background: var(--color-surface-3); + border-color: var(--color-border); + color: var(--color-text-muted); +} +.btn-cancel:hover { background: var(--color-surface-raised); color: var(--color-text); } + +/* Input styles */ +.game-input { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 6px 10px; + color: var(--color-text); + font-size: 12px; + outline: none; + width: 100%; +} + +.game-input:focus { border-color: var(--color-primary); } +.game-input::placeholder { color: var(--color-text-faint); } + +/* Textarea */ +.game-textarea { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px 10px; + color: var(--color-text); + font-size: 12px; + outline: none; + width: 100%; + resize: vertical; + font-family: 'Inter', monospace; + line-height: 1.5; +} + +.game-textarea:focus { border-color: var(--color-primary); } + +/* ============================================================ + COMMANDER DAMAGE MATRIX MODAL + ============================================================ */ +#cmd-dmg-matrix-modal { + position: fixed; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + z-index: 700; + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 20px; + box-shadow: var(--shadow-lg); + display: none; + min-width: 300px; +} + +#cmd-dmg-matrix-modal.visible { display: block; } + +.cdm-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.cdm-table { + border-collapse: collapse; + font-size: 12px; +} + +.cdm-table th, .cdm-table td { + padding: 6px 10px; + border: 1px solid var(--color-border); + text-align: center; +} + +.cdm-corner { background: var(--color-surface); } +.cdm-col-header { font-weight: 700; font-size: 11px; background: rgba(255,255,255,0.03); } +.cdm-row-header { font-weight: 700; font-size: 11px; text-align: left; background: rgba(255,255,255,0.03); } +.cdm-self { background: rgba(0,0,0,0.2); color: var(--color-text-faint); } +.cdm-cell { cursor: pointer; font-weight: 700; } +.cdm-cell:hover { background: rgba(255,255,255,0.08); } +.cdm-red { color: #e74c3c; background: rgba(211,32,42,0.1); } +.cdm-orange { color: #e67e22; background: rgba(200,120,34,0.08); } +.cdm-yellow { color: #f1c40f; } +.cdm-green { color: var(--color-success); } +.cdm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; vertical-align: middle; } +.cdm-hint { font-size: 10px; color: var(--color-text-faint); margin-bottom: 8px; } +.cdm-grid-wrap { overflow-x: auto; } + +/* ============================================================ + SAVE / LOAD MODAL + ============================================================ */ +#save-load-modal { z-index: 650; } + +/* ============================================================ + DECK VIEW (Library browser) + ============================================================ */ +#deck-view-modal { z-index: 620; } + +/* ============================================================ + MANA SHORTFALL WARNING + ============================================================ */ +.mana-shortfall-modal { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 850; + background: rgba(211,32,42,0.15); + border: 1px solid rgba(211,32,42,0.4); + border-radius: var(--radius-lg); + padding: 10px 16px; + font-size: 12px; + color: var(--color-error); + box-shadow: var(--shadow-md); + display: none; +} + +.mana-shortfall-modal.visible { display: flex; gap: 10px; align-items: center; } + +/* ============================================================ + UNDO HISTORY PANEL + ============================================================ */ +#undo-history-panel { + position: fixed; + top: 50%; right: 10px; + transform: translateY(-50%); + z-index: 700; + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: 260px; + max-height: 60vh; + display: none; + flex-direction: column; + box-shadow: var(--shadow-lg); +} + +#undo-history-panel.visible { display: flex; } + +/* ============================================================ + COLLECTION / DECKBUILDER (same large modal as original) + ============================================================ */ +#collection-modal, #deckbuilder-modal { + z-index: 630; +} + +/* ============================================================ + ANIMATIONS + ============================================================ */ +@keyframes modal-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes life-flash-gain { + 0% { background: transparent; } + 30% { background: rgba(46,204,113,0.2); } + 100% { background: transparent; } +} + +@keyframes life-flash-loss { + 0% { background: transparent; } + 30% { background: rgba(231,76,60,0.2); } + 100% { background: transparent; } +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 768px) { + :root { + --card-w: 42px; + --card-h: 58px; + --bf-card-w: 72px; + --bf-card-h: 100px; + } + .player-label { font-size: 7px; letter-spacing: 1px; padding: 1px 5px; } + .life-counter { font-size: 14px; } + .zone-name { font-size: 6px; } + .zone-icon { font-size: 11px; } + .center-emblem { width: 44px; height: 44px; } + .center-emblem svg { width: 24px; height: 24px; } + .battle-zone-label { font-size: 7px; letter-spacing: 2px; } + #card-search-sidebar { width: 220px; } +} + +/* ============================================================ + AI CAST POPUP + ============================================================ */ +.ai-cast-popup { + position: absolute; + z-index: 26; + background: rgba(10,10,20,0.92); + border: 1px solid rgba(91,141,217,0.3); + border-radius: var(--radius-lg); + padding: 8px 12px; + font-size: 11px; + color: var(--color-text-muted); + pointer-events: none; + max-width: 180px; + backdrop-filter: blur(4px); +} + +.ai-badge { + display: inline-block; + background: rgba(91,141,217,0.2); + border: 1px solid rgba(91,141,217,0.4); + border-radius: var(--radius-sm); + padding: 1px 5px; + font-size: 9px; + font-weight: 700; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-left: 4px; +} + +/* ============================================================ + ACTIVATED ABILITY OVERLAY + ============================================================ */ +.activated-ability-overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.65); + backdrop-filter: blur(4px); +} + +.activated-ability-dialog { + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 16px; + width: min(440px, 94vw); + box-shadow: var(--shadow-lg); +} + +.aa-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.aa-header-title { + font-family: var(--font-display); + font-size: 13px; + font-weight: 700; + color: var(--color-text); +} + +.aa-close-btn { + width: 22px; height: 22px; + border-radius: var(--radius-sm); + color: var(--color-text-faint); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.aa-item { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px 10px; + margin-bottom: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.aa-cost-line { + font-size: 12px; + font-weight: 700; + color: var(--mtg-gold); + font-family: var(--font-display); +} + +.aa-effect-line { + font-size: 11px; + color: var(--color-text-muted); + line-height: 1.4; +} + +.aa-activate-btn { + padding: 4px 12px; + background: rgba(200,168,67,0.15); + border: 1px solid rgba(200,168,67,0.35); + border-radius: var(--radius-sm); + color: var(--mtg-gold); + font-size: 11px; + font-weight: 700; + align-self: flex-start; + cursor: pointer; +} + +.aa-activate-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.aa-activate-btn:hover:not(:disabled) { background: rgba(200,168,67,0.25); } + +.aa-disabled { opacity: 0.5; } + +/* ============================================================ + PLANESWALKER ABILITIES PANEL + ============================================================ */ +.planeswalker-panel { + position: fixed; + z-index: 800; + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 14px; + width: min(320px, 94vw); + box-shadow: var(--shadow-lg); +} + +/* ============================================================ + COMBAT RECAP + ============================================================ */ +.combat-recap { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 800; + background: rgba(10,10,20,0.95); + border: 1px solid rgba(211,32,42,0.3); + border-radius: var(--radius-xl); + padding: 12px 18px; + font-size: 11px; + color: var(--color-text-muted); + max-width: min(400px, 94vw); + box-shadow: var(--shadow-md); +} + +/* ============================================================ + LEGAL ACTIONS BAR + ============================================================ */ +#legal-actions-bar { + position: absolute; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + z-index: 44; + display: none; + gap: 6px; + align-items: center; + background: rgba(10,10,20,0.95); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + padding: 6px 12px; + backdrop-filter: blur(6px); +} + +#legal-actions-bar.visible { display: flex; } + +.legal-action-btn { + padding: 4px 12px; + border-radius: var(--radius-md); + font-size: 11px; + font-weight: 700; + border: 1px solid var(--color-border); + background: var(--color-surface-3); + color: var(--color-text); +} + +.legal-action-btn:hover { + background: rgba(91,141,217,0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ============================================================ + DISCARD PROMPT + ============================================================ */ +#discard-prompt { + position: fixed; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + z-index: 800; + background: rgba(10,10,20,0.96); + border: 1px solid rgba(211,32,42,0.4); + border-radius: var(--radius-xl); + padding: 12px 18px; + font-size: 12px; + color: var(--color-text); + box-shadow: var(--shadow-md); + display: none; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +#discard-prompt.visible { display: flex; } + +/* ============================================================ + PRECON BROWSER / DECK SELECTION + ============================================================ */ +.precon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + padding: 10px; +} + +.precon-card { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 12px; + cursor: pointer; +} + +.precon-card:hover { + border-color: var(--color-primary); + background: rgba(91,141,217,0.08); +} + +.precon-card-name { + font-family: var(--font-display); + font-size: 12px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 4px; +} + +.precon-card-commander { + font-size: 11px; + color: var(--color-text-muted); +} + +/* ============================================================ + SCROLLBARS + ============================================================ */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--color-border-strong); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--color-text-faint); } + +/* ============================================================ + UNIFIED STACK PANEL (spell stack) + ============================================================ */ +#unified-stack-panel { + position: fixed; + bottom: 50px; + right: 10px; + z-index: 500; + background: var(--color-surface-2); + border: 1px solid rgba(91,141,217,0.3); + border-radius: var(--radius-xl); + width: 220px; + max-height: 320px; + display: none; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +#unified-stack-panel.visible { display: flex; } + +.unified-stack-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.unified-stack-body { + flex: 1; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stack-item { + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 5px 8px; + font-size: 11px; + color: var(--color-text); + display: flex; + align-items: center; + gap: 6px; +} + +.stack-item.top-of-stack { border-color: rgba(91,141,217,0.5); background: rgba(91,141,217,0.08); } + +/* ============================================================ + SETTINGS PANEL + ============================================================ */ +#settings-panel { + position: fixed; + top: 50%; right: 10px; + transform: translateY(-50%); + z-index: 700; + background: var(--color-surface-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + width: min(400px, 94vw); + max-height: 80vh; + display: none; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +#settings-panel.visible { display: flex; } + +.settings-tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.settings-tab { + flex: 1; + padding: 8px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + border-bottom: 2px solid transparent; + text-align: center; +} + +.settings-tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +.settings-body { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid var(--color-divider); + font-size: 12px; + color: var(--color-text); +} + +/* Toggle switch */ +.toggle-switch { + width: 36px; height: 20px; + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + cursor: pointer; + position: relative; + flex-shrink: 0; +} + +.toggle-switch.on { + background: rgba(91,141,217,0.3); + border-color: var(--color-primary); +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; left: 2px; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--color-text-faint); + transition: left 0.15s, background 0.15s; +} + +.toggle-switch.on::after { + left: 18px; + background: var(--color-primary); +} + +/* Guide modal */ +#guide-modal { z-index: 640; } + +/* Shortcuts modal */ +#shortcuts-modal { z-index: 640; } + +/* ============================================================ + DECK SELECTOR — 3-Category Tabs + ============================================================ */ +.deck-assignment-grid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.deck-select-panel { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + padding: 8px 10px; +} + +.deck-select-panel-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.player-color-dot { + width: 8px; height: 8px; + border-radius: 50%; +} + +.deck-select-player-name { + font-size: 12px; + font-weight: 600; + color: var(--color-text); +} + +.deck-select-ai-badge { + font-size: 10px; + background: rgba(91,141,217,0.2); + color: rgba(91,141,217,0.9); + padding: 1px 6px; + border-radius: 3px; + font-weight: 600; +} + +.deck-select-ai-note { + font-size: 11px; + color: var(--color-text-faint); +} + +.deck-select-status { + font-size: 10px; + color: var(--color-text-faint); + margin-left: auto; +} +.deck-select-status.selected { + color: var(--mtg-gold); +} + +.deck-tabs { + display: flex; + gap: 2px; + margin-bottom: 6px; +} + +.deck-tab { + flex: 1; + padding: 4px 8px; + font-size: 11px; + background: transparent; + border: 1px solid rgba(255,255,255,0.08); + color: var(--color-text-muted); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; +} +.deck-tab:hover { background: rgba(255,255,255,0.05); } +.deck-tab.active { + background: rgba(91,141,217,0.15); + border-color: rgba(91,141,217,0.3); + color: var(--color-text); +} + +.deck-tab-content { + max-height: 200px; + overflow-y: auto; +} + +.deck-filter-row { + display: flex; + gap: 4px; + margin-bottom: 6px; +} + +.deck-filter-input { + flex: 1; + font-size: 11px; + padding: 4px 8px; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + color: var(--color-text); + outline: none; +} +.deck-filter-input:focus { + border-color: rgba(91,141,217,0.4); +} + +.deck-precon-list { + max-height: 180px; + overflow-y: auto; +} + +.deck-set-header { + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + padding: 3px 4px; + cursor: pointer; + user-select: none; +} +.deck-set-header:hover { color: var(--color-text); } + +.deck-set-arrow { + display: inline-block; + width: 10px; + font-size: 10px; +} + +.deck-precon-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 4px 8px 4px 18px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--color-text-muted); + cursor: pointer; + font-size: 11px; + text-align: left; + transition: all 0.12s; +} +.deck-precon-item:hover { + background: rgba(255,255,255,0.04); + color: var(--color-text); +} +.deck-precon-item.selected { + background: rgba(91,141,217,0.12); + border-color: rgba(91,141,217,0.3); + color: var(--color-text); +} + +.deck-precon-cmdr { + font-size: 10px; + color: var(--color-text-faint); + margin-left: auto; + padding-left: 8px; +} + +.deck-tab-empty { + padding: 12px 8px; + text-align: center; + font-size: 11px; + color: var(--color-text-faint); +} + +.deck-tab-loading { + padding: 12px 8px; + text-align: center; + font-size: 11px; + color: rgba(91,141,217,0.8); +} + +.deck-import-textarea { + width: 100%; + min-height: 80px; + font-size: 11px; + font-family: monospace; + padding: 6px 8px; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + color: var(--color-text); + resize: vertical; +} + +.backend-status-row { + margin-bottom: 6px; +} + +.backend-status { + font-size: 10px; + padding: 2px 8px; + border-radius: 3px; + display: inline-block; +} +.backend-status.disconnected { + color: var(--color-text-faint); + background: rgba(255,255,255,0.03); +} +.backend-status.connected { + color: rgba(91,141,217,0.9); + background: rgba(91,141,217,0.1); +} +.backend-status.connected-ml { + color: rgba(39,174,96,0.9); + background: rgba(39,174,96,0.1); +} + +/* ============================================================ + ENHANCED PLAYER PANELS + ============================================================ */ +.epp-container { + margin: 2px 0; +} + +.enhanced-player-panel { + background: rgba(0,0,0,0.4); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + font-size: 11px; + overflow: hidden; +} +.enhanced-player-panel.active-turn { + border-color: rgba(255,255,255,0.15); + box-shadow: 0 0 12px rgba(255,255,255,0.05); +} +.enhanced-player-panel.eliminated { + opacity: 0.5; + filter: grayscale(0.5); +} + +.epp-header { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + cursor: pointer; + user-select: none; + background: rgba(255,255,255,0.02); +} +.epp-header:hover { background: rgba(255,255,255,0.04); } + +.epp-collapse-arrow { + font-size: 9px; + width: 10px; + color: var(--color-text-faint); +} + +.epp-name-dot { + width: 8px; height: 8px; + border-radius: 50%; +} + +.epp-name { + font-weight: 600; + font-size: 11px; + color: var(--color-text); +} + +.epp-ai-badge { + font-size: 9px; + background: rgba(91,141,217,0.2); + color: rgba(91,141,217,0.9); + padding: 0 4px; + border-radius: 2px; +} + +.epp-life-badge { + margin-left: auto; + font-weight: 700; + font-size: 12px; + color: var(--color-text); +} + +.epp-body { + padding: 4px 8px 6px; +} + +/* Life Row */ +.epp-life-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 4px 0; +} +.epp-life-btn { + width: 24px; height: 24px; + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.05); + color: var(--color-text); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.epp-life-btn:hover { background: rgba(255,255,255,0.1); } +.epp-life-btn.minus:active { background: rgba(211,32,42,0.3); } +.epp-life-btn.plus:active { background: rgba(39,174,96,0.3); } + +.epp-life-value { + font-size: 22px; + font-weight: 700; + color: var(--color-text); + min-width: 40px; + text-align: center; + cursor: pointer; + font-family: var(--font-display); +} + +/* Sections */ +.epp-section { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.epp-section-label { + font-size: 9px; + font-weight: 600; + color: var(--color-text-faint); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 3px; +} + +/* Commander Damage */ +.epp-cmd-dmg-grid { + display: flex; + flex-direction: column; + gap: 2px; +} + +.epp-cmd-dmg-item { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: 3px; + position: relative; + overflow: hidden; +} +.epp-cmd-dmg-item.warning { background: rgba(241,196,15,0.08); } +.epp-cmd-dmg-item.critical { background: rgba(211,32,42,0.1); } + +.epp-cmd-dmg-bar { + position: absolute; + left: 0; top: 0; bottom: 0; + background: rgba(211,32,42,0.08); + border-radius: 3px; + transition: width 0.3s; +} + +.epp-cmd-dmg-dot { + width: 6px; height: 6px; + border-radius: 50%; + z-index: 1; +} + +.epp-cmd-dmg-name { + font-size: 10px; + color: var(--color-text-muted); + z-index: 1; +} + +.epp-cmd-dmg-value { + margin-left: auto; + font-weight: 600; + font-size: 10px; + z-index: 1; +} + +/* Counters */ +.epp-counters-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; +} + +.epp-counter { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: 3px; + background: rgba(255,255,255,0.02); +} +.epp-counter.warning { background: rgba(241,196,15,0.08); } +.epp-counter.critical { background: rgba(211,32,42,0.1); } + +.epp-counter-icon { font-size: 12px; } +.epp-counter-value { font-weight: 700; font-size: 12px; } +.epp-counter-label { font-size: 9px; color: var(--color-text-faint); } +.epp-counter-info { display: flex; flex-direction: column; line-height: 1.1; } + +.epp-counter-btns { margin-left: auto; display: flex; gap: 2px; } +.epp-counter-btn { + width: 16px; height: 16px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 3px; + background: transparent; + color: var(--color-text-muted); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.epp-counter-btn:hover { background: rgba(255,255,255,0.08); } + +/* Emblems */ +.epp-emblems { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.epp-emblem-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + background: rgba(200,168,67,0.1); + border: 1px solid rgba(200,168,67,0.2); + border-radius: 3px; + font-size: 10px; + color: var(--mtg-gold); +} + +.epp-emblem-remove { + background: none; + border: none; + color: var(--color-text-faint); + cursor: pointer; + font-size: 10px; + padding: 0; +} +.epp-emblem-remove:hover { color: var(--color-error); } + +.epp-no-emblems { + font-size: 10px; + color: var(--color-text-faint); +} + +.epp-add-emblem-btn { + font-size: 9px; + padding: 1px 6px; + background: transparent; + border: 1px dashed rgba(255,255,255,0.1); + border-radius: 3px; + color: var(--color-text-faint); + cursor: pointer; +} +.epp-add-emblem-btn:hover { + border-color: rgba(255,255,255,0.2); + color: var(--color-text-muted); +} diff --git a/lan-client/ui.js b/lan-client/ui.js new file mode 100644 index 0000000..9ceb38a --- /dev/null +++ b/lan-client/ui.js @@ -0,0 +1,2297 @@ +/** + * MTG Commander Simulator — ui.js + * All UI rendering functions adapted for the 4-quadrant battlefield layout. + * This file overrides/extends DOM-touching functions from engine.js. + * ============================================================ + */ + +'use strict'; + +// ============================================================ +// GLOBAL UI STATE +// ============================================================ +let zoneModalPlayerId = null; +let activeZoneTab = 'graveyard'; +let _zoneCardCtx = null; +let undoHistoryVisible = false; +let aiTurnActive = false; +let floatingPreviewTimeout = null; + +// ============================================================ +// LOG SYSTEM (override engine stub) +// ============================================================ +const _logEntries = []; + +function addLogEntry(text, category) { + const entry = { text, category: category || 'general', ts: Date.now() }; + _logEntries.push(entry); + // Keep last 500 entries + if (_logEntries.length > 500) _logEntries.shift(); + // Show as floating log entry on the battlefield + addFloatingLogEntry(text, category); +} + +function addFloatingLogEntry(text, category) { + const floatingLog = document.getElementById('floating-log'); + if (!floatingLog) return; + const entry = document.createElement('div'); + entry.className = 'floating-log-entry'; + entry.innerHTML = text; + floatingLog.appendChild(entry); + // Remove old entries + while (floatingLog.children.length > 5) { + floatingLog.removeChild(floatingLog.firstChild); + } + // Fade out after 4 seconds + setTimeout(() => { + entry.style.opacity = '0'; + entry.style.transition = 'opacity 0.6s'; + setTimeout(() => { if (entry.parentNode) entry.remove(); }, 700); + }, 4000); +} + +// ============================================================ +// LIFE COUNTER RENDERING (4-quadrant) +// ============================================================ +function updateLifeCounterDisplay(playerId) { + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + const el = document.getElementById('life-' + playerId); + if (el) el.textContent = player.life; + // Also update poison + const poisonEl = document.getElementById('poison-' + playerId); + if (poisonEl) { + poisonEl.textContent = player.poison > 0 ? player.poison + ' poison' : ''; + poisonEl.style.display = player.poison > 0 ? 'block' : 'none'; + } + // Update player label with commander name + const cmdNameEl = document.getElementById('cmd-name-' + playerId); + if (cmdNameEl && player.commanderCardName) { + cmdNameEl.textContent = player.commanderCardName; + } +} + +// Called by engine's setLife +function setLife(playerId, newLife) { + if (!gameState) return; + pushUndoState(); + const player = gameState.players[playerId]; + if (!player) return; + const oldLife = player.life; + player.life = newLife; + updateLifeCounterDisplay(playerId); + const diff = newLife - oldLife; + const sign = diff > 0 ? '+' : ''; + addLogEntry('' + player.name + ' life: ' + oldLife + ' → ' + newLife + ' (' + sign + diff + ')'); + if (diff > 0) fireBattlefieldTriggers('life_gain'); + else if (diff < 0) fireBattlefieldTriggers('life_loss'); + if (newLife <= 0 && !player.eliminated) eliminatePlayer(playerId, 'life total reached 0'); + checkStateBasedActions(); + // Highlight active player quadrant + updateActivePlayerHighlight(); +} + +// ============================================================ +// ACTIVE PLAYER HIGHLIGHT +// ============================================================ +function updateActivePlayerHighlight() { + if (!gameState) return; + for (let i = 0; i < 4; i++) { + const area = document.querySelector('.player-area.p' + (i + 1)); + if (area) { + area.classList.toggle('active-player', gameState.currentPlayerIndex === i); + } + } + // Update active player indicator in top bar + const indi = document.getElementById('active-player-indicator'); + if (indi && gameState.players[gameState.currentPlayerIndex]) { + const p = gameState.players[gameState.currentPlayerIndex]; + indi.innerHTML = + '' + + p.name + "'s Turn"; + } + // Update turn display + const turnEl = document.getElementById('turn-counter'); + if (turnEl) turnEl.innerHTML = 'Turn ' + (gameState.turnNumber || 1) + ''; +} + +// ============================================================ +// RENDER PLAYER PANELS (adapted for 4-quadrant — updates zones/labels) +// ============================================================ +function renderPlayerPanels() { + if (!gameState) return; + gameState.players.forEach((player, idx) => { + updateLifeCounterDisplay(idx); + updateZoneCountsForPlayer(idx); + renderCommanderZone(idx); + renderHandZone(idx); + }); + updateActivePlayerHighlight(); +} + +function updateManaPoolBadge(playerIdx) { + // Future: show mini mana pool indicators on each player's area +} + +function updateZoneCountsForPlayer(playerIdx) { + if (!gameState) return; + const player = gameState.players[playerIdx]; + if (!player) return; + const pNum = playerIdx + 1; + + // Update library count + 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; +} + +function updatePlayerZones() { + if (!gameState) return; + gameState.players.forEach((_, idx) => { + updateZoneCountsForPlayer(idx); + renderHandZone(idx); + renderCommanderZone(idx); + }); +} + +// ============================================================ +// COMMANDER ZONE RENDERING +// ============================================================ +function renderCommanderZone(playerIdx) { + if (!gameState) return; + const player = gameState.players[playerIdx]; + if (!player) return; + const pNum = playerIdx + 1; + const zoneEl = document.getElementById('cmd-zone-' + pNum); + if (!zoneEl) return; + + const cmdCards = player.zones.command || []; + if (cmdCards.length > 0) { + const cmdCard = cmdCards[0]; + if (cmdCard.imageUrl) { + zoneEl.innerHTML = + '' + (cmdCard.name || 'Commander') + ''; + // Count badge + if (player.commanderCastCount > 0) { + zoneEl.innerHTML += '' + (player.commanderCastCount) + 'x'; + } + } else { + zoneEl.innerHTML = + '👑' + + 'Commander' + + '' + cmdCards.length + ''; + } + } else { + zoneEl.innerHTML = + '👑' + + 'Commander'; + } +} + +// ============================================================ +// HAND ZONE RENDERING (thumbnails in zone.hand) +// ============================================================ +function renderHandZone(playerIdx) { + if (!gameState) return; + const player = gameState.players[playerIdx]; + if (!player) return; + const pNum = playerIdx + 1; + const handEl = document.getElementById('hand-zone-' + pNum); + if (!handEl) return; + + const hand = player.zones.hand || []; + + // Update count badge + const countEl = document.getElementById('hand-count-' + pNum); + if (countEl) countEl.textContent = hand.length; + + if (hand.length === 0) { + handEl.innerHTML = + '🃏' + + 'Hand (0)'; + return; + } + + // Render thumbnails + let thumbsHtml = ''; + const maxShow = Math.min(hand.length, 7); // limit thumbnails + for (let i = 0; i < maxShow; i++) { + const card = hand[i]; + const imgSrc = card.imageUrl || ''; + const escapedName = (card.name || '').replace(/'/g, "\\'"); + const escapedType = (card.typeLine || '').replace(/'/g, "\\'"); + if (imgSrc) { + thumbsHtml += + '
' + + '' + (card.name || '') + '' + + '
'; + } else { + thumbsHtml += '
' + + (card.name || '?').substring(0, 4) + '
'; + } + } + + if (hand.length > maxShow) { + thumbsHtml += '
+' + (hand.length - maxShow) + '
'; + } + + handEl.innerHTML = thumbsHtml; +} + +// ============================================================ +// RENDER BATTLEFIELD CARD (adapted for quadrant battle-zones) +// ============================================================ +function renderBattlefieldCard(cardData) { + // Target the player's battle-zone div + const pNum = cardData.ownerIndex + 1; + const battleZone = document.getElementById('battle-zone-' + pNum); + if (!battleZone) { + // Fallback: try old-style battlefield + const bf = document.getElementById('battlefield'); + if (!bf) return; + } + const container = battleZone || document.getElementById('battlefield'); + + // Remove existing card element if re-rendering + const existing = document.getElementById('bc-' + cardData.id); + if (existing) existing.remove(); + + const card = document.createElement('div'); + card.className = 'battlefield-card'; + card.id = 'bc-' + cardData.id; + card.style.left = (cardData.x || 10) + 'px'; + 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'); + if (typeof hasSummoningSickness === 'function' && hasSummoningSickness(cardData)) { + card.classList.add('summoning-sick'); + } + if (cardData.isToken) card.classList.add('token-glow'); + + // Keyword badges HTML + let kwBadgeHtml = ''; + if (typeof getKeywordBadges === 'function') { + const kwBadges = getKeywordBadges(cardData); + if (kwBadges && kwBadges.length > 0) { + kwBadgeHtml = '
'; + kwBadges.forEach(b => { + kwBadgeHtml += '' + b.icon + ''; + }); + kwBadgeHtml += '
'; + } + } + + // Protection badge + let protBadge = ''; + if (typeof getBattlefieldCardProtectionBadge === 'function') { + protBadge = getBattlefieldCardProtectionBadge(cardData) || ''; + } + + const imgUrl = cardData.imageUrl || ''; + const altName = (cardData.name || 'Card').replace(/"/g, '"'); + + card.innerHTML = + protBadge + + '' + altName + '' + + '
' + + kwBadgeHtml; + + // Counter badges + if (cardData.counters) { + const cTypes = Object.keys(cardData.counters).filter(k => cardData.counters[k] > 0); + if (cTypes.length > 0) { + const strip = document.createElement('div'); + strip.className = 'card-counters-strip'; + cTypes.forEach(cType => { + const cCount = cardData.counters[cType]; + const ct = (typeof COUNTER_TYPES !== 'undefined' && COUNTER_TYPES[cType]) || + { icon: '\u25CF', color: '#9ca3af', label: cType }; + const badge = document.createElement('span'); + badge.className = 'card-counter-badge'; + badge.style.cssText = 'background:' + ct.color + ';color:#fff;'; + badge.title = cCount + ' ' + ct.label; + if (cType === '+1/+1') badge.textContent = '+' + cCount + '/+' + cCount; + else if (cType === '-1/-1') badge.textContent = '-' + cCount + '/-' + cCount; + else badge.textContent = ct.icon + cCount; + strip.appendChild(badge); + }); + card.appendChild(strip); + } + } + + // Damage badge + if (cardData.damageMarked && cardData.damageMarked > 0) { + const dmgBadge = document.createElement('div'); + dmgBadge.className = 'damage-badge'; + dmgBadge.textContent = cardData.damageMarked; + card.appendChild(dmgBadge); + } + + // Event listeners — use both for compatibility (Comet: inline onclick already on container) + card.addEventListener('mousedown', e => { + if (e.button !== 0) return; + if (cardData.attachedTo) return; + e.preventDefault(); + isDraggingBattlefieldCard = true; + draggedCardId = cardData.id; + const rect = card.getBoundingClientRect(); + dragOffset.x = e.clientX - rect.left; + dragOffset.y = e.clientY - rect.top; + card.style.zIndex = '100'; + card.style.transition = 'none'; + }); + + card.addEventListener('dblclick', e => { + e.preventDefault(); + toggleTap(cardData.id); + }); + + card.addEventListener('contextmenu', e => { + e.preventDefault(); + showContextMenu(e.clientX, e.clientY, cardData.id); + }); + + card.addEventListener('click', e => { + if (!gameState) return; + if (typeof stackPickMode !== 'undefined' && stackPickMode) { + handleStackPickClick(cardData.id); + e.stopPropagation(); + return; + } + if (typeof equipPickMode !== 'undefined' && equipPickMode) { + handleEquipPickClick(cardData.id); + e.stopPropagation(); + return; + } + // Combat attacker/blocker clicks + const combat = gameState.combat; + if (combat && combat.phase === 'declaring-attackers') { + if (cardData.ownerIndex === gameState.currentPlayerIndex && !cardData.tapped) { + toggleAttacker(cardData.id); + e.stopPropagation(); + } + } else if (combat && combat.phase === 'declaring-blockers') { + if (!cardData.tapped && combat.pendingBlockerFor !== null) { + toggleBlocker(cardData.id, combat.pendingBlockerFor); + e.stopPropagation(); + } else { + const asAttacker = combat.attackers && combat.attackers.find(a => a.cardId === cardData.id); + if (asAttacker) { + combat.pendingBlockerFor = cardData.id; + refreshCombatOverlay(); + e.stopPropagation(); + } + } + } + }); + + // Long press for context menu (touch) + let _lpTimer = null; + card.addEventListener('touchstart', e => { + _lpTimer = setTimeout(() => { + const t = e.touches[0]; + showContextMenu(t.clientX, t.clientY, cardData.id); + }, 600); + }); + card.addEventListener('touchend', () => clearTimeout(_lpTimer)); + card.addEventListener('touchmove', () => clearTimeout(_lpTimer)); + + card.addEventListener('mouseenter', e => { + if (!isDraggingBattlefieldCard) { + if (cardData.stackGroupId && typeof isStackLeader === 'function' && isStackLeader(cardData)) { + fanOutStack(cardData.stackGroupId); + } + showFloatingCardPreview(cardData.imageUrl, e, { + name: cardData.name, + typeLine: cardData.typeLine, + oracleText: cardData.oracleText, + manaCost: cardData.manaCost || '', + power: cardData.power, + toughness: cardData.toughness, + pt: cardData.pt, + loyalty: cardData.loyalty, + _bfCard: cardData + }); + } + }); + + card.addEventListener('mousemove', e => { + if (!isDraggingBattlefieldCard) positionFloatingPreview(e); + }); + + card.addEventListener('mouseleave', () => { + hideFloatingCardPreview(); + }); + + container.appendChild(card); +} + +// Drag tracking for battlefield cards +document.addEventListener('mousemove', e => { + if (!isDraggingBattlefieldCard || draggedCardId === null) return; + hideFloatingCardPreview(); + + const cardData = gameState && gameState.battlefieldCards.find(c => c.id === draggedCardId); + if (!cardData) return; + + const pNum = cardData.ownerIndex + 1; + const battleZone = document.getElementById('battle-zone-' + pNum); + if (!battleZone) return; + + const rect = battleZone.getBoundingClientRect(); + let newX = e.clientX - rect.left - dragOffset.x; + let newY = e.clientY - rect.top - dragOffset.y; + + newX = Math.max(0, Math.min(rect.width - 80, newX)); + newY = Math.max(0, Math.min(rect.height - 105, newY)); + + cardData.x = newX; + cardData.y = newY; + + const cardEl = document.getElementById('bc-' + draggedCardId); + if (cardEl) { + cardEl.style.left = newX + 'px'; + cardEl.style.top = newY + 'px'; + } + + if (cardData.attachments && cardData.attachments.length > 0) { + moveAttachmentsWithCreature(cardData.id); + } +}); + +document.addEventListener('mouseup', e => { + if (!isDraggingBattlefieldCard) return; + isDraggingBattlefieldCard = false; + const droppedId = draggedCardId; + const cardEl = document.getElementById('bc-' + droppedId); + if (cardEl) { + cardEl.style.zIndex = ''; + cardEl.style.transition = ''; + } + draggedCardId = null; + + // Shift+drop stacking + if (e.shiftKey && droppedId && gameState) { + const droppedCard = gameState.battlefieldCards.find(c => c.id === droppedId); + if (droppedCard && typeof createStack === 'function') { + const threshold = 40; + let closestCard = null, closestDist = Infinity; + gameState.battlefieldCards.forEach(other => { + if (other.id === droppedId) return; + if (other.stackGroupId && !isStackLeader(other)) return; + const dx = (other.x || 0) - (droppedCard.x || 0); + const dy = (other.y || 0) - (droppedCard.y || 0); + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < threshold && dist < closestDist) { + closestDist = dist; + closestCard = other; + } + }); + if (closestCard) createStack(closestCard.id, droppedId); + } + } +}); + +// ============================================================ +// PHASE TRACKER +// ============================================================ +function renderPhaseTracker() { + if (!gameState) return; + const track = document.getElementById('phase-track'); + if (!track) return; + + const currentPhase = PHASES[gameState.currentPhaseIndex] || PHASES[0]; + let html = ''; + + PHASES.forEach((phase, idx) => { + const isActive = idx === gameState.currentPhaseIndex; + const isDone = idx < gameState.currentPhaseIndex; + let cls = 'phase-step'; + if (isActive) cls += ' active'; + if (isDone) cls += ' done'; + + let style = ''; + if (isActive) { + style = 'style="background:' + hexToRgba(phase.color || '#5b8dd9', 0.25) + ';box-shadow:inset 0 0 0 1px ' + hexToRgba(phase.color || '#5b8dd9', 0.4) + '"'; + } + + // If in combat and this is the combat phase, show current combat sub-phase + let label = phase.short; + if (idx === 2 && gameState.inCombat) { + const combatPhase = COMBAT_PHASES[gameState.combatPhaseIndex] || COMBAT_PHASES[0]; + label = combatPhase.short; + } + + html += '
'; + html += '' + (phase.icon || '') + ''; + html += '' + label + ''; + html += '
'; + + if (idx < PHASES.length - 1) { + html += '
'; + } + }); + + track.innerHTML = html; +} + +function handlePhaseClick(phaseIdx) { + // Just provide info about the phase, don't jump to it + if (!gameState) return; + const phase = PHASES[phaseIdx]; + if (phase) addLogEntry('Phase info: ' + phase.label + ' — ' + phase.tip); +} + +function updateTurnDisplay() { + if (!gameState) return; + const turnEl = document.getElementById('turn-counter'); + if (turnEl) turnEl.innerHTML = 'Turn ' + (gameState.turnNumber || 1) + ''; + updateActivePlayerHighlight(); + renderPhaseTracker(); +} + +function updateStormCounter() { + if (!gameState) return; + const storm = gameState.stormCount || 0; + const badge = document.getElementById('storm-badge'); + if (badge) { + badge.textContent = 'Storm: ' + storm; + badge.style.display = storm > 0 ? 'block' : 'none'; + } +} + +// ============================================================ +// MANA POOL DISPLAY +// ============================================================ +function renderManaPool(playerIdx) { + if (!gameState) return; + const pool = getManaPool(playerIdx !== undefined ? playerIdx : gameState.currentPlayerIndex); + const display = document.getElementById('mana-pool-display'); + if (!display) return; + + const colors = ['W', 'U', 'B', 'R', 'G', 'C']; + let html = 'MANA:'; + let hasAny = false; + + colors.forEach(c => { + if (pool[c] > 0) { + hasAny = true; + html += '' + pool[c] + ''; + } + }); + + if (!hasAny) { + html += 'Empty'; + } + + html += ''; + display.innerHTML = html; + display.style.display = gameState.inGame ? 'flex' : 'none'; +} + +// ============================================================ +// CONTEXT MENU +// ============================================================ +let contextMenuTargetId = null; + +function showContextMenu(x, y, cardId) { + contextMenuTargetId = cardId; + const menu = document.getElementById('context-menu'); + if (!menu) return; + + menu.classList.add('visible'); + + const cardData = gameState && gameState.battlefieldCards.find(c => c.id === cardId); + const _isEquip = cardData && typeof isEquipment === 'function' && isEquipment(cardData); + const _isAuraCard = cardData && typeof isAura === 'function' && isAura(cardData); + const _isPlaneswalker = cardData && cardData.typeLine && /planeswalker/i.test(cardData.typeLine); + const hasAbilities = cardData && typeof parseActivatedAbilities === 'function' && parseActivatedAbilities(cardData).length > 0; + + menu.innerHTML = + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + (hasAbilities ? '' : '') + + (_isPlaneswalker ? '' : '') + + (_isEquip ? '' : '') + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + ''; + + // Position + const maxX = window.innerWidth - 200; + const maxY = window.innerHeight - 360; + menu.style.left = Math.min(x, maxX) + 'px'; + menu.style.top = Math.min(y, maxY) + 'px'; +} + +function hideContextMenu() { + const menu = document.getElementById('context-menu'); + if (menu) menu.classList.remove('visible'); +} + +// Close context menu on outside click +document.addEventListener('click', e => { + const menu = document.getElementById('context-menu'); + if (menu && !menu.contains(e.target)) hideContextMenu(); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + hideContextMenu(); + if (typeof cancelStackPickMode === 'function') cancelStackPickMode(); + if (typeof cancelEquipPickMode === 'function') cancelEquipPickMode(); + closeZoneModal(); + } +}); + +// ============================================================ +// FLOATING CARD PREVIEW +// ============================================================ +function showFloatingCardPreview(imgSrc, e, cardInfo) { + if (!imgSrc) return; + const preview = document.getElementById('card-preview'); + if (!preview) return; + + if (floatingPreviewTimeout) { + clearTimeout(floatingPreviewTimeout); + floatingPreviewTimeout = null; + } + + const img = preview.querySelector('img'); + if (img) img.src = imgSrc; + + const infoDiv = preview.querySelector('.card-preview-info'); + if (infoDiv && cardInfo) { + const nameEl = preview.querySelector('.card-preview-name'); + const typeEl = preview.querySelector('.card-preview-type'); + const oracleEl = preview.querySelector('.card-preview-oracle'); + const ptEl = preview.querySelector('.card-preview-pt'); + if (nameEl) nameEl.textContent = cardInfo.name || ''; + if (typeEl) typeEl.textContent = cardInfo.typeLine || ''; + if (oracleEl) oracleEl.textContent = cardInfo.oracleText || ''; + if (ptEl) { + const pt = cardInfo.pt || (cardInfo.power && cardInfo.toughness ? cardInfo.power + '/' + cardInfo.toughness : ''); + ptEl.textContent = pt; + } + if (cardInfo.name || cardInfo.typeLine || cardInfo.oracleText) { + infoDiv.style.display = 'block'; + } else { + infoDiv.style.display = 'none'; + } + } + + preview.style.display = 'block'; + positionFloatingPreview(e); +} + +function positionFloatingPreview(e) { + const preview = document.getElementById('card-preview'); + if (!preview || preview.style.display === 'none') return; + + const pw = preview.offsetWidth || 220; + const ph = preview.offsetHeight || 340; + const ww = window.innerWidth; + const wh = window.innerHeight; + + let px = e.clientX + 16; + let py = e.clientY - 20; + + if (px + pw > ww - 10) px = e.clientX - pw - 16; + if (py + ph > wh - 10) py = wh - ph - 10; + if (py < 50) py = 50; + + preview.style.left = px + 'px'; + preview.style.top = py + 'px'; +} + +function hideFloatingCardPreview() { + floatingPreviewTimeout = setTimeout(() => { + const preview = document.getElementById('card-preview'); + if (preview) preview.style.display = 'none'; + }, 80); +} + +// ============================================================ +// ZONE MODAL +// ============================================================ +function openZoneModal(playerId, zone) { + zoneModalPlayerId = playerId; + activeZoneTab = zone || 'graveyard'; + renderZoneModal(); + const modal = document.getElementById('zone-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeZoneModal() { + const modal = document.getElementById('zone-modal'); + if (modal) modal.classList.remove('visible'); + zoneModalPlayerId = null; + if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible'); +} + +function switchZoneTab(zone) { + activeZoneTab = zone; + renderZoneModal(); +} + +function renderZoneModal() { + if (zoneModalPlayerId === null || !gameState) return; + const player = gameState.players[zoneModalPlayerId]; + if (!player) return; + + 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) { + 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' }, + ]; + + tabsEl.innerHTML = zones.map(z => { + const count = (player.zones[z.key] || []).length; + const isActive = z.key === activeZoneTab; + return ''; + }).join(''); + } + + const body = document.getElementById('zone-modal-body'); + if (!body) return; + + const cards = player.zones[activeZoneTab] || []; + + let actionButtons = ''; + if (activeZoneTab === 'library') { + actionButtons = + '' + + '' + + '' + + ''; + } + + if (cards.length === 0) { + body.innerHTML = + (actionButtons ? '
' + actionButtons + '
' : '') + + '
No cards in ' + activeZoneTab + '.
'; + return; + } + + let gridHtml = '
'; + cards.forEach((card, idx) => { + const imgSrc = card.imageUrl || ''; + const safeName = (card.name || 'Unknown').replace(/'/g, "\\'").replace(/"/g, '"'); + const safeType = (card.typeLine || '').replace(/'/g, "\\'"); + gridHtml += + '
' + + (imgSrc ? '' + safeName + '' : + '
' + safeName + '
') + + '
' + (card.name || 'Unknown') + '
' + + '
'; + }); + gridHtml += '
'; + + const infoBar = '
' + + '' + cards.length + ' card' + (cards.length !== 1 ? 's' : '') + '' + + '
' + actionButtons + '
' + + '
'; + + body.innerHTML = infoBar + gridHtml; +} + +// Zone card context menu +function showZoneCardCtx(x, y, playerId, zone, cardIndex) { + if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible'); + if (!_zoneCardCtx) { + _zoneCardCtx = document.createElement('div'); + _zoneCardCtx.className = 'zone-card-ctx'; + _zoneCardCtx.id = 'zone-card-ctx'; + _zoneCardCtx.style.cssText = + 'position:fixed;z-index:1100;background:var(--color-surface-raised);' + + 'border:1px solid var(--color-border-strong);border-radius:var(--radius-lg);' + + 'padding:6px;box-shadow:var(--shadow-lg);min-width:180px;'; + document.body.appendChild(_zoneCardCtx); + } + + const player = gameState && gameState.players[playerId]; + const card = player && player.zones[zone] ? player.zones[zone][cardIndex] : null; + if (!card) return; + + const items = [ + { icon: '⚔', label: 'Play to Battlefield', action: 'battlefield' }, + { icon: '✋', label: 'Return to Hand', action: 'hand', skip: zone === 'hand' }, + { divider: true }, + { icon: '☠', label: 'To Graveyard', action: 'graveyard', skip: zone === 'graveyard' }, + { icon: '⬡', label: 'To Exile', action: 'exile', skip: zone === 'exile' }, + { icon: '👑', label: 'To Command Zone', action: 'command', skip: zone === 'command' }, + { divider: true }, + { icon: '📚', label: 'Top of Library', action: 'library-top' }, + { icon: '📚', label: 'Bottom of Library', action: 'library-bottom' }, + { divider: true }, + { icon: '✕', label: 'Remove from Game', action: 'remove', danger: true }, + ]; + + _zoneCardCtx.innerHTML = + '
' + + (card.name || 'Card') + '
'; + + items.forEach(item => { + if (item.skip) return; + if (item.divider) { + _zoneCardCtx.innerHTML += '
'; + return; + } + _zoneCardCtx.innerHTML += + ''; + }); + + _zoneCardCtx.style.left = Math.min(x, window.innerWidth - 200) + 'px'; + _zoneCardCtx.style.top = Math.min(y, window.innerHeight - 300) + 'px'; + _zoneCardCtx.classList.add('visible'); + _zoneCardCtx.style.display = 'block'; +} + +function hideZoneCardCtxMenu() { + if (_zoneCardCtx) { _zoneCardCtx.classList.remove('visible'); _zoneCardCtx.style.display = 'none'; } +} + +function handleZmCtxAction(playerId, zone, cardIndex, action) { + pushUndoState(); + const player = gameState && gameState.players[playerId]; + if (!player) return; + const cards = player.zones[zone]; + if (!cards || cardIndex < 0 || cardIndex >= cards.length) return; + const card = cards[cardIndex]; + + if (action === 'battlefield') { + cards.splice(cardIndex, 1); + if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) { + player.commanderCastCount = (player.commanderCastCount || 0) + 1; + } + addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '', + { manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '' }); + addLogEntry('' + card.name + ' moved from ' + zone + ' to battlefield'); + } else if (action === 'hand') { + cards.splice(cardIndex, 1); + player.zones.hand.push(card); + addLogEntry('' + card.name + ' returned to ' + player.name + "'s hand"); + } else if (['graveyard', 'exile', 'command'].includes(action)) { + cards.splice(cardIndex, 1); + player.zones[action].push(card); + addLogEntry('' + card.name + ' moved to ' + action); + } else if (action === 'library-top') { + cards.splice(cardIndex, 1); + player.zones.library.unshift(card); + addLogEntry('' + card.name + ' put on top of library'); + } else if (action === 'library-bottom') { + cards.splice(cardIndex, 1); + player.zones.library.push(card); + addLogEntry('' + card.name + ' put on bottom of library'); + } else if (action === 'remove') { + cards.splice(cardIndex, 1); + addLogEntry('' + card.name + ' removed from game'); + } + + updatePlayerZones(); + renderZoneModal(); + renderHandArea(); +} + +// ============================================================ +// HAND AREA — full hand display (used in hand-area section, NOT the zone thumbnails) +// For the new layout, hand is rendered as thumbnails in the zone.hand element +// ============================================================ +function renderHandArea() { + if (!gameState) return; + // In the quadrant layout, hand is shown as thumbnails in each player's zone + gameState.players.forEach((_, idx) => renderHandZone(idx)); +} + +// ============================================================ +// SETUP SCREEN +// ============================================================ +function initSetupScreen() { + let selectedPlayerCount = 4; + let startingLife = 40; + const playerColors = [ + { color: '#c0392b', name: 'P1 (Crimson)' }, + { color: '#2980b9', name: 'P2 (Sapphire)' }, + { color: '#27ae60', name: 'P3 (Emerald)' }, + { color: '#d4930d', name: 'P4 (Gold)' }, + ]; + + const setupEl = document.getElementById('setup-screen'); + if (!setupEl) return; + + setupEl.innerHTML = + '
' + + '
Commander
' + + '
4-Player Battlefield Simulator
' + + + '
' + + '
Players
' + + '
' + + [2, 3, 4].map(n => + '' + ).join('') + + '
' + + '
' + + + '
' + + '
Player Names
' + + '
' + + playerColors.map((pc, i) => + '
' + + '
' + + '' + + '
' + ).join('') + + '
' + + '
' + + + '
' + + '
AI Opponents
' + + '
' + + '' + + '
' + + + '
' + + '
Starting Life
' + + '
' + + '' + + '40' + + '' + + '
' + + '
' + + + '
' + + '
Decks
' + + '
' + + 'Checking backend...' + + '
' + + '
' + + '
' + + + '' + + '
'; + + // Init deck assignment grid + updateDeckAssignmentGrid(4); + + // Expose helpers to window + // Track AI selections locally + var _localAiSet = new Set(); + var _localAiDiff = 'easy'; + + function _renderAiToggles() { + var grid = document.getElementById('ai-toggle-grid'); + if (!grid) return; + grid.innerHTML = ''; + for (var i = 0; i < selectedPlayerCount; i++) { + (function(idx) { + var isAi = _localAiSet.has(idx); + var btn = document.createElement('button'); + btn.style.cssText = 'display:flex;align-items:center;gap:5px;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px;' + + 'border:1px solid ' + (isAi ? 'rgba(91,141,217,0.4)' : 'rgba(255,255,255,0.1)') + ';' + + 'background:' + (isAi ? 'rgba(91,141,217,0.15)' : 'transparent') + ';' + + 'color:' + (isAi ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)') + ';'; + btn.innerHTML = '' + + (isAi ? '🤖 AI' : '👤 Human') + ' P' + (idx + 1) + ''; + btn.setAttribute('onclick', '(function(){})()'); + btn.addEventListener('click', function() { + if (!_localAiSet.has(idx)) { + var humanCount = selectedPlayerCount - _localAiSet.size; + if (humanCount <= 1) return; + _localAiSet.add(idx); + var nameInp = document.getElementById('player-name-' + idx); + if (nameInp && (!nameInp.value.trim() || nameInp.value.startsWith('Player '))) { + nameInp.value = 'AI Bot ' + (idx + 1); + } + } else { + _localAiSet.delete(idx); + var nameInp2 = document.getElementById('player-name-' + idx); + if (nameInp2 && nameInp2.value.startsWith('AI Bot')) nameInp2.value = ''; + } + _renderAiToggles(); + }); + grid.appendChild(btn); + })(i); + } + var diffRow = document.getElementById('ai-difficulty-row'); + if (diffRow) diffRow.style.display = _localAiSet.size > 0 ? 'flex' : 'none'; + } + + window._setAiDiff = function(el, diff) { + _localAiDiff = diff; + document.querySelectorAll('.ai-diff-btn').forEach(function(b) { + b.style.background = b.getAttribute('data-diff') === diff ? 'rgba(91,141,217,0.2)' : 'transparent'; + b.style.color = b.getAttribute('data-diff') === diff ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.5)'; + b.classList.toggle('active', b.getAttribute('data-diff') === diff); + }); + }; + + window.selectPlayerCount = function(n) { + selectedPlayerCount = n; + document.querySelectorAll('.player-count-btn').forEach((btn, i) => { + btn.classList.toggle('active', [2, 3, 4][i] === n); + }); + // Hide/show player fields + for (let i = 0; i < 4; i++) { + const field = document.getElementById('player-field-' + i); + if (field) field.style.display = i < n ? '' : 'none'; + } + // Clear AI selections for removed players + _localAiSet.forEach(function(idx) { if (idx >= n) _localAiSet.delete(idx); }); + _renderAiToggles(); + updateDeckAssignmentGrid(n); + }; + + window.adjustSetupLife = function(delta) { + startingLife = Math.max(10, Math.min(999, startingLife + delta)); + const el = document.getElementById('starting-life-display'); + if (el) el.textContent = startingLife; + }; + + window.startGame = function() { + const playerNames = []; + for (let i = 0; i < selectedPlayerCount; i++) { + const inp = document.getElementById('player-name-' + i); + playerNames.push(inp ? inp.value.trim() || ((_localAiSet.has(i)) ? 'AI Bot ' + (i + 1) : 'Player ' + (i + 1)) : 'Player ' + (i + 1)); + } + // Sync AI config to engine globals + setupAiPlayers = _localAiSet; + setupAiDifficulty = _localAiDiff; + initGame(selectedPlayerCount, playerNames, startingLife); + }; + + // Trigger display update for player count + window.selectPlayerCount(4); +} + +function updateDeckAssignmentGrid(playerCount) { + var grid = document.getElementById('deck-assignment-grid'); + if (!grid) return; + grid.innerHTML = ''; + + var playerColors = [ + { color: '#c0392b', name: 'Crimson' }, + { color: '#2980b9', name: 'Sapphire' }, + { color: '#27ae60', name: 'Emerald' }, + { color: '#d4930d', name: 'Gold' }, + ]; + + for (var i = 0; i < playerCount; i++) { + var isAi = typeof setupAiPlayers !== 'undefined' && setupAiPlayers.has + ? setupAiPlayers.has(i) + : (typeof _localAiSet !== 'undefined' && _localAiSet.has(i)); + + var nameInput = document.getElementById('player-name-' + i); + var playerName = (nameInput && nameInput.value.trim()) || + (isAi ? ('AI Bot ' + (i + 1)) : ('Player ' + (i + 1))); + + var panel = document.createElement('div'); + panel.className = 'deck-select-panel'; + panel.id = 'deck-panel-' + i; + + if (isAi) { + panel.innerHTML = + '
' + + '
' + + '' + playerName + '' + + 'AI' + + '
' + + '
AI Deck (auto-generated)
'; + } else { + panel.innerHTML = + '
' + + '
' + + '' + playerName + '' + + 'No deck selected' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + _buildPreconTabContent(i) + + '
'; + } + + grid.appendChild(panel); + } + + // If backend is connected, load precons from API + _loadBackendPrecons(); +} + +function _buildPreconTabContent(playerIdx) { + // Local precons from precons.js (PRECON_DATA) + var preconList = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : []; + + if (preconList.length === 0) { + return '
No precon data loaded.
Connect to Commander-AI-Lab backend for 163+ precons.
'; + } + + var html = '
' + + '' + + '
'; + + // Group by set + var sets = {}; + preconList.forEach(function(p) { + var setName = p.set || 'Other'; + if (!sets[setName]) sets[setName] = []; + sets[setName].push(p); + }); + + html += '
'; + Object.keys(sets).sort().forEach(function(setName) { + html += '
'; + html += '
'; + html += '\u25b8 ' + setName + ' (' + sets[setName].length + ')'; + html += '
'; + html += '
'; + }); + html += '
'; + + return html; +} + +async function _loadBackendPrecons() { + if (typeof AIBridge === 'undefined' || !AIBridge.connector.connected) return; + + try { + var precons = await AIBridge.deckSource.fetchPrecons(); + if (precons && precons.length > 0) { + var indicator = document.getElementById('backend-status-indicator'); + if (indicator) { + indicator.textContent = '\u26a1 ' + precons.length + ' precons from backend'; + indicator.className = 'backend-status connected'; + } + } + } catch (e) { + console.warn('[_loadBackendPrecons] Error:', e); + } +} + +function switchDeckTab(playerIdx, tab) { + var tabs = document.querySelectorAll('#deck-tabs-' + playerIdx + ' .deck-tab'); + tabs.forEach(function(t) { + t.classList.toggle('active', t.getAttribute('data-tab') === tab); + }); + + var content = document.getElementById('deck-content-' + playerIdx); + if (!content) return; + + switch (tab) { + case 'precons': + content.innerHTML = _buildPreconTabContent(playerIdx); + break; + + case 'edhrec': + content.innerHTML = + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
Enter a commander name to find EDHREC recommendations.
' + + 'EDHREC integration coming soon.
' + + '
' + + '
'; + break; + + case 'mydecks': + content.innerHTML = + '
' + + '
' + + '
Import a decklist:
' + + '' + + '' + + '
' + + '
'; + break; + } +} + +function filterPrecons(playerIdx, query) { + var list = document.getElementById('precon-list-' + playerIdx); + if (!list) return; + var q = query.toLowerCase().trim(); + + list.querySelectorAll('.deck-precon-item').forEach(function(item) { + 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'; + }); + + // Auto-expand groups that have visible items + list.querySelectorAll('.deck-set-group').forEach(function(group) { + var items = group.querySelector('.deck-set-items'); + var hasVisible = items && Array.from(items.querySelectorAll('.deck-precon-item')) + .some(function(it) { return it.style.display !== 'none'; }); + if (items && q) { + items.style.display = hasVisible ? '' : 'none'; + } + }); +} + +function toggleDeckSet(headerEl) { + var items = headerEl.nextElementSibling; + if (!items) return; + var isOpen = items.style.display !== 'none'; + items.style.display = isOpen ? 'none' : ''; + var arrow = headerEl.querySelector('.deck-set-arrow'); + if (arrow) arrow.textContent = isOpen ? '\u25b8' : '\u25be'; +} + +function selectPreconDeck(playerIdx, preconId) { + var panel = document.getElementById('deck-panel-' + playerIdx); + if (panel) { + panel.querySelectorAll('.deck-precon-item').forEach(function(item) { + item.classList.remove('selected'); + }); + var selected = panel.querySelector('[data-precon-id="' + preconId + '"]'); + if (selected) selected.classList.add('selected'); + } + + var status = document.getElementById('deck-status-' + playerIdx); + var preconList = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : []; + var precon = preconList.find(function(p) { return p.id === preconId; }); + if (status) { + status.textContent = precon ? (precon.name || preconId) + ' selected' : preconId + ' selected'; + status.className = 'deck-select-status selected'; + } + + if (typeof setupDeckChoices === 'undefined') window.setupDeckChoices = {}; + setupDeckChoices[playerIdx] = { type: 'precon', preconId: preconId }; +} + +function searchEdhrec(playerIdx) { + var input = document.getElementById('edhrec-search-' + playerIdx); + var results = document.getElementById('edhrec-results-' + playerIdx); + if (!input || !results) return; + + var query = input.value.trim(); + if (!query) return; + + results.innerHTML = '
Searching EDHREC for "' + query + '"...
'; + + if (typeof AIBridge !== 'undefined' && AIBridge.connector.connected) { + AIBridge.deckSource.fetchEdhrecDecks(query).then(function(decks) { + if (decks.length === 0) { + results.innerHTML = '
No results for "' + query + '".
' + + 'EDHREC integration is coming soon.
'; + } else { + var html = ''; + decks.forEach(function(d) { + html += ''; + }); + results.innerHTML = html; + } + }); + } else { + results.innerHTML = '
EDHREC search requires Commander-AI-Lab backend.
' + + 'Start the backend: python lab_api.py
'; + } +} + +function importDeckFromTextarea(playerIdx) { + var textarea = document.getElementById('deck-import-' + playerIdx); + if (!textarea) return; + var text = textarea.value.trim(); + if (!text) return; + + var status = document.getElementById('deck-status-' + playerIdx); + if (status) { + status.textContent = 'Decklist imported'; + status.className = 'deck-select-status selected'; + } + + if (typeof setupDeckChoices === 'undefined') window.setupDeckChoices = {}; + setupDeckChoices[playerIdx] = { type: 'import', text: text }; +} + +// ============================================================ +// GAME INITIALIZATION +// ============================================================ +function initGame(playerCount, playerNames, startingLife) { + gameState = createInitialState(playerCount, playerNames, startingLife); + gameState.inGame = true; + + // Hide setup screen + const setup = document.getElementById('setup-screen'); + if (setup) setup.classList.add('hidden'); + + // Show battlefield + const bf = document.getElementById('game-battlefield'); + if (bf) bf.style.display = 'block'; + + // Update player labels and life counters in the quadrant layout + for (let i = 0; i < 4; i++) { + const pNum = i + 1; + const player = gameState.players[i]; + const area = document.querySelector('.player-area.p' + pNum); + + // Update player label + const labelEl = area && area.querySelector('.player-label'); + if (labelEl) { + labelEl.textContent = player ? player.name : ''; + labelEl.style.display = player ? '' : 'none'; + } + + // Show/hide based on player count + if (area) { + area.style.display = i < playerCount ? '' : 'none'; + } + + if (player) { + // Set life counter + const lifeEl = document.getElementById('life-' + i); + if (lifeEl) lifeEl.textContent = player.life; + } + } + + // Assign precon decks if selected + for (let i = 0; i < playerCount; i++) { + const sel = document.getElementById('deck-sel-' + i); + if (sel && sel.value) { + const preconList2 = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : (typeof PRECONS !== 'undefined') ? PRECONS : []; + const precon = preconList2.find(p => p.id === sel.value); + if (precon) loadPreconDeck(i, precon); + } + } + + // Mark AI players + setupAiPlayers.forEach(function(idx) { + if (gameState.players[idx]) { + gameState.players[idx].isAI = true; + } + }); + gameState.aiDifficulty = setupAiDifficulty; + + // Initialize phase tracker + renderPhaseTracker(); + updateTurnDisplay(); + renderPlayerPanels(); + renderManaPool(0); + + var aiCount = 0; + gameState.players.forEach(function(p) { if (p.isAI) aiCount++; }); + addLogEntry('🎮 Game started! ' + playerCount + ' players, ' + startingLife + ' starting life.' + (aiCount > 0 ? ' (' + aiCount + ' AI on ' + setupAiDifficulty + ')' : '')); + addLogEntry('' + gameState.players[0].name + ' begins the game.'); + + // Build section areas for each battle zone + buildQuadrantBattleZones(); + + // Show mana pool display + const manaDisplay = document.getElementById('mana-pool-display'); + if (manaDisplay) manaDisplay.style.display = 'flex'; + + // If first player is AI, start their turn after a short delay + if (gameState.players[0] && gameState.players[0].isAI && typeof runAiTurn === 'function') { + setTimeout(function() { runAiTurn(); }, 1200); + } +} + +function buildQuadrantBattleZones() { + // Each player's battle-zone div is already in the HTML + // Just ensure they have position:relative and overflow:hidden + for (let i = 0; i < 4; i++) { + const pNum = i + 1; + const bz = document.getElementById('battle-zone-' + pNum); + if (bz) { + bz.style.position = 'relative'; + bz.style.overflow = 'hidden'; + } + } +} + +function loadPreconDeck(playerIdx, precon) { + if (!gameState || !precon) return; + const player = gameState.players[playerIdx]; + if (!player) return; + + player.commanderCardName = precon.commander || ''; + + // Parse decklist text if available (PRECON_DATA format) + const deckText = precon.decklist || ''; + const cards = precon.cards || []; + + // Parse from decklist text + const lines = deckText.split('\n'); + const parsedCards = []; + let commanderName = precon.commander || ''; + let inCommanderSection = false; + + lines.forEach(line => { + line = line.trim(); + if (!line || line.startsWith('//')) { + inCommanderSection = line.toLowerCase().includes('commander'); + return; + } + const match = line.match(/^(\d+)\s+(.+)$/); + if (match) { + const qty = parseInt(match[1]); + const name = match[2].trim(); + if (inCommanderSection || name === commanderName) { + commanderName = name; + inCommanderSection = false; + } else { + for (let q = 0; q < qty; q++) parsedCards.push(name); + } + } + }); + + // Fall back to precon.cards array + const cardNames = parsedCards.length > 0 ? parsedCards : cards.filter(c => c !== commanderName); + + if (cardNames.length > 0 || commanderName) { + // Commander to command zone + if (commanderName) { + player.zones.command.push({ + name: commanderName, + imageUrl: '', + typeLine: 'Legendary Creature', + oracleText: '', + }); + } + // Library + cardNames.forEach(cardName => { + player.zones.library.push({ + name: cardName, + imageUrl: '', + typeLine: '', + oracleText: '', + }); + }); + // Shuffle library + for (let i = player.zones.library.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [player.zones.library[i], player.zones.library[j]] = [player.zones.library[j], player.zones.library[i]]; + } + // Draw opening hand (7 cards) + for (let d = 0; d < 7 && player.zones.library.length > 0; d++) { + player.zones.hand.push(player.zones.library.shift()); + } + updateZoneCountsForPlayer(playerIdx); + renderCommanderZone(playerIdx); + renderHandZone(playerIdx); + addLogEntry('' + player.name + ' loaded deck: ' + precon.name + ' (' + commanderName + ')'); + } +} + +// ============================================================ +// SEARCH SIDEBAR +// ============================================================ +let searchDebounceTimer = null; + +function initSearchSidebar() { + const input = document.getElementById('card-search-input'); + if (!input) return; + + input.addEventListener('input', () => { + clearTimeout(searchDebounceTimer); + const q = input.value.trim(); + if (q.length < 2) { + setSearchStatus('Type to search cards...'); + document.getElementById('card-results').innerHTML = ''; + return; + } + searchDebounceTimer = setTimeout(() => searchCards(q), 300); + }); + + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + clearTimeout(searchDebounceTimer); + const q = input.value.trim(); + if (q.length >= 1) searchCards(q); + } + }); +} + +function setSearchStatus(text) { + const el = document.getElementById('search-status'); + if (el) el.textContent = text; +} + +function showSearchSpinner(show) { + const el = document.getElementById('card-results'); + if (!el) return; + if (show) { + el.innerHTML = '
'; + setSearchStatus('Searching...'); + } +} + +function searchCards(query) { + if (!query) return; + showSearchSpinner(true); + const url = 'https://api.scryfall.com/cards/search?q=' + encodeURIComponent(query) + '&unique=cards&order=name&page=1'; + fetch(url) + .then(r => r.json()) + .then(data => { + if (data.data) { + renderSearchResults(data.data.slice(0, 20)); + setSearchStatus(data.total_cards + ' results'); + } else { + setSearchStatus('No results found'); + document.getElementById('card-results').innerHTML = '
No cards found for "' + query + '"
'; + } + }) + .catch(() => { + setSearchStatus('Search failed'); + document.getElementById('card-results').innerHTML = '
Search failed. Check internet connection.
'; + }); +} + +function getCardImage(card) { + if (card.image_uris) return card.image_uris.normal || card.image_uris.small; + if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) { + return card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.small; + } + return ''; +} + +function renderSearchResults(cards) { + const container = document.getElementById('card-results'); + if (!container) return; + container.innerHTML = ''; + + cards.forEach(card => { + const imgUrl = getCardImage(card); + const item = document.createElement('div'); + item.className = 'search-card-item'; + item.innerHTML = + (imgUrl ? '' + card.name + '' : + '
' + card.name + '
') + + '
' + + '
' + card.name + '
' + + '
' + (card.type_line || '') + '
' + + '
' + + ''; + + // Add to battlefield on click + const addCard = () => { + if (!gameState) return; + const ownerIdx = gameState.currentPlayerIndex; + addCardToBattlefield( + card.name, imgUrl, card.type_line || '', + ownerIdx, undefined, undefined, + card.oracle_text || card.card_faces?.[0]?.oracle_text || '', + { + manaCost: card.mana_cost || '', + power: card.power || '', + toughness: card.toughness || '', + pt: (card.power && card.toughness) ? card.power + '/' + card.toughness : '', + cmc: card.cmc || 0, + loyalty: card.loyalty || '', + } + ); + }; + + item.querySelector('.search-card-add-btn').onclick = e => { e.stopPropagation(); addCard(); }; + + item.addEventListener('mouseenter', e => { + if (imgUrl) showFloatingCardPreview(imgUrl, e, { + name: card.name, + 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 || '', + power: card.power, toughness: card.toughness, + }); + }); + item.addEventListener('mousemove', positionFloatingPreview); + item.addEventListener('mouseleave', hideFloatingCardPreview); + + container.appendChild(item); + }); +} + +// ============================================================ +// TRIGGER BADGE & STACK PANEL +// ============================================================ +function updateTriggerBadge() { + if (!gameState) return; + const pending = (gameState.triggerStack || []).filter(t => !t.resolved); + const btn = document.getElementById('btn-triggers'); + if (!btn) return; + btn.textContent = pending.length > 0 ? '❇ ' + pending.length : '❇'; + btn.classList.toggle('has-triggers', pending.length > 0); +} + +function openTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (panel) { panel.classList.add('visible'); renderTriggerStack(); } +} + +function closeTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (panel) panel.classList.remove('visible'); +} + +function toggleTriggerStack() { + const panel = document.getElementById('trigger-stack-panel'); + if (panel) { + if (panel.classList.contains('visible')) closeTriggerStack(); + else openTriggerStack(); + } +} + +function renderTriggerStack() { + const body = document.getElementById('trigger-stack-body'); + if (!body || !gameState) return; + + const pending = (gameState.triggerStack || []).filter(t => !t.resolved); + + if (pending.length === 0) { + body.innerHTML = '
No pending triggers.
'; + return; + } + + body.innerHTML = pending.map((trigger, idx) => { + const typeIcon = (typeof TRIGGER_TYPE_ICONS !== 'undefined' && TRIGGER_TYPE_ICONS[trigger.type]) || '❇'; + const typeLabel = (typeof TRIGGER_TYPE_LABELS !== 'undefined' && TRIGGER_TYPE_LABELS[trigger.type]) || trigger.type; + const ownerColor = (gameState.players[trigger.ownerIndex] || {}).color || '#888'; + return '
' + + '
' + typeIcon + '
' + + '
' + + '
' + (trigger.cardName || 'Unknown') + '
' + + '
' + typeLabel + '
' + + '
' + (trigger.oracleSnippet || '') + '
' + + '
' + + '
' + + '' + + '
'; + }).join(''); + + updateTriggerBadge(); +} + +// ============================================================ +// UNDO/REDO BUTTONS +// ============================================================ +function updateUndoRedoButtons() { + const undoBtn = document.getElementById('btn-undo'); + const redoBtn = document.getElementById('btn-redo'); + if (undoBtn) undoBtn.disabled = undoStack.length === 0; + if (redoBtn) redoBtn.disabled = redoStack.length === 0; + if (undoHistoryVisible) renderUndoHistory(); +} + +function renderUndoHistory() { + const panel = document.getElementById('undo-history-panel'); + if (!panel) return; + // Simple render of last 20 undo states + const items = undoStack.slice(-20).reverse(); + panel.innerHTML = + '
' + + 'Undo History
' + + '
' + + items.map((snap, i) => '
' + + (snap._description || 'State #' + (undoStack.length - i)) + '
').join('') + + '
'; +} + +function closeUndoHistory() { + undoHistoryVisible = false; + const panel = document.getElementById('undo-history-panel'); + if (panel) panel.classList.remove('visible'); +} + +// ============================================================ +// DICE MODAL +// ============================================================ +function openDiceModal() { + const modal = document.getElementById('dice-modal'); + if (modal) modal.classList.add('visible'); +} + +function closeDiceModal() { + const modal = document.getElementById('dice-modal'); + if (modal) modal.classList.remove('visible'); +} + +function rollDice(sides) { + const result = Math.floor(Math.random() * sides) + 1; + const display = document.getElementById('dice-result-display'); + const label = document.getElementById('dice-label'); + if (display) { + display.textContent = result; + display.classList.remove('rolling'); + void display.offsetWidth; // Reflow + display.classList.add('rolling'); + } + if (label) label.textContent = 'd' + sides + ' Roll'; + addLogEntry('🎲 Rolled d' + sides + ': ' + result + ''); +} + +function flipCoin() { + const result = Math.random() < 0.5 ? 'Heads' : 'Tails'; + const display = document.getElementById('dice-result-display'); + const label = document.getElementById('dice-label'); + if (display) { display.textContent = result === 'Heads' ? 'H' : 'T'; display.classList.remove('rolling'); void display.offsetWidth; display.classList.add('rolling'); } + if (label) label.textContent = 'Coin Flip'; + addLogEntry('🪙 Coin flip: ' + result + ''); +} + +// ============================================================ +// TOAST NOTIFICATIONS +// ============================================================ +function showToast(message) { + const toast = document.createElement('div'); + toast.className = 'toast-message'; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.4s'; + setTimeout(() => toast.remove(), 450); + }, 2800); +} + +// ============================================================ +// MANA POOL DISPLAY BUTTON +// ============================================================ +function showManaPool() { + const display = document.getElementById('mana-pool-display'); + if (display) { + display.style.display = display.style.display === 'none' ? 'flex' : 'none'; + } +} + +// ============================================================ +// SHORTCUT HINT +// ============================================================ +let _shortcutHintTimeout = null; + +function showShortcutHint() { + const hint = document.querySelector('.shortcut-hint'); + if (!hint) return; + const hints = [ + 'N next phase T next turn Space tap/untap', + 'U undo D draw card S search cards', + 'G dice roll Esc close panels', + ]; + hint.innerHTML = hints[Math.floor(Math.random() * hints.length)]; + hint.classList.add('show'); + clearTimeout(_shortcutHintTimeout); + _shortcutHintTimeout = setTimeout(() => hint.classList.remove('show'), 4000); +} + +// ============================================================ +// KEYBOARD SHORTCUTS +// ============================================================ +function setupKeyboardShortcuts() { + document.addEventListener('keydown', e => { + // Don't fire when typing in inputs + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; + if (!gameState || !gameState.inGame) return; + + switch (e.key.toLowerCase()) { + case 'n': if (!e.ctrlKey && !e.metaKey) { nextPhase(); e.preventDefault(); } break; + case 't': if (!e.ctrlKey && !e.metaKey) { nextTurn(); e.preventDefault(); } break; + case 'u': if (!e.ctrlKey) { undo(); e.preventDefault(); } break; + case 'y': if (!e.ctrlKey) { redo(); e.preventDefault(); } break; + case 'd': drawCard(gameState.currentPlayerIndex); break; + case 's': { + const sidebar = document.getElementById('card-search-sidebar'); + if (sidebar) { + sidebar.classList.toggle('collapsed'); + const inp = document.getElementById('card-search-input'); + if (inp && !sidebar.classList.contains('collapsed')) { inp.focus(); } + } + break; + } + case 'g': openDiceModal(); break; + case 'escape': { + hideContextMenu(); + closeZoneModal(); + closeDiceModal(); + if (typeof cancelStackPickMode === 'function') cancelStackPickMode(); + if (typeof cancelEquipPickMode === 'function') cancelEquipPickMode(); + break; + } + } + + if (e.ctrlKey && e.key === 'z') { undo(); e.preventDefault(); } + if (e.ctrlKey && e.key === 'y') { redo(); e.preventDefault(); } + }); +} + +// ============================================================ +// COMBAT OVERLAY (SVG arrows) +// ============================================================ +function refreshCombatOverlay() { + const svg = document.getElementById('combat-arrows-svg'); + if (!svg || !gameState || !gameState.combat) { return; } + + svg.innerHTML = ''; + const combat = gameState.combat; + if (!combat.attackers || combat.attackers.length === 0) return; + + // Draw arrows from attacker cards to the target player quadrant + combat.attackers.forEach(atk => { + const attackerEl = document.getElementById('bc-' + atk.cardId); + if (!attackerEl) return; + const targetPNum = atk.targetPlayerId + 1; + const targetArea = document.querySelector('.player-area.p' + targetPNum); + if (!targetArea) return; + + const svgRect = svg.getBoundingClientRect(); + const atkRect = attackerEl.getBoundingClientRect(); + const tgtRect = targetArea.getBoundingClientRect(); + + const x1 = atkRect.left + atkRect.width / 2 - svgRect.left; + const y1 = atkRect.top + atkRect.height / 2 - svgRect.top; + const x2 = tgtRect.left + tgtRect.width / 2 - svgRect.left; + const y2 = tgtRect.top + tgtRect.height / 2 - svgRect.top; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', x1); + line.setAttribute('y1', y1); + line.setAttribute('x2', x2); + line.setAttribute('y2', y2); + line.setAttribute('stroke', 'rgba(211,32,42,0.6)'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(line); + }); +} + +function updateCombatArrows() { refreshCombatOverlay(); } +function showCombatOverlay() { refreshCombatOverlay(); } + +// ============================================================ +// SETTINGS PANEL +// ============================================================ +function toggleSettingsPanel() { + const panel = document.getElementById('settings-panel'); + if (panel) panel.classList.toggle('visible'); + if (panel && panel.classList.contains('visible')) renderSettingsPanel(); +} + +function renderSettingsPanel() { + if (!gameState) return; + const body = document.getElementById('settings-body'); + if (!body) return; + const re = gameState.rulesEngine || {}; + body.innerHTML = + '
Auto Untap' + renderToggle('autoUntap', re.autoUntap) + '
' + + '
Auto Draw' + renderToggle('autoDraw', re.autoDraw) + '
' + + '
SBA Checks' + renderToggle('sbaChecks', re.sbaChecks) + '
' + + '
Commander Tax' + renderToggle('commanderTaxEnabled', re.commanderTaxEnabled) + '
' + + '
Auto Resolve Triggers' + renderToggle('autoResolveTriggers', re.autoResolveTriggers) + '
' + + '
Enforce Hand Size' + renderToggle('enforceHandSize', re.enforceHandSize) + '
' + + '
Auto Eliminate on SBA' + renderToggle('autoEliminateOnSBA', re.autoEliminateOnSBA) + '
' + + '
Mana Auto-Clear' + renderToggle('manaAutoClear', gameState.manaAutoClear) + '
'; +} + +function renderToggle(key, value) { + return ''; +} + +function toggleSetting(key) { + if (!gameState) return; + if (key === 'manaAutoClear') { + gameState.manaAutoClear = !gameState.manaAutoClear; + } else if (gameState.rulesEngine && gameState.rulesEngine[key] !== undefined) { + gameState.rulesEngine[key] = !gameState.rulesEngine[key]; + } + renderSettingsPanel(); + addLogEntry('Setting ' + key + ' toggled'); +} + +// ============================================================ +// PLACEHOLDER STUBS for engine-called UI functions +// (engine.js calls these — we provide no-op or simple implementations) +// ============================================================ + +function renderPlayerPanelsNoOp() { renderPlayerPanels(); } +function showManaShortfallWarning(cardName, shortfall, playerIndex) { + showToast('Mana shortfall for ' + cardName + ': need ' + shortfall + ' more mana'); +} + +function closeManaShortfallModal() {} +function renderMulliganUI() {} // Mulligan handled by engine - UI prompt via showToast +function openDeckView(playerId) { openZoneModal(playerId, 'library'); } +function closeDeckView() { closeZoneModal(); } +function renderDeckView() { renderZoneModal(); } +function openFullLogModal() { showToast('Full log: ' + _logEntries.length + ' entries'); } +function closeFullLogModal() {} +function renderFullLog() {} +function renderTimerDisplay() {} +function updateTimerDisplay(playerId) {} +function renderAllTimerDisplays() {} +function flashTimerWarning(playerId) {} +function renderBattlefieldZoneLabels() {} +function renderZonePanel() { updatePlayerZones(); } +function renderZonePanelBody() { renderZoneModal(); } +function showZpContextMenu(x, y, player, zone, cardIndex) { showZoneCardCtx(x, y, player, zone, cardIndex); } +function showHandContextMenu(x, y, playerId, handIndex) { + // Show context menu for hand card + if (!gameState) return; + const player = gameState.players[playerId]; + if (!player || !player.zones.hand[handIndex]) return; + showZoneCardCtx(x, y, playerId, 'hand', handIndex); +} +function renderDeckStats() {} +function updateDeckSummary(playerId) {} +function updateDeckImportProgress(loaded, total, message) {} +function showPreconDetail(preconId) {} +function closePreconDetail() {} +function showManaPickerModal() {} +function closeManaPickerModal() {} +function updatePlayerNameFields() {} +function updateAiToggleGrid() {} +function renderDeckSelectionGrid() {} +function renderTrainingUI() {} +function openAiTrainingModal() {} +function closeAiTrainingModal() {} +function renderDeckTesterProgress() {} +function renderDeckTesterResults() {} +function openDeckTester() {} +function closeDeckTester() {} +function renderDeckTesterSetup() {} +function updateMonarchBadges() { renderPlayerPanels(); } +function openShortcutsModal() {} +function closeShortcutsModal() {} +function renderShortcutsContent() {} +function openGuideModal() {} +function closeGuideModal() {} +function renderAiThoughtBubble(playerIdx, thought) {} +function showAiCastPopup(card, aiPlayerIdx, handIndex, aiTarget) {} +function closeSaveLoadModal() {} +function renderCollectionGrid() {} +function renderWishlistGrid() {} +function openCollectionAddModal() {} +function closeCollectionAddModal() {} +function renderDbResultGrid(cards) {} +function addCardToDeck(card) {} +function closeDbCtxMenu() {} +function renderDbCommanderSlot() {} +function renderDbList() {} +function updateDbCountDisplay() {} +function renderDbStats() {} +function renderDbCurve() {} +function renderDbColors() {} +function renderDbTypes() {} +function updateCardLegalityDots() {} +function renderDbSavedDecks() {} +function openDeckBuilderImport() {} +function closeDbImportModal() {} +function showImportProgress(current, total, text) {} +function renderSettingsRulesTab() { renderSettingsPanel(); } +function renderSettingsDisplayTab() {} +function renderSettingsGameplayTab() {} +function renderSettingsAITab() {} +function updateDisplaySetting(key, value) {} +function updateGameplaySetting(key, value) { toggleSetting(key); } +function updateAIDifficulty(diff) {} +function updateAISetting(key, value) {} +function renderStackPanel() {} +function updateUnifiedStack() {} +function openPriorityWindow(reason, onAllPass) {} +function closePriorityWindow() {} +function renderPriorityBanner() {} +function showPriorityActionMode() {} +function hidePriorityBanner() {} +function renderLegalActionsBar() {} +function showStackBlockedWarning() { + addLogEntry('⚠ Resolve all stack items before advancing.'); +} +function isPriorityBlocking() { return false; } +function isPriorityEnabled() { + return gameState && gameState.rulesEngine && gameState.rulesEngine.useStack; +} +function showTargetingWarningBadge(targetCardId, sourceOwnerId) {} +function showColorIdentityWarning(cardName, violations) { + showToast('Color identity warning: ' + cardName); +} +function showCommanderZonePrompt(cardId, cardData, intendedZone) { + // Auto-send to command zone + if (typeof moveCardToZone === 'function') moveCardToZone(cardId, 'command'); +} +function showDiscardPrompt(playerId, count) { + addLogEntry('' + (gameState.players[playerId] || {}).name + ' must discard ' + count + ' card(s).'); +} +function closeDiscardPrompt() {} +function toggleRulesPanel() { toggleSettingsPanel(); } +function closeRulesPanel() { document.getElementById('settings-panel') && document.getElementById('settings-panel').classList.remove('visible'); } +function renderTurnHistory() {} +function updateBattlefieldSectionCounts() {} +function buildBattlefieldSections() {} +function buildOwnerFilterButtons() {} +function applyBattlefieldFilter() {} +function autoArrangeBattlefield() { + if (!gameState) return; + gameState.battlefieldCards.forEach((card, idx) => { + const pNum = card.ownerIndex + 1; + const bz = document.getElementById('battle-zone-' + pNum); + if (!bz) return; + const cols = Math.ceil(Math.sqrt(gameState.battlefieldCards.filter(c => c.ownerIndex === card.ownerIndex).length)); + const col = idx % cols; + const row = Math.floor(idx / cols); + card.x = col * 110 + 5; + card.y = row * 145 + 5; + const el = document.getElementById('bc-' + card.id); + if (el) { el.style.left = card.x + 'px'; el.style.top = card.y + 'px'; } + }); +} +function setupBattlefieldDrop() {} +function showDamagePreview() {} +function renderDamagePreviewContent() {} +function showFloatingDamage(elementId, amount, type) { + const el = document.getElementById(elementId); + if (!el) return; + const damage = document.createElement('div'); + damage.className = 'floating-damage'; + damage.textContent = (type === 'loss' ? '-' : '+') + amount; + damage.style.color = type === 'loss' ? '#e74c3c' : '#2ecc71'; + damage.style.left = (el.offsetLeft + el.offsetWidth / 2) + 'px'; + damage.style.top = (el.offsetTop - 20) + 'px'; + el.parentNode.appendChild(damage); + setTimeout(() => damage.remove(), 1200); +} +function updateDamageBadge(cardData) { + const el = document.getElementById('bc-' + cardData.id); + if (!el) return; + let badge = el.querySelector('.damage-badge'); + if (cardData.damageMarked > 0) { + if (!badge) { badge = document.createElement('div'); badge.className = 'damage-badge'; el.appendChild(badge); } + badge.textContent = cardData.damageMarked; + badge.style.display = 'flex'; + } else if (badge) { + badge.style.display = 'none'; + } +} +function showCombatRecap(logLines) { + if (logLines && logLines.length > 0) { + logLines.forEach(line => addLogEntry(line)); + } +} +function showPlaneswalkerAbilities(cardId) {} +function closePlaneswalkerAbilities() {} +function openScryModal(playerId, count) { showScryDialog(playerId); } +function closeScryModal() {} +function renderScryZones() {} +function renderScryZone(containerId, cards, zone) {} +function openTokenModal(playerId) { + const modal = document.getElementById('token-modal'); + if (modal) modal.classList.add('visible'); +} +function closeTokenModal() { + const modal = document.getElementById('token-modal'); + if (modal) modal.classList.remove('visible'); +} +function updateTokenPreview() {} +function addToTokenHistory(entry) {} +function renderTokenHistory() {} +function renderTokenBrowserResults(tokens) {} +function updateTokenSummaryBar() {} +function openDeckModal(playerId) { + const modal = document.getElementById('deck-modal'); + if (modal) { + modal.classList.add('visible'); + renderDeckModalContent(playerId); + } +} +function closeDeckModal() { + const modal = document.getElementById('deck-modal'); + if (modal) modal.classList.remove('visible'); +} +function renderDeckModalContent(playerId) { + const body = document.getElementById('deck-modal-body'); + if (!body || !gameState) return; + const player = gameState.players[playerId]; + if (!player) return; + body.innerHTML = + '

' + player.name + "'s Deck Manager

" + + '
' + + '
Commander: ' + (player.commanderCardName || 'Not set') + '
' + + '
Library: ' + player.zones.library.length + ' cards | Hand: ' + player.zones.hand.length + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
'; +} + +window.importDeckFromTextarea = function(playerId) { + const textarea = document.getElementById('deck-import-text-' + playerId); + if (!textarea || !gameState) return; + if (typeof importDeckList === 'function') { + importDeckList(playerId, textarea.value); + } else { + showToast('Deck import processing...'); + } +}; + +function openPreconBrowser() { + showToast('Precon browser: select a deck from the setup screen dropdown.'); +} + +// ============================================================ +// DOM UTILITY — updateCardCounterBadge +// ============================================================ +function updateCardCounterBadge(cardId) { + if (!gameState) return; + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card) return; + const el = document.getElementById('bc-' + cardId); + if (!el) return; + // Re-render counter badges + const existingStrip = el.querySelector('.card-counters-strip'); + if (existingStrip) existingStrip.remove(); + + if (card.counters) { + const cTypes = Object.keys(card.counters).filter(k => card.counters[k] > 0); + if (cTypes.length > 0) { + const strip = document.createElement('div'); + strip.className = 'card-counters-strip'; + cTypes.forEach(cType => { + const cCount = card.counters[cType]; + const ct = (typeof COUNTER_TYPES !== 'undefined' && COUNTER_TYPES[cType]) || { icon: '\u25CF', color: '#9ca3af', label: cType }; + const badge = document.createElement('span'); + badge.className = 'card-counter-badge'; + badge.style.cssText = 'background:' + ct.color + ';color:#fff;'; + if (cType === '+1/+1') badge.textContent = '+' + cCount + '/+' + cCount; + else if (cType === '-1/-1') badge.textContent = '-' + cCount + '/-' + cCount; + else badge.textContent = ct.icon + cCount; + strip.appendChild(badge); + }); + el.appendChild(strip); + } + } +} + +// ============================================================ +// DOM — toggleTap visual update +// ============================================================ +function _refreshCardEl(cardId) { + if (!gameState) return; + const card = gameState.battlefieldCards.find(c => c.id === cardId); + if (!card) return; + const el = document.getElementById('bc-' + cardId); + if (!el) return; + if (card.tapped) el.classList.add('tapped'); + else el.classList.remove('tapped'); +} + +// ============================================================ +// DOMContentLoaded — wire up everything +// ============================================================ +document.addEventListener('DOMContentLoaded', () => { + initSetupScreen(); + initSearchSidebar(); + setupKeyboardShortcuts(); + + // Sidebar toggle + const sidebarTab = document.getElementById('sidebar-edge-tab'); + if (sidebarTab) { + sidebarTab.onclick = () => { + const sidebar = document.getElementById('card-search-sidebar'); + if (sidebar) sidebar.classList.toggle('collapsed'); + }; + } + + // Close modal clicks + document.getElementById('zone-modal') && document.getElementById('zone-modal').addEventListener('click', e => { + if (e.target.id === 'zone-modal') closeZoneModal(); + }); + document.getElementById('dice-modal') && document.getElementById('dice-modal').addEventListener('click', e => { + if (e.target.id === 'dice-modal') closeDiceModal(); + }); + document.getElementById('token-modal') && document.getElementById('token-modal').addEventListener('click', e => { + if (e.target.id === 'token-modal') closeTokenModal(); + }); + document.getElementById('deck-modal') && document.getElementById('deck-modal').addEventListener('click', e => { + if (e.target.id === 'deck-modal') closeDeckModal(); + }); +});