/** * 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.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; }); 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.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; }); 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.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; }); 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.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; }); 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 = 63, cardH = 84; // Zone regions (percentage of section area) // Offset from edges to avoid overlapping the overlaid zone-row/commander-row var zones = { creature: { x1: 0.25, x2: 0.70, y1: 0.20, y2: 0.55 }, artifact: { x1: 0.70, x2: 0.95, y1: 0.20, y2: 0.55 }, land: { x1: 0.03, x2: 0.35, y1: 0.55, y2: 0.85 }, spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.85 }, other: { x1: 0.25, x2: 0.70, y1: 0.30, y2: 0.70 } }; 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(); if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline(); } // ============================================================ // 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(); if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline(); } // ============================================================ // 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(); } // ============================================================ // 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) if (typeof _enrichCardFromCache === 'function') _enrichCardFromCache(card); 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 — supports "// Commander", "Commander:", "[Commander]", "[Main]", "[metadata]" if (/^\/\/\s*/i.test(line) || /^\[?(COMMANDER|COMPANION|SIDEBOARD|MAYBEBOARD|MAINBOARD|MAIN|DECK|LAND|METADATA)\]?[:\s]*$/i.test(line)) { const cleaned = line.replace(/^\/\/\s*/, '').replace(/[\[\]:]/g, '').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 if (/metadata/i.test(cleaned)) currentSection = 'metadata'; else currentSection = 'mainboard'; continue; } // Skip metadata lines like "Name=Elven Empire" if (currentSection === 'metadata') 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; panel.innerHTML = '
' + '
' + '' + playerName + '' + 'No deck selected' + '
' + '
' + '' + '' + '
' + '
' + '' + '
' + '
' + '
' + '
Loading user decks...
' + '
' + '
'; } grid.appendChild(panel); } // Fetch user-created decks from the API and populate My Decks tabs _loadUserDecksForSetup(); } function _loadUserDecksForSetup() { var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; fetch(apiBase + '/api/lab/decks') .then(function(res) { return res.json(); }) .then(function(data) { var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; }); for (var i = 0; i < setupPlayerCount; i++) { if (setupAiPlayers.has(i)) continue; var listEl = document.getElementById('setup-custom-deck-list-' + i); if (!listEl) continue; if (decks.length === 0) { listEl.innerHTML = '
No saved decks. Build one in Deck Builder first.
'; continue; } var html = ''; for (var di = 0; di < decks.length; di++) { var d = decks[di]; var cardCount = d.totalCards || d.card_count || d.total_cards || 0; var cmdr = d.commander || ''; html += ''; } listEl.innerHTML = html; } }) .catch(function(e) { console.warn('[Setup] Could not load user decks:', e); for (var i = 0; i < setupPlayerCount; i++) { if (setupAiPlayers.has(i)) continue; var listEl = document.getElementById('setup-custom-deck-list-' + i); if (listEl) listEl.innerHTML = '
Could not load decks. Is the server running?
'; } }); } function selectSetupUserDeck(playerIdx, deckId, deckName) { var statusEl = document.getElementById('setup-deck-status-' + playerIdx); // Highlight selected button var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx); if (listEl) { listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); }); } // Find clicked button and highlight it var allBtns = listEl ? listEl.querySelectorAll('.setup-custom-deck-btn') : []; allBtns.forEach(function(b) { if (b.textContent.indexOf(deckName) !== -1) b.classList.add('selected'); }); // Export the deck to a .dck file, then store it as a precon-style choice var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; } fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' }) .then(function(res) { return res.json(); }) .then(function(result) { if (result.success) { setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' }; if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; } } else { if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; } } }) .catch(function(e) { console.error('[Setup] Export failed:', e); if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; } }); } function switchSetupDeckTab(playerIdx, tab) { var tabs = ['precon', 'mydecks']; 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'; } } 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; // Try to get decklist text: embedded first, then fetch .dck from server var decklistText = precon.decklist || ''; if (!decklistText && precon.fileName) { // 1. Try the API endpoint (returns JSON with decklist field) try { var apiResp = await fetch('/api/lab/precons/deck?fileName=' + encodeURIComponent(precon.fileName)); if (apiResp.ok) { var deckData = await apiResp.json(); if (deckData && deckData.decklist) decklistText = deckData.decklist; } } catch (e) { console.warn('[loadPreconForPlayer] API deck fetch failed:', e); } // 2. Fallback: fetch .dck file directly from static mount if (!decklistText) { try { var staticResp = await fetch('/precon-decks/' + encodeURIComponent(precon.fileName)); if (staticResp.ok) decklistText = await staticResp.text(); } catch (e2) { console.warn('[loadPreconForPlayer] Static .dck fetch failed:', precon.fileName); } } } var parsedCards = parseDeckList(decklistText); 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 || ''; } if (precon.fileName) { player.deckFileName = precon.fileName; } // 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 loadUserDeckForPlayer(playerIdx, deckName) { var player = gameState.players[playerIdx]; if (!player || !deckName) return; // Fetch the .dck file content from user-decks/ via static mount or API var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : ''; var deckText = ''; try { var resp = await fetch(apiBase + '/user-decks/' + encodeURIComponent(deckName) + '.dck'); if (resp.ok) deckText = await resp.text(); } catch (e) { console.warn('[loadUserDeckForPlayer] Could not fetch .dck file:', e); } if (!deckText) { addLogEntry('\u26A0 Could not load user deck "' + deckName + '"'); return; } await importDeckForPlayer(playerIdx, deckText); player.deckFileName = deckName + '.dck'; } async function importDeckForPlayer(playerIdx, deckText) { var player = gameState.players[playerIdx]; if (!player || !deckText.trim()) return; 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 === 'user-deck') { await loadUserDeckForPlayer(hd.idx, hd.choice.deckName); } } } 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(); if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline(); if (typeof refreshLeftPanelTriggers === 'function') refreshLeftPanelTriggers(); // 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: Declare attacks for an AI player (used by ai-bridge) ---- function aiDeclareAttacks(playerIdx) { if (!gameState) return; var player = gameState.players[playerIdx]; if (!player || player.eliminated) return; var difficulty = gameState.aiDifficulty || 'easy'; var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) { return !hasSummoningSickness(c); }); if (creatures.length === 0) return; var attackerCards = []; if (difficulty === 'easy') { attackerCards = creatures.filter(function() { return Math.random() > 0.5; }); if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]]; } else { var primaryTarget = aiPickAttackTarget(playerIdx); if (primaryTarget === -1) return; creatures.forEach(function(c) { var eval_ = aiEvaluateAttack(c, primaryTarget); if (difficulty === 'normal') { if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c); } else { if (eval_.shouldAttack) attackerCards.push(c); else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c); } }); if (difficulty === 'hard' && attackerCards.length > 2) { var sortedByToughness = attackerCards.slice().sort(function(a, b) { return getCreatureToughness(b) - getCreatureToughness(a); }); var holdBack = sortedByToughness.find(function(c) { return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3; }); if (holdBack) { attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; }); } } } if (attackerCards.length === 0) return; var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty); if (assignments.length === 0) return; gameState.combat.attackers = assignments.map(function(a) { return { cardId: a.card.id, targetPlayerId: a.targetIdx }; }); attackerCards.forEach(function(c) { if (!hasKeyword(c, 'vigilance')) { c.tapped = true; var el = document.getElementById('bc-' + c.id); if (el) el.classList.add('tapped'); } var el2 = document.getElementById('bc-' + c.id); if (el2) el2.classList.add('attacking'); }); fireBattlefieldTriggers('attack'); } // ---- AI Helper: Check if AI has a counterspell and should use it ---- function aiCheckForCounterspell(playerIdx) { if (!gameState) return null; 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) { 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 // Hard: play held-back removal + instants + any remaining affordable spells // Normal/Easy: try to play any remaining affordable spells // BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers // ======================================== { 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) {} } } // Expose functions needed by ai-bridge.js window.aiTryCastCommander = aiTryCastCommander; window.aiDeclareAttacks = aiDeclareAttacks; window.playCardFromHand = playCardFromHand; window.aiCountUntappedLands = aiCountUntappedLands;