/**
* 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,
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 += getManaColorName(c) + ' ';
}
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 += '';
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 += '
Activate ';
html += '
';
}
html += '
';
panel.innerHTML = html;
document.body.appendChild(panel);
}
function closeActivatedAbilityPanel() {
var el = document.getElementById('activated-ability-panel');
if (el) el.remove();
}
// ============================================================
// 3. REPLACEMENT EFFECTS ENGINE
// ============================================================
/**
* Replacement effects registry.
* Each replacement effect is an object:
* {
* id: unique,
* sourceCardId: battlefield card id,
* ownerIndex: player index,
* event: 'draw' | 'damage' | 'die' | 'gain_life' | 'lose_life' | 'etb' | 'to_graveyard',
* condition: function(eventData) -> bool,
* replace: function(eventData) -> modifiedEventData,
* description: string
* }
*/
var replacementEffects = [];
var nextReplacementId = 1;
/**
* Register a replacement effect from a card on the battlefield
*/
function registerReplacementEffect(effect) {
effect.id = nextReplacementId++;
replacementEffects.push(effect);
return effect.id;
}
/**
* Remove all replacement effects from a specific card
*/
function removeReplacementEffects(sourceCardId) {
replacementEffects = replacementEffects.filter(function(r) {
return r.sourceCardId !== sourceCardId;
});
}
/**
* Apply replacement effects to an event.
* Returns the modified event data (or null if the event is fully replaced/prevented).
*/
function applyReplacements(eventType, eventData) {
var applicable = replacementEffects.filter(function(r) {
return r.event === eventType && (!r.condition || r.condition(eventData));
});
if (applicable.length === 0) return eventData;
var result = eventData;
for (var i = 0; i < applicable.length; i++) {
if (!result) break; // Event was fully prevented
result = applicable[i].replace(result);
if (result && result._prevented) {
addLogEntry('>> Replacement: ' + applicable[i].description);
return null;
}
if (result && result._modified) {
addLogEntry('>> Replacement: ' + applicable[i].description);
}
}
return result;
}
/**
* Scan battlefield cards and register known replacement effects.
* Call this whenever a card enters or leaves the battlefield.
*/
function scanReplacementEffects() {
replacementEffects = [];
if (!gameState) return;
gameState.battlefieldCards.forEach(function(card) {
var oracle = (card.oracleText || '').toLowerCase();
var oi = card.ownerIndex;
// "If you would draw a card, instead..." (e.g., Notion Thief for opponents)
if (/if .* would draw a card.* instead/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'draw',
condition: function(data) {
// Check if applies to controller or opponents based on card text
if (/each opponent/i.test(oracle) || /another player/i.test(oracle)) {
return data.playerIdx !== oi;
}
return data.playerIdx === oi;
},
replace: function(data) {
if (/that player.*instead/i.test(oracle) || /you draw/i.test(oracle)) {
// Redirect draw to controller
data.playerIdx = oi;
data._modified = true;
}
return data;
},
description: card.name + ' replaces a draw'
});
}
// "If a creature would die, exile it instead" (e.g., Rest in Peace)
if (/would (?:die|be put into a graveyard).*exile.*instead/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'die',
condition: function() { return true; },
replace: function(data) {
data.destination = 'exile';
data._modified = true;
return data;
},
description: card.name + ' exiles instead of graveyard'
});
}
// "If you would gain life, you lose that much life instead" (e.g., Tainted Remedy for opponents)
if (/would gain life.*lose that much life instead/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'gain_life',
condition: function(data) {
// Usually applies to opponents
if (/opponent/i.test(oracle)) return data.playerIdx !== oi;
return true;
},
replace: function(data) {
data.amount = -Math.abs(data.amount);
data._modified = true;
return data;
},
description: card.name + ' reverses life gain to life loss'
});
}
// "Damage can't be prevented" (e.g., Leyline of Punishment) â flag
if (/damage.*can't be prevented/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'prevent_damage',
condition: function() { return true; },
replace: function(data) {
data._prevented = false; // Override any prevention
data._modified = true;
return data;
},
description: card.name + ' prevents damage prevention'
});
}
// "If a nontoken creature you control would die, instead [effect]"
if (/nontoken creature you control would die.*instead/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'die',
condition: function(data) {
return data.ownerIndex === oi && !data.isToken;
},
replace: function(data) {
// Common: return to hand, exile, etc.
if (/return.*to.*hand/i.test(oracle)) {
data.destination = 'hand';
} else if (/exile/i.test(oracle)) {
data.destination = 'exile';
}
data._modified = true;
return data;
},
description: card.name + ' replaces creature death'
});
}
// "If a permanent entering the battlefield causes a triggered ability..."
// "Enters the battlefield tapped" is handled by applyStaticETBEffects already
// "Double that amount" / "If you would create a token, create an additional"
if (/create (?:one|an?) additional/i.test(oracle) || /twice that many.*instead/i.test(oracle)) {
if (/token/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'create_token',
condition: function(data) { return data.ownerIndex === oi; },
replace: function(data) {
data.count = (data.count || 1) * 2;
data._modified = true;
return data;
},
description: card.name + ' doubles token creation'
});
}
}
// "If you would gain life, gain twice that much instead" (e.g., Boon Reflection)
if (/would gain life.*gain twice that much/i.test(oracle)) {
registerReplacementEffect({
sourceCardId: card.id,
ownerIndex: oi,
event: 'gain_life',
condition: function(data) { return data.playerIdx === oi; },
replace: function(data) {
data.amount = data.amount * 2;
data._modified = true;
return data;
},
description: card.name + ' doubles life gain'
});
}
});
}
// ============================================================
// 4. DAMAGE MARKING & CLEANUP STEP
// ============================================================
/**
* Mark damage on a creature (instead of instant kill).
* Damage is tracked on cardData.damageMarked.
* Creatures die from SBA check when damageMarked >= toughness.
*/
function markDamage(cardId, amount, sourceData) {
if (!gameState || amount <= 0) return;
var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; });
if (!card) return;
// Apply replacement effects to damage
var eventData = applyReplacements('damage', {
targetCardId: cardId,
amount: amount,
source: sourceData || null,
ownerIndex: card.ownerIndex
});
if (!eventData) return; // Damage was prevented
card.damageMarked = (card.damageMarked || 0) + (eventData.amount || amount);
// Update visual
var el = document.getElementById('bc-' + cardId);
if (el) {
var badge = el.querySelector('.damage-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'damage-badge';
el.appendChild(badge);
}
badge.textContent = card.damageMarked;
badge.style.display = card.damageMarked > 0 ? 'flex' : 'none';
}
return card.damageMarked;
}
/**
* Clear all damage marked on all creatures (cleanup step)
*/
function clearAllDamageMarks() {
if (!gameState) return;
gameState.battlefieldCards.forEach(function(card) {
if (card.damageMarked && card.damageMarked > 0) {
card.damageMarked = 0;
var el = document.getElementById('bc-' + card.id);
if (el) {
var badge = el.querySelector('.damage-badge');
if (badge) badge.style.display = 'none';
}
}
});
}
/**
* Remove "until end of turn" effects from all cards
*/
function clearEndOfTurnEffects() {
if (!gameState) return;
gameState.battlefieldCards.forEach(function(card) {
// Clear temp keywords
if (card.tempKeywords && card.tempKeywords.length > 0) {
addLogEntry('>> Cleanup: Removed temporary keywords from ' + card.name + ' ');
card.tempKeywords = [];
}
// Clear power/toughness mods from "until end of turn" effects
if (card.powerMod && card.powerMod !== 0) {
card.powerMod = 0;
}
if (card.toughnessMod && card.toughnessMod !== 0) {
card.toughnessMod = 0;
}
});
}
/**
* Full cleanup step â called at end of each turn.
* 1. Remove "until end of turn" effects
* 2. Clear damage marked on creatures
* 3. Discard to hand size (already handled)
* 4. Clear mana pools (if auto-clear enabled)
*/
function performCleanupStep() {
if (!gameState) return;
addLogEntry('Phase: Cleanup Step ');
// 1. Remove temporary effects
clearEndOfTurnEffects();
// 2. Clear damage
clearAllDamageMarks();
// 3. Hand size enforcement (already exists elsewhere)
// 4. Clear mana pools
if (gameState.manaAutoClear !== false) {
clearAllManaPools();
}
// 5. Check SBA after cleanup
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) {
checkStateBasedActions();
}
renderPlayerPanels();
}
/**
* Enhanced SBA check: creatures with damage >= toughness die
*/
function checkDamageBasedSBA() {
if (!gameState) return [];
var alerts = [];
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/creature/i.test(card.typeLine)) return;
if (!card.damageMarked || card.damageMarked <= 0) return;
var toughness = getCreatureToughness(card);
if (card.damageMarked >= toughness) {
if (hasKeyword(card, 'indestructible')) return;
alerts.push({
type: 'lethal_damage_marked',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' has ' + card.damageMarked + ' damage marked (toughness ' + toughness + ') -- lethal',
});
}
});
return alerts;
}
// ============================================================
// UNDO / REDO SYSTEM
// ============================================================
const UNDO_MAX = 100;
let undoStack = [];
let redoStack = [];
let undoDisabled = false; // temporarily suppress snapshots during restore
function deepCloneState() {
if (!gameState) return null;
const snapshot = {
gameState: JSON.parse(JSON.stringify(gameState)),
nextStackGroupId: nextStackGroupId,
};
return snapshot;
}
function pushUndoState(description) {
if (undoDisabled || !gameState) return;
const snap = deepCloneState();
if (!snap) return;
if (description) snap._description = description;
undoStack.push(snap);
if (undoStack.length > UNDO_MAX) undoStack.shift();
redoStack = []; // any new action clears redo
updateUndoRedoButtons();
}
function undo() {
if (undoStack.length === 0 || !gameState) return;
// Push current state onto redo
const current = deepCloneState();
if (current) redoStack.push(current);
// Pop previous state
const prev = undoStack.pop();
restoreFromSnapshot(prev);
addLogEntry('\u21A9\uFE0F Undo ');
updateUndoRedoButtons();
}
function redo() {
if (redoStack.length === 0 || !gameState) return;
// Push current state onto undo
const current = deepCloneState();
if (current) undoStack.push(current);
// Pop redo state
const next = redoStack.pop();
restoreFromSnapshot(next);
addLogEntry('\u21AA\uFE0F Redo ');
updateUndoRedoButtons();
}
function restoreFromSnapshot(snapshot) {
if (!snapshot) return;
undoDisabled = true;
// Restore game state
gameState = snapshot.gameState;
nextStackGroupId = snapshot.nextStackGroupId;
// Clear existing battlefield card DOM elements (from section areas and battlefield fallback)
document.querySelectorAll('.bf-section-area .battlefield-card, #battlefield > .battlefield-card').forEach(function(c) { c.remove(); });
// Rebuild section divs for the restored game state (player count may differ)
buildBattlefieldSections();
// Re-render all battlefield cards
for (const cardData of gameState.battlefieldCards) {
renderBattlefieldCard(cardData);
}
refreshStackVisuals();
// Re-render all UI panels
renderPlayerPanels();
renderHandArea();
renderPhaseTracker();
updateTurnDisplay();
updateStormCounter();
renderManaPool();
updatePlayerZones();
// Sync mana auto-clear checkbox
const acCb = document.getElementById('mana-autoclear-toggle');
if (acCb) acCb.checked = gameState.manaAutoClear !== false;
// Hide battlefield label if cards exist
var bfEl = document.getElementById('battlefield');
if (gameState.battlefieldCards.length > 0) {
var label2 = bfEl ? bfEl.querySelector('.battlefield-label') : null;
if (label2) label2.style.opacity = '0';
} else {
var label3 = bfEl ? bfEl.querySelector('.battlefield-label') : null;
if (label3) label3.style.opacity = '';
}
undoDisabled = false;
}
function updateUndoRedoButtons() {
// Refresh undo history panel if visible
if (undoHistoryVisible) renderUndoHistory();
const undoBtn = document.getElementById('btn-undo');
const redoBtn = document.getElementById('btn-redo');
if (undoBtn) {
undoBtn.disabled = undoStack.length === 0;
var lastSnap = undoStack.length > 0 ? undoStack[undoStack.length - 1] : null;
var undoDesc = lastSnap && lastSnap._description ? lastSnap._description : 'last action';
undoBtn.title = undoStack.length > 0 ? `Undo: ${undoDesc} (${undoStack.length} steps)` : 'Nothing to undo';
// Badge showing count
var undoBadge = undoBtn.querySelector('.undo-count-badge');
if (!undoBadge) {
undoBadge = document.createElement('span');
undoBadge.className = 'undo-count-badge';
undoBtn.appendChild(undoBadge);
}
undoBadge.textContent = undoStack.length > 0 ? undoStack.length : '';
undoBadge.style.display = undoStack.length > 0 ? 'inline' : 'none';
}
if (redoBtn) {
redoBtn.disabled = redoStack.length === 0;
redoBtn.title = redoStack.length > 0 ? `Redo (${redoStack.length} steps)` : 'Nothing to redo';
}
}
// ============================================================
// TRIGGER SYSTEM â PARSING ENGINE
// ============================================================
let nextTriggerId = 1;
const TRIGGER_TYPE_ICONS = {
etb: 'âĶ',
ltb: 'âĐ',
dies: 'â ',
cast: 'âĶ',
attack: 'â',
block: 'ðĄ',
upkeep: 'â',
combat_begin: 'â',
end_step: 'â',
damage: 'ðĨ',
life_gain: 'âĨ',
life_loss: 'âĶ',
draw: 'ðĨ',
discard: 'ð',
mill: 'âŽ',
token_create: 'âĻ',
sacrifice: 'ð',
landfall: 'ð',
// New trigger types
enchantment_etb: 'â§',
artifact_etb: 'â',
counter_placed: 'â',
equipped: 'ðĄ',
target: 'ðŊ',
tap: 'â·',
transform: 'ð',
exile_from_grave: '⎥',
mana_spent: 'â',
commander_cast: 'ð',
player_eliminated: 'ð',
combat_damage_player: 'â',
};
const TRIGGER_TYPE_LABELS = {
etb: 'Enters the Battlefield',
ltb: 'Leaves the Battlefield',
dies: 'Dies',
cast: 'Cast',
attack: 'Attacks',
block: 'Blocks',
upkeep: 'Beginning of Upkeep',
combat_begin: 'Beginning of Combat',
end_step: 'End Step',
damage: 'Deals Damage',
life_gain: 'Life Gained',
life_loss: 'Life Lost',
draw: 'Card Drawn',
discard: 'Card Discarded',
mill: 'Milled',
token_create: 'Token Created',
sacrifice: 'Sacrificed',
landfall: 'Landfall',
// New trigger types
enchantment_etb: 'Enchantment Enters Battlefield',
artifact_etb: 'Artifact Enters Battlefield',
counter_placed: 'Counter Placed',
equipped: 'Equipped/Attached',
target: 'Becomes Target',
tap: 'Creature Tapped',
transform: 'Transforms',
exile_from_grave: 'Exiled from Graveyard',
mana_spent: 'Mana Spent',
commander_cast: 'Commander Cast',
player_eliminated: 'Player Eliminated',
combat_damage_player: 'Combat Damage to Player',
};
/**
* Parse oracle text to identify trigger patterns.
* Returns an array of trigger descriptors.
*/
function parseTriggers(cardName, oracleText) {
if (!oracleText) return [];
const triggers = [];
const text = oracleText;
const safeName = cardName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function extractSnippet(matchStr) {
const idx = text.indexOf(matchStr);
if (idx === -1) return matchStr.slice(0, 80);
const snippet = text.slice(idx, Math.min(text.length, idx + 90));
return snippet + (idx + 90 < text.length ? '\u2026' : '');
}
// ETB
const etbPatterns = [
new RegExp('when ' + safeName + ' enters the battlefield', 'i'),
new RegExp('when ' + safeName + ' enters', 'i'),
/whenever a creature enters the battlefield/i,
/whenever a creature enters/i,
/when a creature enters the battlefield/i,
/whenever another creature enters/i,
/whenever a permanent enters the battlefield/i,
/whenever a nontoken creature enters/i,
/whenever one or more creatures enter/i,
/whenever a land enters the battlefield/i,
/whenever an artifact enters the battlefield/i,
/whenever a creature enters the battlefield under your control/i,
];
for (const pat of etbPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'etb', text: extractSnippet(m[0]), cardName }); break; }
}
// Nontoken ETB (separate trigger type for cards that specifically care)
{
const m = text.match(/whenever a nontoken creature enters/i);
if (m) triggers.push({ type: 'nontoken_etb', text: extractSnippet(m[0]), cardName });
}
// LTB
const ltbPatterns = [
new RegExp('when ' + safeName + ' leaves the battlefield', 'i'),
/whenever a creature leaves the battlefield/i,
/when a permanent leaves the battlefield/i,
/whenever .+ leaves the battlefield/i,
];
for (const pat of ltbPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'ltb', text: extractSnippet(m[0]), cardName }); break; }
}
// Dies
const diesPatterns = [
new RegExp('when ' + safeName + ' dies', 'i'),
/whenever a creature you control dies/i,
/whenever a creature dies/i,
/whenever another creature dies/i,
/whenever a nontoken creature dies/i,
/whenever a creature an opponent controls dies/i,
/whenever .+ dies/i,
];
for (const pat of diesPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'dies', text: extractSnippet(m[0]), cardName }); break; }
}
// Cast
const castPatterns = [
new RegExp('when you cast ' + safeName, 'i'),
/whenever you cast a spell/i,
/whenever a player casts a spell/i,
/whenever you cast an instant or sorcery spell/i,
/whenever you cast a noncreature spell/i,
/whenever you cast a creature spell/i,
/whenever you cast a historic spell/i,
/whenever you cast .+ spell/i,
];
for (const pat of castPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'cast', text: extractSnippet(m[0]), cardName }); break; }
}
// Attack
const attackPatterns = [
new RegExp('whenever ' + safeName + ' attacks', 'i'),
/whenever a creature attacks/i,
/whenever one or more creatures attack/i,
/whenever you attack/i,
/whenever .+ attacks/i,
];
for (const pat of attackPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'attack', text: extractSnippet(m[0]), cardName }); break; }
}
// Block
const blockPatterns = [
new RegExp('whenever ' + safeName + ' blocks', 'i'),
new RegExp('when ' + safeName + ' becomes blocked', 'i'),
/whenever a creature blocks/i,
/whenever .+ blocks/i,
];
for (const pat of blockPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'block', text: extractSnippet(m[0]), cardName }); break; }
}
// Upkeep
const upkeepPatterns = [
/at the beginning of your upkeep/i,
/at the beginning of each player's upkeep/i,
/at the beginning of each upkeep/i,
/at the beginning of .+ upkeep/i,
];
for (const pat of upkeepPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'upkeep', text: extractSnippet(m[0]), cardName }); break; }
}
// Combat begin
const combatBeginPatterns = [
/at the beginning of combat on your turn/i,
/at the beginning of combat/i,
/at the beginning of each combat/i,
];
for (const pat of combatBeginPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'combat_begin', text: extractSnippet(m[0]), cardName }); break; }
}
// End step
const endStepPatterns = [
/at the beginning of your end step/i,
/at the beginning of each end step/i,
/at the beginning of the end step/i,
/at the beginning of .+ end step/i,
];
for (const pat of endStepPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'end_step', text: extractSnippet(m[0]), cardName }); break; }
}
// Damage
const damagePatterns = [
new RegExp('whenever ' + safeName + ' deals combat damage to a player', 'i'),
new RegExp('whenever ' + safeName + ' deals damage', 'i'),
/whenever .+ deals combat damage to a player/i,
/whenever .+ deals damage/i,
];
for (const pat of damagePatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'damage', text: extractSnippet(m[0]), cardName }); break; }
}
// Life gain
{
const m = text.match(/whenever you gain life/i);
if (m) triggers.push({ type: 'life_gain', text: extractSnippet(m[0]), cardName });
}
// Life loss
{
const m = text.match(/whenever you lose life/i);
if (m) triggers.push({ type: 'life_loss', text: extractSnippet(m[0]), cardName });
}
// Draw
const drawPatterns = [
/whenever you draw a card/i,
/whenever a player draws a card/i,
/whenever you draw one or more cards/i,
/whenever an opponent draws a card/i,
/whenever a card is drawn/i,
];
for (const pat of drawPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'draw', text: extractSnippet(m[0]), cardName }); break; }
}
// Discard
const discardPatterns = [
/whenever you discard a card/i,
/whenever a player discards a card/i,
/whenever you discard one or more cards/i,
/whenever a card is discarded/i,
/whenever an opponent discards a card/i,
];
for (const pat of discardPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'discard', text: extractSnippet(m[0]), cardName }); break; }
}
// Mill
const millPatterns = [
/whenever one or more cards are put into your graveyard from your library/i,
/whenever a card is put into a graveyard from a library/i,
/whenever you mill/i,
/whenever a player mills/i,
/whenever one or more cards are milled/i,
];
for (const pat of millPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'mill', text: extractSnippet(m[0]), cardName }); break; }
}
// Token creation
const tokenPatterns = [
/whenever you create a token/i,
/whenever one or more tokens are created/i,
/whenever a creature token enters/i,
/whenever you create one or more tokens/i,
/whenever a token enters the battlefield/i,
/whenever a token is created/i,
];
for (const pat of tokenPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'token_create', text: extractSnippet(m[0]), cardName }); break; }
}
// Sacrifice
const sacrificePatterns = [
/whenever you sacrifice a/i,
/whenever a player sacrifices a/i,
/whenever a creature is sacrificed/i,
/whenever a permanent is sacrificed/i,
/whenever you sacrifice another/i,
];
for (const pat of sacrificePatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'sacrifice', text: extractSnippet(m[0]), cardName }); break; }
}
// Landfall (dedicated â not just generic ETB land)
const landfallPatterns = [
/landfall/i,
/whenever a land enters the battlefield under your control/i,
];
for (const pat of landfallPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'landfall', text: extractSnippet(m[0]), cardName }); break; }
}
// Enchantment ETB (Constellation)
const enchantmentEtbPatterns = [
/constellation/i,
/whenever an enchantment enters the battlefield/i,
/whenever an enchantment you control enters/i,
/whenever an enchantment enters the battlefield under your control/i,
];
for (const pat of enchantmentEtbPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'enchantment_etb', text: extractSnippet(m[0]), cardName }); break; }
}
// Artifact ETB
const artifactEtbPatterns = [
/whenever an artifact enters the battlefield under your control/i,
/whenever an artifact enters the battlefield/i,
/whenever an artifact you control enters/i,
];
for (const pat of artifactEtbPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'artifact_etb', text: extractSnippet(m[0]), cardName }); break; }
}
// Counter placed
const counterPlacedPatterns = [
/whenever a counter is placed/i,
/whenever you put a counter/i,
/whenever one or more counters are put/i,
/whenever a \+1\/\+1 counter is placed/i,
/whenever a counter is put on/i,
];
for (const pat of counterPlacedPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'counter_placed', text: extractSnippet(m[0]), cardName }); break; }
}
// Equipped / attached
const equippedPatterns = [
/whenever equipped creature/i,
/whenever this becomes attached/i,
/whenever .+ becomes equipped/i,
/whenever .+ becomes attached/i,
];
for (const pat of equippedPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'equipped', text: extractSnippet(m[0]), cardName }); break; }
}
// Target
const targetTrigPatterns = [
/whenever you become the target/i,
/whenever a creature becomes the target/i,
/whenever .+ becomes the target of/i,
/whenever .+ is the target of/i,
];
for (const pat of targetTrigPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'target', text: extractSnippet(m[0]), cardName }); break; }
}
// Tap
const tapTriggerPatterns = [
/whenever you tap/i,
/whenever a creature becomes tapped/i,
/whenever .+ becomes tapped/i,
/whenever .+ is tapped/i,
];
for (const pat of tapTriggerPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'tap', text: extractSnippet(m[0]), cardName }); break; }
}
// Transform / daybound / nightbound
const transformPatterns = [
/when this (creature )?transforms/i,
/daybound/i,
/nightbound/i,
/whenever this (permanent|creature|card) transforms/i,
];
for (const pat of transformPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'transform', text: extractSnippet(m[0]), cardName }); break; }
}
// Exile from graveyard
const exileFromGravePatterns = [
/whenever a card is exiled from a graveyard/i,
/whenever a card is exiled from your graveyard/i,
/whenever a card leaves your graveyard/i,
/whenever a card leaves a graveyard/i,
/whenever .+ is exiled from a graveyard/i,
];
for (const pat of exileFromGravePatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'exile_from_grave', text: extractSnippet(m[0]), cardName }); break; }
}
// Mana spent
const manaSpentPatterns = [
/whenever you spend mana/i,
/whenever you cast a spell, you may pay/i,
/whenever .+ mana is spent/i,
/whenever mana of any type is spent/i,
];
for (const pat of manaSpentPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'mana_spent', text: extractSnippet(m[0]), cardName }); break; }
}
// Commander cast
{
const m = text.match(/whenever you cast your commander/i);
if (m) triggers.push({ type: 'commander_cast', text: extractSnippet(m[0]), cardName });
}
// Player eliminated
const playerEliminatedPatterns = [
/whenever a player loses the game/i,
/whenever an opponent loses the game/i,
/whenever a player leaves the game/i,
];
for (const pat of playerEliminatedPatterns) {
const m = text.match(pat);
if (m) { triggers.push({ type: 'player_eliminated', text: extractSnippet(m[0]), cardName }); break; }
}
// Combat damage to a player (dedicated â separate from generic damage)
{
const m = text.match(new RegExp('whenever ' + safeName + ' deals combat damage to a player', 'i'));
if (m) triggers.push({ type: 'combat_damage_player', text: extractSnippet(m[0]), cardName });
}
return triggers;
}
// ============================================================
// TRIGGER EFFECT HINTS
// ============================================================
/**
* Parse the effect portion of a trigger from Oracle text.
* Returns { hint: string, autoResolvable: bool, effectType: string|null }
*/
const EFFECT_PATTERNS = [
// ---- Compound effects (must come BEFORE single-effect patterns) ----
{ pattern: /draw a card and you lose (\d+) life/i, hint: 'Draw + lose life', effectType: 'draw_lose', auto: true },
{ pattern: /draw a card.{0,15}lose (\d+) life/i, hint: 'Draw + lose life', effectType: 'draw_lose', auto: true },
{ pattern: /gain (\d+) life.{0,20}draw a card/i, hint: 'Gain life + draw', effectType: 'gain_draw', auto: true },
{ pattern: /deals? (\d+) damage.{0,30}gains? that much life/i, hint: 'Damage + lifegain', effectType: 'damage_lifegain', auto: false },
// ---- Draw ----
{ pattern: /draw a card/i, hint: 'Draw a card', effectType: 'draw', auto: true },
{ pattern: /draw (two|three|four|five|six|seven|\d+) cards?/i, hint: 'Draw cards', effectType: 'draw_n', auto: true },
// ---- Life ----
{ pattern: /gain (\d+) life/i, hint: 'Gain life', effectType: 'gain_life', auto: true },
{ pattern: /lose (\d+) life/i, hint: 'Lose life', effectType: 'lose_life', auto: true },
{ pattern: /each opponent loses (\d+) life/i, hint: 'Opponents lose life', effectType: 'opp_lose', auto: true },
{ pattern: /each opponent loses (\d+) life and you gain .* life/i, hint: 'Drain', effectType: 'drain', auto: true },
{ pattern: /you gain life equal to/i, hint: 'Gain life (variable)', effectType: 'gain_life_var', auto: false },
// ---- Tokens ----
{ pattern: /create a? ?\d*\/\d* .* token/i, hint: 'Create token', effectType: 'token', auto: true },
{ pattern: /create a? ?[A-Z].* token/i, hint: 'Create token', effectType: 'token', auto: true },
{ pattern: /create two? .*token/i, hint: 'Create tokens', effectType: 'tokens', auto: true },
{ pattern: /create a? ?treasure/i, hint: 'Create Treasure', effectType: 'treasure', auto: true },
{ pattern: /create a? ?food/i, hint: 'Create Food', effectType: 'food', auto: true },
{ pattern: /investigate|create a? ?clue/i, hint: 'Investigate', effectType: 'investigate',auto: true },
// ---- Counters ----
{ pattern: /put a \+1\/\+1 counter on (.+?)(?:\.|$)/i, hint: '+1/+1 counter', effectType: 'counter_plus', auto: true },
{ pattern: /put (two|three|four|\d+) \+1\/\+1 counters? on/i, hint: '+1/+1 counters', effectType: 'counter_plus_n',auto: true },
{ pattern: /put a \-1\/\-1 counter on (.+?)(?:\.|$)/i, hint: '-1/-1 counter', effectType: 'counter_minus', auto: true },
{ pattern: /put a \+1\/\+1 counter on each creature you control/i, hint: '+1/+1 all', effectType: 'counter_all', auto: true },
{ pattern: /proliferate/i, hint: 'Proliferate', effectType: 'proliferate',auto: false },
// ---- Damage ----
{ pattern: /deals? (\d+) damage to (?:each|any) opponent/i, hint: 'Damage opponents', effectType: 'damage_opp', auto: true },
{ pattern: /deals? (\d+) damage to each player/i, hint: 'Damage all players', effectType: 'damage_all', auto: true },
{ pattern: /deals? (\d+) damage/i, hint: 'Deal damage', effectType: 'damage', auto: false },
// ---- Destroy / Exile / Bounce ----
{ pattern: /destroy target/i, hint: 'Destroy', effectType: 'destroy', auto: false },
{ pattern: /exile target/i, hint: 'Exile', effectType: 'exile', auto: false },
{ pattern: /return .* to (your|its owner's) hand/i, hint: 'Bounce', effectType: 'bounce', auto: false },
// ---- Mill ----
{ pattern: /mill (\d+)/i, hint: 'Mill', effectType: 'mill_ef', auto: true },
{ pattern: /put the top (\w+|\d+) cards? of your library into your graveyard/i, hint: 'Self mill', effectType: 'self_mill_n', auto: true },
{ pattern: /put the top card of your library into your graveyard/i, hint: 'Self mill 1', effectType: 'self_mill', auto: true },
{ pattern: /each opponent mills (\d+)/i, hint: 'Opponents mill', effectType: 'opp_mill', auto: true },
// ---- Scry ----
{ pattern: /scry (\d+)/i, hint: 'Scry', effectType: 'scry', auto: true },
// ---- Library ----
{ pattern: /search your library/i, hint: 'Tutor', effectType: 'tutor', auto: false },
{ pattern: /return .* from .* graveyard/i, hint: 'Return from graveyard',effectType: 'reanimate', auto: false },
// ---- Sacrifice / Discard ----
{ pattern: /sacrifice a/i, hint: 'Sacrifice', effectType: 'sacrifice', auto: false },
{ pattern: /discard a card/i, hint: 'Discard', effectType: 'discard_ef', auto: false },
{ pattern: /each opponent discards a card/i, hint: 'Opponents discard', effectType: 'opp_discard',auto: true },
{ pattern: /each opponent sacrifices a/i, hint: 'Opponents sacrifice', effectType: 'opp_sacrifice', auto: false },
// ---- Mana / Pump / Keywords ----
{ pattern: /add (\{[WUBRGC]\}|one mana)/i, hint: 'Add mana', effectType: 'mana', auto: false },
{ pattern: /\+\d+\/\+\d+ until end of turn/i, hint: 'Pump', effectType: 'pump', auto: false },
{ pattern: /gains? (flying|haste|trample|vigilance|lifelink|deathtouch|first strike|double strike|indestructible|hexproof|menace|reach) until end of turn/i, hint: 'Grant keyword', effectType: 'keyword_temp', auto: true },
{ pattern: /gains? (flying|haste|trample|vigilance|lifelink|deathtouch|first strike|double strike|indestructible|hexproof|menace|reach)/i, hint: 'Grant keyword', effectType: 'keyword', auto: false },
// ---- Other ----
{ pattern: /untap all .* you control/i, hint: 'Untap all', effectType: 'untap_all', auto: true },
{ pattern: /untap target/i, hint: 'Untap', effectType: 'untap', auto: false },
{ pattern: /tap target/i, hint: 'Tap', effectType: 'tap', auto: false },
{ pattern: /each player draws a card/i, hint: 'All players draw', effectType: 'all_draw', auto: true },
{ pattern: /target player draws a card/i, hint: 'Target draws', effectType: 'target_draw',auto: false },
{ pattern: /counter target spell/i, hint: 'Counter spell', effectType: 'counter_spell', auto: false },
{ pattern: /you may/i, hint: 'Optional', effectType: 'optional', auto: false },
];
function parseTriggerEffect(oracleText) {
if (!oracleText) return { hint: '', autoResolvable: false, effectType: null };
// Get the part after the trigger condition (after the comma typically)
var effectText = oracleText;
var commaIdx = oracleText.indexOf(',');
if (commaIdx > 10 && commaIdx < oracleText.length - 5) {
effectText = oracleText.slice(commaIdx + 1);
}
for (var i = 0; i < EFFECT_PATTERNS.length; i++) {
var ep = EFFECT_PATTERNS[i];
var m = effectText.match(ep.pattern);
if (m) {
var hint = ep.hint;
// Add specifics from capture groups
if (ep.effectType === 'gain_life' && m[1]) hint = 'Gain ' + m[1] + ' life';
if (ep.effectType === 'lose_life' && m[1]) hint = 'Lose ' + m[1] + ' life';
if (ep.effectType === 'opp_lose' && m[1]) hint = 'Each opponent loses ' + m[1] + ' life';
if (ep.effectType === 'damage' && m[1]) hint = 'Deal ' + m[1] + ' damage';
return { hint: hint, autoResolvable: ep.auto, effectType: ep.effectType };
}
}
return { hint: '', autoResolvable: false, effectType: null };
}
/**
* Push a trigger onto the game state trigger stack.
*/
function pushTrigger(type, cardName, oracleSnippet, ownerIndex, sourceCardId) {
if (!gameState) return;
var effect = parseTriggerEffect(oracleSnippet);
const trigger = {
id: nextTriggerId++,
type,
cardName,
oracleSnippet: oracleSnippet || '',
ownerIndex: ownerIndex != null ? ownerIndex : (gameState.currentPlayerIndex),
sourceCardId: sourceCardId || null,
resolved: false,
effectHint: effect.hint,
autoResolvable: effect.autoResolvable,
effectType: effect.effectType,
};
gameState.triggerStack.push(trigger);
updateTriggerBadge();
renderTriggerStack();
updateUnifiedStack();
addLogEntry('⥠Trigger: ' + cardName + ' â ' + (TRIGGER_TYPE_LABELS[type] || type) + (effect.hint ? ' (' + effect.hint + ') ' : ''));
// Auto-resolve immediately if eligible
if (trigger.autoResolvable && gameState.rulesEngine && gameState.rulesEngine.autoResolveTriggers) {
setTimeout(function() { autoResolveTrigger(trigger); }, 150);
}
}
/**
* Fire all battlefield permanents that have triggers of triggerType.
* filterFn(cardData) => bool optional filter.
*/
function fireBattlefieldTriggers(triggerType, filterFn) {
if (!gameState) return;
let firedAny = false;
var currentPlayerIdx = gameState.currentPlayerIndex;
gameState.battlefieldCards.forEach(cardData => {
if (!cardData.triggers || !cardData.triggers.length) return;
if (filterFn && !filterFn(cardData)) return;
const matching = cardData.triggers.filter(t => t.type === triggerType);
matching.forEach(t => {
// For upkeep/end_step triggers: check if they should fire this turn
if (triggerType === 'upkeep' || triggerType === 'end_step') {
var trigText = (t.text || '').toLowerCase();
var isYourOnly = /\byour\b/.test(trigText) && !/\beach\b/.test(trigText);
// "your upkeep" only fires on the card owner's turn
if (isYourOnly && cardData.ownerIndex !== currentPlayerIdx) return;
}
pushTrigger(t.type, t.cardName, t.text, cardData.ownerIndex, cardData.id);
firedAny = true;
});
});
// Flash the active phase step when triggers fire
if (firedAny) flashActivePhaseStep();
}
/**
* Fire triggers for a single card (by type array).
*/
function fireCardTriggers(cardData, triggerTypes) {
if (!cardData || !cardData.triggers) return;
cardData.triggers
.filter(t => triggerTypes.includes(t.type))
.forEach(t => pushTrigger(t.type, t.cardName, t.text, cardData.ownerIndex, cardData.id));
}
// ============================================================
// TRIGGER STACK UI
// ============================================================
function updateTriggerBadge() {
if (!gameState) return;
const count = gameState.triggerStack.filter(t => !t.resolved).length;
const badgeEl = document.getElementById('trigger-count');
const inlineEl = document.getElementById('trigger-count-inline');
const btnEl = document.getElementById('btn-trigger-stack');
if (badgeEl) badgeEl.textContent = count;
if (inlineEl) inlineEl.textContent = count;
if (btnEl) btnEl.classList.toggle('has-triggers', count > 0);
}
function openTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (panel) panel.classList.add('visible');
renderTriggerStack();
}
function closeTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (panel) panel.classList.remove('visible');
}
function toggleTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (!panel) return;
panel.classList.contains('visible') ? closeTriggerStack() : openTriggerStack();
}
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderTriggerStack() {
const body = document.getElementById('trigger-stack-body');
if (!body || !gameState) return;
const pending = gameState.triggerStack.filter(t => !t.resolved);
if (!pending.length) {
body.innerHTML = 'No pending triggers
';
return;
}
body.innerHTML = '';
pending.forEach((trigger, idx) => {
const ownerColor = (gameState.players[trigger.ownerIndex] || {}).color || '#888';
const typeIcon = TRIGGER_TYPE_ICONS[trigger.type] || '?';
const typeLabel = TRIGGER_TYPE_LABELS[trigger.type] || trigger.type;
const item = document.createElement('div');
item.className = 'trigger-item';
item.dataset.triggerId = trigger.id;
var effectBadge = '';
if (trigger.effectHint) {
effectBadge = '' + escapeHtml(trigger.effectHint) + ' ';
}
item.innerHTML =
'âĄ
' +
'' + typeIcon + '
' +
'' +
'
' + escapeHtml(trigger.cardName) + effectBadge + '
' +
'
' + escapeHtml(typeLabel) + '
' +
'
' + escapeHtml(trigger.oracleSnippet) + '
' +
'
' +
'
' +
'â ';
// Dismiss button â use inline onclick for Comet browser compatibility
var dismissBtn = item.querySelector('.trigger-item-dismiss');
if (dismissBtn) dismissBtn.setAttribute('onclick', 'event.stopPropagation();dismissTrigger(' + trigger.id + ')');
setupTriggerItemDrag(item, trigger.id);
body.appendChild(item);
});
// Auto-open when triggers arrive
const panel = document.getElementById('trigger-stack-panel');
if (panel && !panel.classList.contains('visible')) {
openTriggerStack();
}
updateUnifiedStack();
}
/**
* Automatically apply simple trigger effects when resolved.
* Only handles unambiguous, single-player effects.
*/
function autoResolveTriggerEffect(trigger) {
if (!trigger || !trigger.autoResolvable || !trigger.effectType) return;
var owner = gameState.players[trigger.ownerIndex];
if (!owner) return;
var snippet = trigger.oracleSnippet || '';
var cname = trigger.cardName || 'Unknown';
var oi = trigger.ownerIndex;
// Helper: get opponent indices
function getOpponents(ownerIdx) {
var opps = [];
for (var p = 0; p < gameState.players.length; p++) {
if (p !== ownerIdx) opps.push(p);
}
return opps;
}
// Helper: find the card on battlefield for counter placement
function findCardOnBattlefield(cardName, ownerIdx) {
var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
for (var i = 0; i < bf.length; i++) {
if (bf[i].name === cardName) return bf[i];
}
return null;
}
switch (trigger.effectType) {
// ---- Compound effects ----
case 'draw_lose': {
drawCard(oi);
var dlMatch = snippet.match(/lose (\d+) life/i);
var dlAmt = dlMatch ? parseInt(dlMatch[1], 10) : 1;
adjustLife(oi, -dlAmt);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw a card and lose ' + dlAmt + ' life');
break;
}
case 'gain_draw': {
var gdMatch = snippet.match(/gain (\d+) life/i);
var gdAmt = gdMatch ? parseInt(gdMatch[1], 10) : 1;
adjustLife(oi, gdAmt);
drawCard(oi);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Gain ' + gdAmt + ' life and draw a card');
break;
}
case 'draw':
drawCard(oi);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw a card');
break;
case 'draw_n': {
var dnMatch = snippet.match(/draws?\s+(\w+|\d+)\s+cards?/i);
var dnCount = 1;
if (dnMatch) {
var wordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9,ten:10};
dnCount = wordMap[dnMatch[1].toLowerCase()] || parseInt(dnMatch[1], 10) || 1;
}
for (var dn = 0; dn < dnCount; dn++) drawCard(oi);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Draw ' + dnCount + ' cards');
break;
}
case 'all_draw': {
var adMatch = snippet.match(/each player draws?\s+(\w+|\d+)?/i);
var adCount = 1;
if (adMatch && adMatch[1]) {
var adMap = {a:1,one:1,two:2,three:3,four:4,five:5};
adCount = adMap[adMatch[1].toLowerCase()] || parseInt(adMatch[1], 10) || 1;
}
for (var ap = 0; ap < gameState.players.length; ap++) {
for (var adc = 0; adc < adCount; adc++) drawCard(ap);
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each player draws ' + adCount);
break;
}
case 'gain_life': {
var gainMatch = snippet.match(/gain\s+(\d+)\s+life/i);
var gainAmt = gainMatch ? parseInt(gainMatch[1], 10) : 1;
adjustLife(oi, gainAmt);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Gain ' + gainAmt + ' life');
break;
}
case 'lose_life': {
var loseMatch = snippet.match(/lose\s+(\d+)\s+life/i);
var loseAmt = loseMatch ? parseInt(loseMatch[1], 10) : 1;
adjustLife(oi, -loseAmt);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Lose ' + loseAmt + ' life');
break;
}
case 'opp_lose': {
var olMatch = snippet.match(/(?:each opponent|opponents?)\s+loses?\s+(\d+)\s+life/i);
var olAmt = olMatch ? parseInt(olMatch[1], 10) : 1;
var opps1 = getOpponents(oi);
for (var ol = 0; ol < opps1.length; ol++) adjustLife(opps1[ol], -olAmt);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent loses ' + olAmt + ' life');
break;
}
case 'drain': {
var drainLose = snippet.match(/(?:each opponent|opponents?)\s+loses?\s+(\d+)\s+life/i);
var drainGain = snippet.match(/you gain\s+(?:that much life|(\d+)\s+life)/i);
var drLose = drainLose ? parseInt(drainLose[1], 10) : 1;
var drGain = drainGain && drainGain[1] ? parseInt(drainGain[1], 10) : drLose;
var opps2 = getOpponents(oi);
for (var dr = 0; dr < opps2.length; dr++) adjustLife(opps2[dr], -drLose);
adjustLife(oi, drGain * opps2.length);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Drain ' + drLose + ' from each opponent, gain ' + (drGain * opps2.length));
break;
}
case 'damage_opp': {
var doMatch = snippet.match(/deals?\s+(\d+)\s+damage\s+to\s+each\s+opponent/i);
var doDmg = doMatch ? parseInt(doMatch[1], 10) : 1;
var opps3 = getOpponents(oi);
for (var dop = 0; dop < opps3.length; dop++) adjustLife(opps3[dop], -doDmg);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Deal ' + doDmg + ' damage to each opponent');
break;
}
case 'damage_all': {
var daMatch = snippet.match(/deals?\s+(\d+)\s+damage\s+to\s+each\s+(?:player|creature and each player)/i);
var daDmg = daMatch ? parseInt(daMatch[1], 10) : 1;
for (var dap = 0; dap < gameState.players.length; dap++) adjustLife(dap, -daDmg);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Deal ' + daDmg + ' damage to each player');
break;
}
case 'counter_plus': {
var cpCard = findCardOnBattlefield(cname, oi);
if (cpCard) {
cpCard.counters = cpCard.counters || {};
cpCard.counters['+1/+1'] = (cpCard.counters['+1/+1'] || 0) + 1;
if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cpCard);
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Put a +1/+1 counter on it');
break;
}
case 'counter_plus_n': {
var cpnMatch = snippet.match(/(\w+|\d+)\s+\+1\/\+1\s+counters?/i);
var cpnCount = 1;
if (cpnMatch) {
var cpnMap = {a:1,one:1,two:2,three:3,four:4,five:5,six:6};
cpnCount = cpnMap[cpnMatch[1].toLowerCase()] || parseInt(cpnMatch[1], 10) || 1;
}
var cpnCard = findCardOnBattlefield(cname, oi);
if (cpnCard) {
cpnCard.counters = cpnCard.counters || {};
cpnCard.counters['+1/+1'] = (cpnCard.counters['+1/+1'] || 0) + cpnCount;
if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cpnCard);
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Put ' + cpnCount + ' +1/+1 counters');
break;
}
case 'counter_minus': {
// -1/-1 counter on target â place on first opponent creature as default
var opps4 = getOpponents(oi);
var placed = false;
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
cmBf[cmj].counters = cmBf[cmj].counters || {};
cmBf[cmj].counters['-1/-1'] = (cmBf[cmj].counters['-1/-1'] || 0) + 1;
if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(cmBf[cmj]);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Put -1/-1 counter on ' + cmBf[cmj].name);
placed = true;
}
}
}
if (!placed) addLogEntry('>> Auto-resolved: ' + cname + ' -- -1/-1 counter (no valid target)');
break;
}
case 'counter_all': {
// +1/+1 counter on each creature you control
var caBf = (gameState.players[oi] || {}).battlefield || [];
var caCount = 0;
for (var cai = 0; cai < caBf.length; cai++) {
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
caBf[cai].counters = caBf[cai].counters || {};
caBf[cai].counters['+1/+1'] = (caBf[cai].counters['+1/+1'] || 0) + 1;
if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(caBf[cai]);
caCount++;
}
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- +1/+1 counter on ' + caCount + ' creatures');
break;
}
case 'self_mill':
millCards(oi, 1);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Mill 1');
break;
case 'self_mill_n': {
var smnMatch = snippet.match(/mills?\s+(\w+|\d+)/i);
var smnCount = 1;
if (smnMatch) {
var smnMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,ten:10};
smnCount = smnMap[smnMatch[1].toLowerCase()] || parseInt(smnMatch[1], 10) || 1;
}
millCards(oi, smnCount);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Mill ' + smnCount);
break;
}
case 'mill_ef': {
var meMatch = snippet.match(/(?:target|each)\s+(?:player|opponent)\s+(?:mills?|puts?).*?(\w+|\d+)/i);
var meCount = 1;
if (meMatch) {
var meMap = {one:1,two:2,three:3,four:4,five:5,ten:10};
meCount = meMap[meMatch[1].toLowerCase()] || parseInt(meMatch[1], 10) || 1;
}
var isOppOnly = /each opponent|target opponent/i.test(snippet);
if (isOppOnly) {
var opps5 = getOpponents(oi);
for (var mei = 0; mei < opps5.length; mei++) millCards(opps5[mei], meCount);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent mills ' + meCount);
} else {
for (var mep = 0; mep < gameState.players.length; mep++) millCards(mep, meCount);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each player mills ' + meCount);
}
break;
}
case 'opp_mill': {
var omMatch = snippet.match(/opponent\s+mills?\s+(\w+|\d+)/i);
var omCount = 1;
if (omMatch) {
var omMap = {one:1,two:2,three:3,four:4,five:5,ten:10};
omCount = omMap[omMatch[1].toLowerCase()] || parseInt(omMatch[1], 10) || 1;
}
var opps6 = getOpponents(oi);
for (var omi = 0; omi < opps6.length; omi++) millCards(opps6[omi], omCount);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent mills ' + omCount);
break;
}
case 'scry': {
var scryMatch = snippet.match(/scry\s+(\d+)/i);
var scryN = scryMatch ? parseInt(scryMatch[1], 10) : 1;
if (typeof openScryModal === 'function') {
openScryModal(oi, scryN);
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Scry ' + scryN);
break;
}
case 'opp_discard': {
var odMatch = snippet.match(/(?:each opponent|opponents?)\s+discards?\s+(?:a|one|\d+)\s+cards?/i);
var odCount = 1;
if (odMatch) {
var odNumMatch = odMatch[0].match(/(\d+)/);
odCount = odNumMatch ? parseInt(odNumMatch[1], 10) : 1;
}
var opps7 = getOpponents(oi);
for (var odi = 0; odi < opps7.length; odi++) {
var odHand = (gameState.players[opps7[odi]] || {}).hand || [];
for (var odj = 0; odj < odCount && odHand.length > 0; odj++) {
var randIdx = Math.floor(Math.random() * odHand.length);
var discarded = odHand.splice(randIdx, 1)[0];
gameState.players[opps7[odi]].graveyard = gameState.players[opps7[odi]].graveyard || [];
gameState.players[opps7[odi]].graveyard.push(discarded);
addLogEntry('>> ' + gameState.players[opps7[odi]].name + ' discards ' + (discarded.name || 'a card'));
}
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Each opponent discards ' + odCount);
break;
}
case 'keyword_temp': {
// Grants a temporary keyword (haste, lifelink, etc.) â tag the card
var kwMatch = snippet.match(/gains?\s+(haste|lifelink|deathtouch|flying|trample|vigilance|menace|first strike|double strike|hexproof|indestructible|reach)/i);
if (kwMatch) {
var kw = kwMatch[1].toLowerCase();
var kwCard = findCardOnBattlefield(cname, oi);
if (kwCard) {
kwCard.tempKeywords = kwCard.tempKeywords || [];
if (kwCard.tempKeywords.indexOf(kw) === -1) kwCard.tempKeywords.push(kw);
if (typeof renderBattlefieldCard === 'function') renderBattlefieldCard(kwCard);
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Gains ' + kw);
}
break;
}
case 'untap_all': {
var uaBf = (gameState.players[oi] || {}).battlefield || [];
var uaCount = 0;
for (var uai = 0; uai < uaBf.length; uai++) {
if (uaBf[uai].tapped) {
uaBf[uai].tapped = false;
uaCount++;
}
}
addLogEntry('>> Auto-resolved: ' + cname + ' -- Untapped ' + uaCount + ' permanents');
break;
}
case 'token':
case 'tokens': {
var tokenSpecs = parseTokenCreationAbilities(snippet);
if (tokenSpecs.length > 0) {
for (var ti = 0; ti < tokenSpecs.length; ti++) {
var ts = tokenSpecs[ti];
createQuickToken(oi, ts.name, ts.power, ts.toughness, ts.typeLine, ts.color, ts.abilities, ts.count);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Created ' + ts.displayLabel);
}
} else {
addLogEntry('!! Could not auto-parse token from: ' + cname + ' -- resolve manually');
}
break;
}
case 'treasure': {
createQuickToken(oi, 'Treasure', '', '', 'Artifact -- Treasure', 'C', 'Sacrifice: Add one mana of any color', 1);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Created 1x Treasure');
break;
}
case 'food': {
createQuickToken(oi, 'Food', '', '', 'Artifact -- Food', 'C', '{2}, Sacrifice: Gain 3 life', 1);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Created 1x Food');
break;
}
case 'investigate': {
createQuickToken(oi, 'Clue', '', '', 'Artifact -- Clue', 'C', '{2}, Sacrifice: Draw a card', 1);
addLogEntry('>> Auto-resolved: ' + cname + ' -- Investigated (Created 1x Clue)');
break;
}
default:
addLogEntry('!! Unhandled auto-trigger type "' + trigger.effectType + '" for ' + cname + ' -- resolve manually');
break;
}
// Refresh UI after any auto-resolution
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
}
/**
* autoResolveTrigger(trigger) â gated by rulesEngine.autoResolveTriggers setting.
* Auto-resolves simple effects, removes from stack, and chains to next auto trigger.
* @returns {boolean} true if auto-resolved, false if manual needed
*/
function autoResolveTrigger(trigger) {
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.autoResolveTriggers) return false;
if (!trigger || !trigger.autoResolvable || !trigger.effectType) return false;
// Verify trigger is still on stack
var stillPending = gameState.triggerStack.find(function(t) { return t.id === trigger.id && !t.resolved; });
if (!stillPending) return false;
autoResolveTriggerEffect(trigger);
var idx = gameState.triggerStack.findIndex(function(t) { return t.id === trigger.id; });
if (idx !== -1) gameState.triggerStack.splice(idx, 1);
updateTriggerBadge();
if (gameState.rulesEngine.sbaChecks) checkStateBasedActions();
var nextAuto = gameState.triggerStack.filter(function(t) { return !t.resolved && t.autoResolvable; });
if (nextAuto.length > 0) {
setTimeout(function() {
autoResolveTrigger(nextAuto[0]);
renderTriggerStack();
updateUnifiedStack();
}, 300);
} else {
renderTriggerStack();
updateUnifiedStack();
if (!gameState.triggerStack.filter(function(t) { return !t.resolved; }).length) {
setTimeout(closeTriggerStack, 500);
}
}
return true;
}
function resolveNextTrigger() {
if (!gameState) return;
const pending = gameState.triggerStack.filter(t => !t.resolved);
if (!pending.length) return;
const trigger = pending[0];
// Try auto-resolve first (gated by setting)
if (autoResolveTrigger(trigger)) {
updateUnifiedStack();
return;
}
const body = document.getElementById('trigger-stack-body');
const item = body ? body.querySelector('[data-trigger-id="' + trigger.id + '"]') : null;
// Manual resolution â apply effect for non-auto-resolve case
autoResolveTriggerEffect(trigger);
addLogEntry('â Resolved: ' + trigger.cardName + ' â ' + (TRIGGER_TYPE_LABELS[trigger.type] || trigger.type) + (trigger.effectHint ? ' (' + trigger.effectHint + ')' : ''));
if (item) {
item.classList.add('resolving');
setTimeout(function() {
const idx = gameState.triggerStack.findIndex(t => t.id === trigger.id);
if (idx !== -1) gameState.triggerStack.splice(idx, 1);
updateTriggerBadge();
renderTriggerStack();
updateUnifiedStack();
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
if (!gameState.triggerStack.filter(t => !t.resolved).length) {
setTimeout(closeTriggerStack, 500);
}
}, 420);
} else {
const idx = gameState.triggerStack.findIndex(t => t.id === trigger.id);
if (idx !== -1) gameState.triggerStack.splice(idx, 1);
updateTriggerBadge();
renderTriggerStack();
updateUnifiedStack();
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
if (!gameState.triggerStack.filter(t => !t.resolved).length) {
setTimeout(closeTriggerStack, 500);
}
}
}
function resolveAllTriggers() {
if (!gameState) return;
var pending = gameState.triggerStack.filter(t => !t.resolved);
if (!pending.length) return;
pending.forEach(function(t) { autoResolveTriggerEffect(t); });
addLogEntry('â Resolved ' + pending.length + ' trigger' + (pending.length !== 1 ? 's' : ''));
gameState.triggerStack = [];
updateTriggerBadge();
renderTriggerStack();
updateUnifiedStack();
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
setTimeout(closeTriggerStack, 500);
}
function dismissTrigger(triggerId) {
if (!gameState) return;
const idx = gameState.triggerStack.findIndex(t => t.id === triggerId);
if (idx === -1) return;
const trigger = gameState.triggerStack[idx];
gameState.triggerStack.splice(idx, 1);
updateTriggerBadge();
renderTriggerStack();
updateUnifiedStack();
addLogEntry('â Dismissed: ' + trigger.cardName + ' ');
if (!gameState.triggerStack.filter(t => !t.resolved).length) {
setTimeout(closeTriggerStack, 500);
}
}
// Drag-to-reorder trigger items
let dragTriggerSourceId = null;
function setupTriggerItemDrag(item, triggerId) {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', function(e) {
dragTriggerSourceId = triggerId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(triggerId));
setTimeout(function() { item.style.opacity = '0.45'; }, 0);
});
item.addEventListener('dragend', function() {
item.style.opacity = '';
dragTriggerSourceId = null;
document.querySelectorAll('.trigger-item').forEach(function(el) {
el.style.outline = '';
});
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.trigger-item').forEach(function(el) { el.style.outline = ''; });
if (triggerId !== dragTriggerSourceId) {
item.style.outline = '2px solid var(--mtg-gold)';
}
});
item.addEventListener('dragleave', function() {
item.style.outline = '';
});
item.addEventListener('drop', function(e) {
e.preventDefault();
item.style.outline = '';
if (!gameState || dragTriggerSourceId == null || dragTriggerSourceId === triggerId) return;
const pending = gameState.triggerStack.filter(t => !t.resolved);
const srcIdx = pending.findIndex(t => t.id === dragTriggerSourceId);
const dstIdx = pending.findIndex(t => t.id === triggerId);
if (srcIdx === -1 || dstIdx === -1) return;
// Move srcIdx to dstIdx position
const moved = pending.splice(srcIdx, 1)[0];
pending.splice(dstIdx, 0, moved);
gameState.triggerStack = pending;
addLogEntry('â
Reordered trigger stack');
renderTriggerStack();
});
}
// ============================================================
// SCRYFALL API
// ============================================================
// searchDebounceTimer declared in ui.js
let lastSearchTime = 0;
const SEARCH_DEBOUNCE_MS = 300;
const RATE_LIMIT_MS = 100;
async function searchScryfall(query) {
if (!query || query.trim().length < 2) {
renderSearchResults([]);
setSearchStatus('Type to search cards...');
return;
}
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(async () => {
const now = Date.now();
const waitMs = Math.max(0, lastSearchTime + RATE_LIMIT_MS - now);
setTimeout(async () => {
lastSearchTime = Date.now();
setSearchStatus('');
showSearchSpinner(true);
try {
const url = `${SCRYFALL_BASE}/cards/search?q=${encodeURIComponent(query)}&order=name&unique=cards`;
const resp = await fetch(url);
if (!resp.ok) {
if (resp.status === 404) {
renderSearchResults([]);
setSearchStatus('No cards found.');
} else {
setSearchStatus('Search error. Try again.');
}
return;
}
const data = await resp.json();
renderSearchResults(data.data || []);
setSearchStatus(data.total_cards
? `${data.total_cards} result${data.total_cards === 1 ? '' : 's'}` + (data.total_cards > data.data.length ? ` (showing ${data.data.length})` : '')
: 'No results.');
} catch (err) {
setSearchStatus('Network error. Check connection.');
} finally {
showSearchSpinner(false);
}
}, waitMs);
}, SEARCH_DEBOUNCE_MS);
}
function getCardImage(card) {
if (card.image_uris && card.image_uris.normal) {
return card.image_uris.normal;
}
if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
return card.card_faces[0].image_uris.normal;
}
return null;
}
function setSearchStatus(text) {
const el = document.getElementById('search-status');
if (el) el.textContent = text;
}
function showSearchSpinner(show) {
const el = document.getElementById('search-status');
if (!el) return;
if (show) {
el.innerHTML = '';
}
}
function renderSearchResults(cards) {
const container = document.getElementById('card-results');
if (!container) return;
if (!cards.length) {
container.innerHTML = 'No cards found.
';
return;
}
container.innerHTML = '';
cards.slice(0, 40).forEach(card => {
const imgSrc = getCardImage(card);
if (!imgSrc) return;
const item = document.createElement('div');
item.className = 'search-card-item';
item.draggable = true;
item.innerHTML = `
${card.name}
${card.type_line || ''}
`;
// Drag start
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'search-card',
card: {
id: card.id || card.name,
name: card.name,
imageUrl: imgSrc,
typeLine: card.type_line || '',
manaCost: card.mana_cost || '',
oracleText: card.oracle_text || '',
}
}));
e.dataTransfer.effectAllowed = 'copy';
});
// Click to preview
item.addEventListener('click', e => {
if (e.target.classList.contains('search-card-add-btn')) {
addCardToBattlefield(card.name, imgSrc, card.type_line || '', gameState.currentPlayerIndex, undefined, undefined, card.oracle_text || '', { manaCost: card.mana_cost || '', power: card.power || '', toughness: card.toughness || '', pt: (card.power && card.toughness) ? card.power + '/' + card.toughness : '', cmc: card.cmc || 0, loyalty: card.loyalty || '' });
} else if (e.target.classList.contains('search-card-lib-btn')) {
addCardToPlayerZone(card, imgSrc, 'library', 'top');
} else if (e.target.classList.contains('search-card-hand-btn')) {
addCardToPlayerZone(card, imgSrc, 'hand');
} else {
showCardPreviewModal(card.name, imgSrc, card.type_line, card.oracle_text, card.mana_cost);
}
});
// Hover to preview
item.addEventListener('mouseenter', e => {
showFloatingCardPreview(imgSrc, e, {
name: card.name,
typeLine: card.type_line || '',
oracleText: card.oracle_text || '',
manaCost: card.mana_cost || '',
power: card.power,
toughness: card.toughness,
loyalty: card.loyalty
});
});
item.addEventListener('mousemove', e => {
positionFloatingPreview(e);
});
item.addEventListener('mouseleave', () => {
hideFloatingCardPreview();
});
container.appendChild(item);
});
}
// ============================================================
// FLOATING CARD PREVIEW
// ============================================================
// Calculate effective Power/Toughness for a battlefield creature
// Includes: base P/T + counters + equipment buffs + anthem effects
function getEffectivePT(bfCard) {
if (!bfCard) return null;
var basePower = 0;
var baseTough = 0;
// Try power/toughness fields first, but only if they contain valid numbers
var hasPower = bfCard.power !== undefined && bfCard.power !== '' && bfCard.power !== null;
var hasTough = bfCard.toughness !== undefined && bfCard.toughness !== '' && bfCard.toughness !== null;
if (hasPower) basePower = parseInt(bfCard.power, 10) || 0;
if (hasTough) baseTough = parseInt(bfCard.toughness, 10) || 0;
// If power/toughness not set (or 0/0 which could be parse failure), try pt string
if ((!hasPower || !hasTough) && bfCard.pt) {
var ptParts = bfCard.pt.split('/');
if (ptParts.length === 2) {
if (!hasPower) basePower = parseInt(ptParts[0], 10) || 0;
if (!hasTough) baseTough = parseInt(ptParts[1], 10) || 0;
}
}
var bonusPower = 0;
var bonusTough = 0;
// +1/+1 and -1/-1 counters
if (bfCard.counters) {
var plus = bfCard.counters['+1/+1'] || 0;
var minus = bfCard.counters['-1/-1'] || 0;
bonusPower += plus - minus;
bonusTough += plus - minus;
}
// Equipment buffs: scan attached equipment oracle text for buff patterns
if (bfCard.attachments && bfCard.attachments.length > 0 && gameState) {
for (var i = 0; i < bfCard.attachments.length; i++) {
var eq = gameState.battlefieldCards.find(function(c) { return c.id === bfCard.attachments[i]; });
if (!eq || !eq.oracleText) continue;
var buffMatches = eq.oracleText.match(/(?:gets?|has)\s+([+-]\d+)\/([+-]\d+)/gi);
if (buffMatches) {
for (var m = 0; m < buffMatches.length; m++) {
var nums = buffMatches[m].match(/([+-]\d+)\/([+-]\d+)/);
if (nums) {
bonusPower += parseInt(nums[1]) || 0;
bonusTough += parseInt(nums[2]) || 0;
}
}
}
}
}
// Anthem effects: scan all OTHER battlefield permanents for "creatures you control get +X/+X"
if (gameState && gameState.battlefieldCards) {
for (var j = 0; j < gameState.battlefieldCards.length; j++) {
var other = gameState.battlefieldCards[j];
if (other.id === bfCard.id) continue;
if (other.ownerIndex !== bfCard.ownerIndex) continue;
if (!other.oracleText) continue;
// Match patterns like "creatures you control get +1/+1" or "other creatures you control get +2/+2"
var anthemMatch = other.oracleText.match(/(?:other\s+)?creatures\s+you\s+control\s+get\s+([+-]\d+)\/([+-]\d+)/i);
if (anthemMatch) {
// "Other creatures" shouldn't buff itself â check if the anthem source IS the creature
var isOther = /other\s+creatures/i.test(other.oracleText);
if (!isOther || other.id !== bfCard.id) {
bonusPower += parseInt(anthemMatch[1]) || 0;
bonusTough += parseInt(anthemMatch[2]) || 0;
}
}
}
}
return {
power: basePower + bonusPower,
toughness: baseTough + bonusTough,
bonusPower: bonusPower,
bonusTough: bonusTough
};
}
function showFloatingCardPreview(imgSrc, e, cardInfo) {
var preview = document.getElementById('card-preview');
if (!preview) return;
preview.style.display = 'block';
preview.querySelector('img').src = imgSrc;
// Effective P/T overlay for battlefield creatures
var ptOverlay = preview.querySelector('.card-preview-pt-overlay');
if (ptOverlay) {
var bfCard = cardInfo && cardInfo._bfCard;
if (bfCard && aiIsCreature(bfCard)) {
var ept = getEffectivePT(bfCard);
if (ept) {
var basePower = parseInt(bfCard.power) || 0;
var baseTough = parseInt(bfCard.toughness) || 0;
var boosted = ept.power !== basePower || ept.toughness !== baseTough;
ptOverlay.textContent = ept.power + ' / ' + ept.toughness;
ptOverlay.style.display = 'flex';
ptOverlay.className = 'card-preview-pt-overlay' + (boosted ? ' boosted' : '') +
(ept.power > basePower ? ' power-up' : '') +
(ept.toughness > baseTough ? ' tough-up' : '') +
(ept.power < basePower ? ' power-down' : '') +
(ept.toughness < baseTough ? ' tough-down' : '');
// Position at bottom-left of the card image
var imgEl = preview.querySelector('img');
if (imgEl) {
requestAnimationFrame(function() {
ptOverlay.style.top = (imgEl.offsetHeight - ptOverlay.offsetHeight - 8) + 'px';
});
}
} else {
ptOverlay.style.display = 'none';
}
} else {
ptOverlay.style.display = 'none';
}
}
// Populate info panel if card data provided
if (cardInfo && (cardInfo.name || cardInfo.typeLine || cardInfo.oracleText)) {
preview.classList.add('has-info');
var nameEl = preview.querySelector('.card-preview-name');
var costEl = preview.querySelector('.card-preview-cost');
var typeEl = preview.querySelector('.card-preview-type');
var oracleEl = preview.querySelector('.card-preview-oracle');
var ptEl = preview.querySelector('.card-preview-pt');
if (nameEl) nameEl.textContent = cardInfo.name || '';
if (costEl) costEl.innerHTML = cardInfo.manaCost ? renderManaCostHTML(cardInfo.manaCost) : '';
if (typeEl) typeEl.textContent = cardInfo.typeLine || '';
if (oracleEl) oracleEl.textContent = cardInfo.oracleText || '';
if (ptEl) {
// Show P/T for creatures, loyalty for planeswalkers
if (cardInfo.power !== undefined && cardInfo.toughness !== undefined) {
ptEl.textContent = cardInfo.power + '/' + cardInfo.toughness;
} else if (cardInfo.pt) {
ptEl.textContent = cardInfo.pt;
} else if (cardInfo.loyalty) {
ptEl.textContent = 'Loyalty: ' + cardInfo.loyalty;
} else {
ptEl.textContent = '';
}
}
} else {
preview.classList.remove('has-info');
}
positionFloatingPreview(e);
}
function positionFloatingPreview(e) {
var preview = document.getElementById('card-preview');
if (!preview || preview.style.display === 'none') return;
var offset = 16;
var pw = 290; // preview width + buffer
var ph = preview.offsetHeight || 420;
var x = e.clientX + offset;
var y = e.clientY + offset;
if (x + pw > window.innerWidth) x = e.clientX - pw - offset;
if (y + ph > window.innerHeight) y = Math.max(8, window.innerHeight - ph - 8);
if (x < 4) x = 4;
if (y < 4) y = 4;
preview.style.left = x + 'px';
preview.style.top = y + 'px';
}
function hideFloatingCardPreview() {
var preview = document.getElementById('card-preview');
if (preview) {
preview.style.display = 'none';
preview.classList.remove('has-info');
}
}
// Render mana cost string like "{2}{W}{U}" into colored circle HTML
function renderManaCostHTML(costStr) {
if (!costStr) return '';
var parts = costStr.match(/\{[^}]+\}/g);
if (!parts) return costStr;
return parts.map(function(p) {
var sym = p.replace(/[{}]/g, '');
var cls = 'mana-symbol';
if (sym === 'W') cls += ' mana-W';
else if (sym === 'U') cls += ' mana-U';
else if (sym === 'B') cls += ' mana-B';
else if (sym === 'R') cls += ' mana-R';
else if (sym === 'G') cls += ' mana-G';
else if (sym === 'C') cls += ' mana-C';
else if (sym === 'X') cls += ' mana-X';
else if (/^\d+$/.test(sym)) cls += ' mana-generic';
else if (sym.indexOf('/') !== -1) {
// Hybrid like W/U â use first color
var first = sym.split('/')[0];
if ('WUBRGC'.indexOf(first) !== -1) cls += ' mana-' + first;
else cls += ' mana-generic';
}
else cls += ' mana-generic';
return '' + sym + ' ';
}).join('');
}
// ============================================================
// BATTLEFIELD
// ============================================================
let nextCardId = 1;
let draggedCardId = null;
let dragOffset = { x: 0, y: 0 };
let isDraggingBattlefieldCard = false;
// Determine battlefield zone for a card based on its type line
function getCardTypeZone(typeLine) {
var t = (typeLine || '').toLowerCase();
if (/instant|sorcery/.test(t)) return 'spell';
if (/creature/.test(t)) return 'creature';
if (/artifact|enchantment/.test(t)) return 'artifact';
if (/land/.test(t)) return 'land';
if (/planeswalker/.test(t)) return 'creature'; // planeswalkers with creatures top center
return 'other';
}
// ============================================================
// EQUIPMENT ATTACH/DETACH SYSTEM
// ============================================================
var equipPickMode = false;
var equipPickSourceId = null;
function isEquipment(card) {
if (!card || !card.typeLine) return false;
return /equipment/i.test(card.typeLine);
}
function isAura(card) {
if (!card || !card.typeLine) return false;
return /aura/i.test(card.typeLine) && /enchantment/i.test(card.typeLine);
}
function isAttachable(card) {
return isEquipment(card) || isAura(card);
}
function enterEquipPickMode(equipmentId) {
equipPickMode = true;
equipPickSourceId = equipmentId;
document.body.classList.add('equip-pick-mode');
var eqCard = gameState.battlefieldCards.find(function(c){return c.id===equipmentId});
var eqIcon = eqCard && isAura(eqCard) ? '\u2728' : '\u2694';
addLogEntry(eqIcon + ' Click a creature to attach ' + (eqCard || {}).name + ' to it.');
}
function cancelEquipPickMode() {
equipPickMode = false;
equipPickSourceId = null;
document.body.classList.remove('equip-pick-mode');
}
function handleEquipPickClick(creatureId) {
if (!equipPickMode || !equipPickSourceId) return;
var equipment = gameState.battlefieldCards.find(function(c) { return c.id === equipPickSourceId; });
var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; });
if (!equipment || !creature) { cancelEquipPickMode(); return; }
if (!aiIsCreature(creature)) {
addLogEntry('\u26A0 ' + creature.name + ' is not a creature \u2014 cannot attach.');
cancelEquipPickMode();
return;
}
// If equipment is already on another creature, detach first
if (equipment.attachedTo) {
detachEquipment(equipment.id, true);
}
pushUndoState();
// Attach
equipment.attachedTo = creatureId;
if (!creature.attachments) creature.attachments = [];
if (creature.attachments.indexOf(equipment.id) === -1) {
creature.attachments.push(equipment.id);
}
var attachIcon = isAura(equipment) ? '\u2728' : '\u2694';
addLogEntry(attachIcon + ' ' + equipment.name + ' attached to ' + creature.name + ' ');
cancelEquipPickMode();
repositionAttachments(creatureId);
}
function detachEquipment(equipmentId, silent) {
var equipment = gameState.battlefieldCards.find(function(c) { return c.id === equipmentId; });
if (!equipment || !equipment.attachedTo) return;
var creatureId = equipment.attachedTo;
var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; });
if (creature && creature.attachments) {
var idx = creature.attachments.indexOf(equipmentId);
if (idx !== -1) creature.attachments.splice(idx, 1);
}
equipment.attachedTo = null;
// Auras go to graveyard when detached (they can't exist unattached)
if (isAura(equipment)) {
var el = document.getElementById('bc-' + equipmentId);
if (el) {
el.classList.remove('attached-equipment', 'attached-aura');
el.style.clipPath = '';
el.style.zIndex = '';
}
if (!silent) {
pushUndoState();
addLogEntry('\u2728 ' + equipment.name + ' (Aura) lost its target and was moved to graveyard');
}
moveCardToZone(equipmentId, 'graveyard');
// Reposition remaining attachments on the creature
if (creature && creature.attachments && creature.attachments.length > 0) {
repositionAttachments(creatureId);
}
return;
}
// Equipment: return to its type zone position
var sectionArea = document.getElementById('bf-section-area-' + equipment.ownerIndex);
var battlefield = document.getElementById('battlefield');
var rect = sectionArea ? sectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect();
var zone = getCardTypeZone(equipment.typeLine || '');
var zones = {
creature: { x1: 0.25, x2: 0.65, y1: 0.05, y2: 0.42 },
artifact: { x1: 0.68, x2: 0.95, y1: 0.05, y2: 0.42 },
land: { x1: 0.03, x2: 0.32, y1: 0.55, y2: 0.90 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.90 },
other: { x1: 0.25, x2: 0.70, y1: 0.25, y2: 0.75 }
};
var z = zones[zone] || zones.artifact;
equipment.x = z.x1 * rect.width + Math.random() * ((z.x2 - z.x1) * rect.width - 110) + 4;
equipment.y = z.y1 * rect.height + Math.random() * ((z.y2 - z.y1) * rect.height - 150) + 4;
var el = document.getElementById('bc-' + equipmentId);
if (el) {
el.style.left = equipment.x + 'px';
el.style.top = equipment.y + 'px';
el.classList.remove('attached-equipment', 'attached-equip');
el.style.clipPath = '';
el.style.zIndex = '';
}
if (!silent) {
pushUndoState();
addLogEntry('\u2694 ' + equipment.name + ' unequipped');
}
// Reposition remaining attachments on the creature
if (creature && creature.attachments && creature.attachments.length > 0) {
repositionAttachments(creatureId);
}
}
function repositionAttachments(creatureId) {
var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; });
if (!creature || !creature.attachments) return;
var creatureEl = document.getElementById('bc-' + creatureId);
if (!creatureEl) return;
var cardH = creatureEl.offsetHeight || 140;
var cardW = creatureEl.offsetWidth || 100;
var attachOffset = 40; // how much of each attached card peeks out below
for (var i = 0; i < creature.attachments.length; i++) {
var eqId = creature.attachments[i];
var eq = gameState.battlefieldCards.find(function(c) { return c.id === eqId; });
if (!eq) continue;
// Position attached card below the creature, offset for each attachment
eq.x = creature.x;
eq.y = creature.y + cardH + (i * attachOffset);
var el = document.getElementById('bc-' + eqId);
if (el) {
el.style.left = eq.x + 'px';
el.style.top = eq.y + 'px';
el.classList.add('attached-equipment');
el.classList.remove('attached-aura', 'attached-equip');
// Distinguish aura vs equipment visually
if (isAura(eq)) {
el.classList.add('attached-aura');
} else {
el.classList.add('attached-equip');
}
// Show only the top portion (card name area) by clipping the bottom
el.style.clipPath = 'inset(50% 0 0 0)';
el.style.zIndex = '1';
}
}
}
// When a creature moves (drag), move its attachments too
function moveAttachmentsWithCreature(creatureId) {
var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; });
if (!creature || !creature.attachments || creature.attachments.length === 0) return;
repositionAttachments(creatureId);
}
// When a creature leaves the battlefield, detach all equipment
function detachAllFromCreature(creatureId) {
var creature = gameState.battlefieldCards.find(function(c) { return c.id === creatureId; });
if (!creature || !creature.attachments) return;
var attachmentsCopy = creature.attachments.slice();
for (var i = 0; i < attachmentsCopy.length; i++) {
detachEquipment(attachmentsCopy[i], true);
}
}
/**
* Apply static ETB effects to a card that just entered the battlefield.
* Handles: enters tapped, enters with counters, defender, other static abilities.
*/
function applyStaticETBEffects(cardData) {
if (!cardData || !gameState) return;
var oracle = (cardData.oracleText || '').toLowerCase();
var typeLine = (cardData.typeLine || '').toLowerCase();
var ownerIdx = cardData.ownerIndex !== undefined ? cardData.ownerIndex : cardData.ownerId;
var player = gameState.players[ownerIdx];
var playerName = player ? player.name : 'Unknown';
// ---- Enters the battlefield tapped ----
if (/enters the battlefield tapped/i.test(oracle) ||
/enters tapped/i.test(oracle) ||
/this (?:land|creature|permanent|artifact) enters tapped/i.test(oracle)) {
cardData.tapped = true;
addLogEntry('\u27a1 ' + cardData.name + ' enters the battlefield tapped');
}
// ---- Enters with +1/+1 counters ----
var plusMatch = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) \+1\/\+1 counter/i);
if (plusMatch) {
var numStr = plusMatch[1];
var num = parseInt(numStr, 10);
if (isNaN(num)) {
var wordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9,ten:10,eleven:11,twelve:12,thirteen:13};
num = wordMap[numStr.toLowerCase()] || 0;
}
if (num > 0) {
if (!cardData.counters) cardData.counters = {};
cardData.counters['+1/+1'] = (cardData.counters['+1/+1'] || 0) + num;
addLogEntry('\u2728 ' + cardData.name + ' enters with ' + num + ' +1/+1 counter' + (num > 1 ? 's' : ''));
}
}
// ---- Enters with -1/-1 counters ----
var minusMatch = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) \-1\/\-1 counter/i);
if (minusMatch) {
var mNum = parseInt(minusMatch[1], 10);
if (isNaN(mNum)) {
var mWordMap = {one:1,two:2,three:3,four:4,five:5};
mNum = mWordMap[minusMatch[1].toLowerCase()] || 0;
}
if (mNum > 0) {
if (!cardData.counters) cardData.counters = {};
cardData.counters['-1/-1'] = (cardData.counters['-1/-1'] || 0) + mNum;
addLogEntry('\u2728 ' + cardData.name + ' enters with ' + mNum + ' -1/-1 counter' + (mNum > 1 ? 's' : ''));
}
}
// ---- Enters with charge/lore/other named counters ----
var namedCtrs = oracle.match(/enters (?:the battlefield )?with (\w+|\d+) (\w+) counter/i);
if (namedCtrs && !/\+1|\-1/.test(namedCtrs[0])) {
var nCnt = parseInt(namedCtrs[1], 10);
if (isNaN(nCnt)) {
var nWordMap = {one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8};
nCnt = nWordMap[namedCtrs[1].toLowerCase()] || 0;
}
var ctrName = namedCtrs[2].toLowerCase();
if (nCnt > 0 && ctrName !== 'loyalty') {
if (!cardData.counters) cardData.counters = {};
cardData.counters[ctrName] = (cardData.counters[ctrName] || 0) + nCnt;
addLogEntry('\u2728 ' + cardData.name + ' enters with ' + nCnt + ' ' + ctrName + ' counter' + (nCnt > 1 ? 's' : ''));
}
}
// ---- ETB draw a card (non-trigger static like cantrip creatures) ----
// "When [this] enters, draw a card" is handled by triggers; this catches "enters...draw"
// ---- ETB gain life ----
var etbGainLife = oracle.match(/when .* enters.*gain (\d+) life/i);
// Handled by trigger system, no need to duplicate here
// ---- Creatures with Haste should not have summoning sickness regardless ----
// (Already handled by hasKeyword check in hasSummoningSickness)
// ---- Creatures that enter with specific abilities ----
// "other creatures you control have haste" is an anthem, handled by hasKeyword
// ---- Mana-producing lands auto-detect (tap abilities parsed elsewhere) ----
}
function addCardToBattlefield(name, imageUrl, typeLine, ownerIndex, x, y, oracleText, extraData) {
pushUndoState("Play " + name);
// New 4-quadrant layout: each player has battle-zone-N
var pNum = (ownerIndex || 0) + 1;
const battlefield = document.getElementById('battle-zone-' + pNum) || document.getElementById('battlefield');
if (!battlefield) return;
var sectionArea = document.getElementById('bf-section-area-' + ownerIndex);
var rect = sectionArea ? sectionArea.getBoundingClientRect() : battlefield.getBoundingClientRect();
var cardX, cardY;
if (x !== undefined && y !== undefined) {
cardX = x;
cardY = y;
} else {
// Place cards by type in designated battlefield zones
var zone = getCardTypeZone(typeLine || '');
var cardW = 110, cardH = 150;
// Zone regions (percentage of section area)
// Creatures: top center (30%-70% x, 5%-45% y)
// Artifacts/Enchantments: top right (70%-95% x, 5%-45% y)
// Lands: bottom left (5%-35% x, 55%-90% y)
// Instants/Sorceries: bottom center (35%-65% x, 55%-90% y)
// Other: center (30%-70% x, 25%-75% y)
var zones = {
creature: { x1: 0.25, x2: 0.65, y1: 0.05, y2: 0.42 },
artifact: { x1: 0.68, x2: 0.95, y1: 0.05, y2: 0.42 },
land: { x1: 0.03, x2: 0.32, y1: 0.55, y2: 0.90 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.90 },
other: { x1: 0.25, x2: 0.70, y1: 0.25, y2: 0.75 }
};
var z = zones[zone] || zones.other;
var areaW = rect.width;
var areaH = rect.height;
// Count existing cards of same type to offset placement and avoid stacking
var sameTypeCount = 0;
if (gameState && gameState.battlefieldCards) {
sameTypeCount = gameState.battlefieldCards.filter(function(c) {
return c.ownerIndex === ownerIndex && getCardTypeZone(c.typeLine || '') === zone;
}).length;
}
var zoneW = (z.x2 - z.x1) * areaW;
var zoneH = (z.y2 - z.y1) * areaH;
var cols = Math.max(1, Math.floor(zoneW / (cardW + 8)));
var row = Math.floor(sameTypeCount / cols);
var col = sameTypeCount % cols;
cardX = z.x1 * areaW + col * (cardW + 8) + 4;
cardY = z.y1 * areaH + row * (cardH + 8) + 4;
// Wrap within zone bounds
if (cardX + cardW > z.x2 * areaW) cardX = z.x1 * areaW + 4;
if (cardY + cardH > z.y2 * areaH) cardY = z.y1 * areaH + 4;
}
const parsedTriggers = parseTriggers(name, oracleText || '');
const cardData = {
id: nextCardId++,
name,
imageUrl,
typeLine,
ownerIndex,
ownerId: ownerIndex,
x: cardX,
y: cardY,
tapped: false,
faceDown: false,
oracleText: oracleText || '',
triggers: parsedTriggers,
turnPlayed: gameState ? `${gameState.turnNumber}_${gameState.currentPlayerIndex}` : '0_0',
damageMarked: 0,
damageThisTurn: 0,
...(extraData || {}),
};
gameState.battlefieldCards.push(cardData);
// Apply static ETB effects (enters tapped, enters with counters, etc.)
applyStaticETBEffects(cardData);
// Rescan replacement effects since a new permanent entered
scanReplacementEffects();
renderBattlefieldCard(cardData);
animateCardEnter(cardData.id);
addLogEntry(`${gameState.players[ownerIndex].name} played ${name} `);
// Feature 8: color identity check
var _ciViolation = checkColorIdentityViolation(cardData, ownerIndex);
if (_ciViolation) {
showColorIdentityWarning(name, _ciViolation);
addLogEntry('⚠ ' + name + ' may violate color identity (' + _ciViolation.join(',') + ')');
}
gameState.stormCount++;
updateStormCounter();
// Fire ETB triggers: first check all existing battlefield permanents for
// "whenever a creature/permanent enters" triggers, then the new card's own ETB.
fireBattlefieldTriggers('etb', function(bfCard) {
return bfCard.id !== cardData.id; // other permanents watching for ETBs
});
// Also fire the new card's own ETB trigger ("When [this card] enters...").
fireCardTriggers(cardData, ['etb']);
// Fire nontoken_etb if the entering card is NOT a token and IS a creature
if (!cardData.isToken && cardData.typeLine && /creature/i.test(cardData.typeLine)) {
fireBattlefieldTriggers('nontoken_etb', function(bfCard) {
return bfCard.id !== cardData.id;
});
}
if (cardData.typeLine && /land/i.test(cardData.typeLine)) {
if (gameState.rulesEngine && gameState.rulesEngine.enforceLandLimit && gameState.landsPlayedThisTurn >= gameState.maxLandsPerTurn) {
addLogEntry('â ' + cardData.name + ' â land drop limit reached this turn! (Playing anyway)');
}
if (gameState.landsPlayedThisTurn !== undefined) gameState.landsPlayedThisTurn++;
renderPhaseTracker();
}
// Fire landfall triggers if this is a land
if (cardData.typeLine && /land/i.test(cardData.typeLine)) {
fireBattlefieldTriggers('landfall');
}
// Fire cast triggers for all permanents watching spells being cast
fireBattlefieldTriggers('cast');
// Planeswalker ETB: auto-set loyalty counters from starting loyalty
if (cardData.typeLine && /planeswalker/i.test(cardData.typeLine)) {
var startingLoyalty = 0;
if (cardData.loyalty) {
startingLoyalty = parseInt(cardData.loyalty, 10) || 0;
}
if (startingLoyalty > 0) {
if (!cardData.counters) cardData.counters = {};
cardData.counters['loyalty'] = startingLoyalty;
addLogEntry('\u25c6 ' + name + ' enters with ' + startingLoyalty + ' loyalty');
// Re-render to show loyalty counter badge
var bcEl = document.getElementById('bc-' + cardData.id);
if (bcEl) bcEl.remove();
renderBattlefieldCard(cardData);
}
// Track loyalty ability usage per turn
cardData.loyaltyUsedThisTurn = false;
}
// Auto-attach: Auras prompt for target creature on ETB
if (isAura(cardData)) {
// Check if there are any creatures to attach to
var ownerCreatures = gameState.battlefieldCards.filter(function(c) {
return c.id !== cardData.id && aiIsCreature(c);
});
if (ownerCreatures.length > 0) {
enterEquipPickMode(cardData.id);
} else {
addLogEntry('\u26A0 No creatures on the battlefield to enchant with ' + cardData.name + ' ');
}
}
}
function getBattlefieldCardProtectionBadge(cardData) {
var oracle = (cardData.oracleText || '').toLowerCase();
if (/shroud/.test(oracle)) return 'ðŦ
';
if (/hexproof/.test(oracle)) return 'ðĄ
';
if (/protection from/.test(oracle)) return 'ð
';
return '';
}
function renderBattlefieldCard(cardData) {
const battlefield = document.getElementById('battlefield');
if (!battlefield) return;
// Target the player's section area; fall back to #battlefield if sections not built yet
var sectionArea = document.getElementById('bf-section-area-' + cardData.ownerIndex);
var cardContainer = sectionArea || battlefield;
// Remove label if cards exist
const label = battlefield.querySelector('.battlefield-label');
if (label) label.style.opacity = '0';
const card = document.createElement('div');
card.className = 'battlefield-card';
card.id = `bc-${cardData.id}`;
card.style.left = cardData.x + 'px';
card.style.top = cardData.y + 'px';
card.style.borderColor = gameState.players[cardData.ownerIndex]?.color || 'transparent';
if (cardData.tapped) card.classList.add('tapped');
if (cardData.faceDown) card.classList.add('face-down');
if (hasSummoningSickness(cardData)) card.classList.add('summoning-sick');
if (cardData.isToken) card.classList.add('token-glow');
// Build keyword badges
const kwBadges = getKeywordBadges(cardData);
let badgeHtml = '';
if (kwBadges.length > 0) {
badgeHtml = '';
for (const b of kwBadges) {
badgeHtml += `${b.icon} `;
}
badgeHtml += '
';
}
// Check for activated/triggered abilities indicators
const abilities = parseCardAbilities(cardData.oracleText || '', cardData.typeLine || '', cardData.name || '');
let abilityIndicators = '';
if (abilities.activated.length > 0) {
abilityIndicators += `âĄ${abilities.activated.length > 1 ? abilities.activated.length : ''} `;
}
if (abilities.triggered.length > 0) {
abilityIndicators += `â${abilities.triggered.length > 1 ? abilities.triggered.length : ''} `;
}
if (abilities.mana.length > 0) {
abilityIndicators += `â `;
}
// Feature 6: Protection/hexproof/shroud badge
const protectionBadge = getBattlefieldCardProtectionBadge(cardData);
card.innerHTML = `
${protectionBadge}
${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 = 'All ';
gameState.players.forEach((p, idx) => {
const short = p.name.length > 6 ? p.name.substring(0, 6) : p.name;
html += `${short} `;
});
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 =
'' +
'' +
'' +
'' +
'';
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 =
'' +
'' +
'';
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 = '';
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 += '';
// 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 += '';
}
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 = '';
// 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 += '' + ct.icon + ' ';
gridHtml += '' + ct.shortLabel + ' ';
gridHtml += ' ';
}
gridHtml += '
';
// Custom counter input
var customHtml = '';
customHtml += ' ';
customHtml += 'Add ';
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 `
`;
}).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 ' : ''}
Mana Pool
0
Clear
Empty
W
U
B
R
G
C
CMD Damage
21 = elim.
${cmdDamageHtml || 'No opponents '}
â
${player.poison}
poison
ðĪ Draw
ðŪ Scry
â° Mill
ð Shuffle
ð Browse
ð Deck Manager
âĻ Create Token
${gameState.monarchPlayerIndex === player.id ? '\ud83d\udc51 Monarch' : '\ud83d\udc51 Become Monarch'}
â° ${gravCount} ⎥ ${exileCount} â ${cmdCount} â ${handCount} ð ${libCount}
${player.commanderCardName2 ? 'ð+ð ' + player.commanderCardName2 : '+ Set Partner'}
${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();
}
// ============================================================
// 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();
}
// ============================================================
// 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}
Cancel
Set Life
`;
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!
Continue Spectating
`;
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 = '';
html += 'Row = damage from row commander. Column = player taking damage. Click cell +1, right-click \u22121.
';
html += '';
// Header row
html += 'From \ To ';
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 += '' + dmg + ' ';
}
});
html += ' ';
});
html += '
';
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 =
'' +
'' +
'
' +
'
Enter the name of ' + player.name + '’s partner commander:
' +
'
' +
'
' +
'Set Partner ' +
'Cancel ' +
'
';
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 = '';
html += '';
alerts.forEach(function(a) {
html += '
';
html += '
' + a.msg + ' ';
if (a.type === 'toughness' && a.cardId) {
html += '
Move to Graveyard ';
html += '
Dismiss ';
} else if (a.type === 'pw_loyalty' && a.cardId) {
html += '
Move to Graveyard ';
html += '
Dismiss ';
} else if (a.type === 'aura' && a.cardId) {
html += '
Move to Graveyard ';
html += '
Dismiss ';
} else if (a.type === 'lethal_damage' && a.cardId) {
html += '
Destroy (Lethal Damage) ';
html += '
Dismiss ';
} else if (a.type === 'lethal_damage_marked' && a.cardId) {
html += '
Destroy (Damage Marked) ';
html += '
Dismiss ';
} 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 += 'Keep: ' + cardLabel + ' ';
}
html += '
';
html += '
Dismiss ';
} else {
html += '
Eliminate ';
html += '
Dismiss ';
}
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?
' +
'
' +
'Command Zone ' +
'' + (intendedZone === 'graveyard' ? 'Graveyard' : 'Exile') + ' ' +
'
' +
'
';
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 + '
' +
'
' +
'Cast from Command Zone ' +
'Cancel ' +
'
';
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 += '' + 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 += '' + t.label + ' ';
});
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 += '';
html += '' + s.label + ' ';
html += '';
html += ' ';
html += ' ';
html += ' ';
html += ' ';
if (s.desc) html += '' + s.desc + '
';
});
// Timer section
html += 'Timer
';
var timerChecked = r.turnTimerEnabled ? ' checked' : '';
html += '';
html += 'Turn timer (chess clock) ';
html += '';
html += ' ';
html += ' ';
html += ' ';
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 += 'Dark';
html += ' ';
html += '';
html += 'Light';
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 += '';
html += '' + t.label + ' ';
html += '';
html += ' ';
html += ' ';
html += ' ';
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 += '' + label + ' ';
});
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 += 'Show AI thought process ';
html += '';
html += ' ';
html += ' ';
html += ' ';
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 = '';
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 += '
';
}
html += '
';
if (isTop) html += '
Resolving Next
';
html += '
' + escapeHtml(entry.name) + '
';
if (entry.typeLine) html += '
' + escapeHtml(entry.typeLine) + '
';
if (entry.manaCost) html += '
' + escapeHtml(entry.manaCost) + '
';
html += '
Cast by: ' + escapeHtml(casterName) + '
';
html += '
';
if (isTop) {
html += '
â Resolve ';
}
html += '
';
}
html += '
';
html += '';
html += '→ Pass Priority ';
html += 'âļ Hold Priority ';
html += 'âšâš Resolve All ';
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 += '
';
} else {
html += '
âĶ
';
}
html += '
';
if (isTop) html += '
Resolving Next
';
html += '
' + escapeHtml(entry.name) + '
';
if (entry.typeLine) html += '
' + escapeHtml(entry.typeLine) + '
';
if (entry.manaCost) html += '
' + escapeHtml(entry.manaCost) + '
';
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 += '
' + escapeHtml(TRIGGER_TYPE_LABELS[trig.type] || trig.type) + '
';
if (trig.effectHint) {
html += '
' + escapeHtml(trig.effectHint) + '
';
}
html += '
' + escapeHtml(tName) + '
';
html += '
';
if (isFirst) html += '
â ';
html += '
× ';
html += '
';
}
// Actions bar
html += '';
if (spellStack.length > 1) {
html += 'âšâš Resolve Stack ';
}
if (trigStack.length > 1) {
html += 'â All Triggers ';
}
if (spellStack.length > 0) {
html += '→ Pass Priority ';
html += 'âļ Hold ';
}
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 = '\u2192 Pass ' +
'\ud83c\udccf Cast / Activate ';
} else if (isHuman) {
actionsEl.innerHTML = '\u2192 Pass for ' + escapeHtml(playerName) + ' ' +
'\ud83c\udccf Act for ' + escapeHtml(playerName) + ' ';
} 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 = '\u2705 Done Acting ';
}
}
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 +=
'\uD83D\uDD00 Shuffle ' +
'\uD83D\uDCE4 Draw ' +
'\uD83D\uDD2E Scry ';
}
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 =
' ' +
typeBadgeHtml +
'' + (card.name || 'Unknown') + '
';
// Hover preview
thumb.setAttribute('onmouseenter', "showFloatingCardPreview('" + (card.imageUrl || '').replace(/'/g, "\\'") + "', event, { name: '" + (card.name || '').replace(/'/g, "\\'") + "', typeLine: '" + (card.typeLine || '').replace(/'/g, "\\'") + "', oracleText: '" + (card.oracleText || '').replace(/'/g, "\\'").replace(/\n/g, '\\n') + "' })");
thumb.setAttribute('onmousemove', 'positionFloatingPreview(event)');
thumb.setAttribute('onmouseleave', 'hideFloatingCardPreview()');
// Left-click: play to battlefield
thumb.setAttribute('onclick', '(function(){zmPlayCard(' + zoneModalPlayerId + ',"' + activeZoneTab + '",' + idx + ')})()');
// Right-click: context menu
thumb.setAttribute('oncontextmenu', '(function(e){e.preventDefault();showZoneCardCtx(e.clientX,e.clientY,' + zoneModalPlayerId + ',"' + activeZoneTab + '",' + idx + ')})(event)');
grid.appendChild(thumb);
});
}
// Zone modal â play card to battlefield
function zmPlayCard(playerId, zone, cardIndex) {
pushUndoState();
var player = gameState.players[playerId];
if (!player) return;
var cards = player.zones[zone];
if (!cards || cardIndex < 0 || cardIndex >= cards.length) return;
var card = cards.splice(cardIndex, 1)[0];
// Commander tax tracking
if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) {
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
}
addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '',
{ manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords });
addLogEntry('' + card.name + ' moved from ' + player.name + "'s " + zone + ' to battlefield');
updatePlayerZones();
renderZoneModal();
}
// Zone modal context menu
function showZoneCardCtx(x, y, playerId, zone, cardIndex) {
hideZoneCardCtx();
if (!_zoneCardCtx) {
_zoneCardCtx = document.createElement('div');
_zoneCardCtx.className = 'zone-card-ctx';
_zoneCardCtx.id = 'zone-card-ctx';
document.body.appendChild(_zoneCardCtx);
document.addEventListener('click', function(e) {
if (_zoneCardCtx && !_zoneCardCtx.contains(e.target)) hideZoneCardCtx();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideZoneCardCtx();
});
}
var player = gameState.players[playerId];
var card = player && player.zones[zone] ? player.zones[zone][cardIndex] : null;
if (!card) return;
var items = [];
items.push({ icon: '\u2694\ufe0f', label: 'Play to Battlefield', action: 'battlefield' });
if (zone !== 'hand') items.push({ icon: '\u270B', label: 'Return to Hand', action: 'hand' });
items.push({ divider: true });
if (zone !== 'graveyard') items.push({ icon: '\u2620', label: 'Send to Graveyard', action: 'graveyard' });
if (zone !== 'exile') items.push({ icon: '\u2b21', label: 'Send to Exile', action: 'exile' });
if (zone !== 'command') items.push({ icon: '\u{1F451}', label: 'Send to Command Zone', action: 'command' });
items.push({ divider: true });
items.push({ icon: '\uD83D\uDCDA', label: 'Top of Library', action: 'library-top' });
items.push({ icon: '\uD83D\uDCDA', label: 'Bottom of Library', action: 'library-bottom' });
items.push({ divider: true });
items.push({ icon: '\u2715', label: 'Remove from Game', action: 'remove', danger: true });
_zoneCardCtx.innerHTML = '' + card.name + '
';
items.forEach(function(item) {
if (item.divider) {
var div = document.createElement('div');
div.className = 'zone-card-ctx-divider';
_zoneCardCtx.appendChild(div);
return;
}
var btn = document.createElement('button');
btn.className = 'zone-card-ctx-item' + (item.danger ? ' danger' : '');
btn.innerHTML = '' + item.icon + ' ' + item.label;
btn.setAttribute('onclick', '(function(){hideZoneCardCtx();handleZmCtxAction(' + playerId + ',"' + zone + '",' + cardIndex + ',"' + item.action + '")})()');
_zoneCardCtx.appendChild(btn);
});
// Position
_zoneCardCtx.style.left = Math.min(x, window.innerWidth - 200) + 'px';
_zoneCardCtx.style.top = Math.min(y, window.innerHeight - 300) + 'px';
_zoneCardCtx.classList.add('visible');
}
function handleZmCtxAction(playerId, zone, cardIndex, action) {
pushUndoState();
var player = gameState.players[playerId];
if (!player) return;
var cards = player.zones[zone];
if (!cards || cardIndex < 0 || cardIndex >= cards.length) return;
var card = cards[cardIndex];
var zoneLabels = { graveyard: 'graveyard', exile: 'exile', command: 'command zone', hand: 'hand', library: 'library' };
if (action === 'battlefield') {
cards.splice(cardIndex, 1);
if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) {
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
}
addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '',
{ manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '', colorIdentity: card.colorIdentity, keywords: card.keywords });
addLogEntry('' + card.name + ' moved from ' + (zoneLabels[zone] || zone) + ' to battlefield');
} else if (action === 'hand') {
cards.splice(cardIndex, 1);
player.zones.hand.push(card);
addLogEntry('' + card.name + ' returned to ' + player.name + "'s hand from " + (zoneLabels[zone] || zone));
} else if (action === 'graveyard' || action === 'exile' || action === 'command') {
cards.splice(cardIndex, 1);
player.zones[action].push(card);
addLogEntry('' + card.name + ' moved to ' + (zoneLabels[action] || action) + ' from ' + (zoneLabels[zone] || zone));
} else if (action === 'library-top') {
cards.splice(cardIndex, 1);
player.zones.library.unshift(card);
addLogEntry('' + card.name + ' put on top of ' + player.name + "'s library");
} else if (action === 'library-bottom') {
cards.splice(cardIndex, 1);
player.zones.library.push(card);
addLogEntry('' + card.name + ' put on bottom of ' + player.name + "'s library");
} else if (action === 'remove') {
cards.splice(cardIndex, 1);
addLogEntry('' + card.name + ' removed from game');
}
updatePlayerZones();
renderZoneModal();
renderHandArea();
}
// ============================================================
// LIBRARY FUNCTIONS
// ============================================================
function moveCardToLibrary(cardId, position) {
pushUndoState();
const idx = gameState.battlefieldCards.findIndex(c => c.id === cardId);
if (idx === -1) return;
const cardData = gameState.battlefieldCards[idx];
const owner = gameState.players[cardData.ownerIndex];
if (!owner) return;
// Clean up stack membership before leaving battlefield
cleanupCardFromStack(cardId);
// Tokens cease to exist when they leave the battlefield
if (cardData.isToken) {
fireBattlefieldTriggers('ltb', bfCard => bfCard.id !== cardData.id);
fireCardTriggers(cardData, ['ltb']);
gameState.battlefieldCards.splice(idx, 1);
const cardEl = document.getElementById('bc-' + cardId);
if (cardEl) {
cardEl.style.transition = 'opacity 0.3s, transform 0.3s';
cardEl.style.opacity = '0';
cardEl.style.transform = 'scale(0.8)';
setTimeout(() => cardEl.remove(), 300);
}
addLogEntry(`${cardData.name} ceased to exist (token)`);
if (!gameState.battlefieldCards.length) {
const label = document.querySelector('.battlefield-label');
if (label) label.style.opacity = '';
}
updateBattlefieldSectionCounts();
updatePlayerZones();
return;
}
const cardObj = {
name: cardData.name,
imageUrl: cardData.imageUrl,
typeLine: cardData.typeLine,
oracleText: cardData.oracleText || '',
manaCost: cardData.manaCost || '',
power: cardData.power,
toughness: cardData.toughness,
loyalty: cardData.loyalty,
};
// Fire LTB triggers
fireBattlefieldTriggers('ltb', bfCard => bfCard.id !== cardData.id);
fireCardTriggers(cardData, ['ltb']);
if (position === 'top') {
owner.zones.library.unshift(cardObj); // top of library = index 0
} else {
owner.zones.library.push(cardObj); // bottom = last index
}
// Remove from battlefield
gameState.battlefieldCards.splice(idx, 1);
const cardEl = document.getElementById('bc-' + cardId);
if (cardEl) {
cardEl.style.transition = 'opacity 0.3s, transform 0.3s';
cardEl.style.opacity = '0';
cardEl.style.transform = 'scale(0.8)';
setTimeout(() => cardEl.remove(), 300);
}
addLogEntry('' + cardData.name + ' put on ' + position + ' of ' + owner.name + "'s library ");
if (!gameState.battlefieldCards.length) {
const label = document.querySelector('.battlefield-label');
if (label) label.style.opacity = '';
}
updateBattlefieldSectionCounts();
updatePlayerZones();
}
function drawCard(playerId) {
if (!gameState) return;
pushUndoState();
// Apply replacement effects to draw
var drawEvent = applyReplacements('draw', { playerIdx: playerId });
if (!drawEvent) return; // Draw was fully replaced/prevented
var actualPlayer = drawEvent.playerIdx;
const player = gameState.players[actualPlayer];
if (!player) return;
if (player.zones.library.length === 0) {
addLogEntry('' + player.name + ' tried to draw but library is empty!');
return;
}
const card = player.zones.library.shift(); // Take from top (index 0)
player.zones.hand.push(card);
addLogEntry('' + player.name + ' drew a card (' + player.zones.hand.length + ' in hand)');
updatePlayerZones();
// Fire draw triggers for all battlefield permanents watching for card draw
fireBattlefieldTriggers('draw');
// If zone modal is open, re-render
if (zoneModalPlayerId === actualPlayer) renderZoneModal();
}
// ============================================================
// MULLIGAN SYSTEM (London Mulligan â Commander variant)
// Commander: first mulligan is free (no cards put back).
// Each subsequent mulligan: draw 7, then put N on bottom.
// ============================================================
/**
* Start or continue a mulligan for a player.
* - Shuffles current hand back into library
* - Shuffles library
* - Draws 7 new cards
* - Increments mulliganCount
* - If mulliganCount > 1 (past the free mull), enters "bottom" phase
*/
function performMulligan(playerId) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[playerId];
if (!player) return;
// Initialize mulligan tracking if needed
if (player.mulliganCount === undefined) player.mulliganCount = 0;
// Put hand back into library
while (player.zones.hand.length > 0) {
player.zones.library.push(player.zones.hand.pop());
}
// Shuffle library (Fisher-Yates)
const lib = player.zones.library;
for (let i = lib.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lib[i], lib[j]] = [lib[j], lib[i]];
}
// Draw 7 new cards
for (let i = 0; i < 7 && player.zones.library.length > 0; i++) {
player.zones.hand.push(player.zones.library.shift());
}
player.mulliganCount++;
// Commander free mulligan: first mulligan (count=1) is free, no cards go back
// Second mulligan (count=2): put 1 on bottom, third: put 2, etc.
const cardsToPutBack = Math.max(0, player.mulliganCount - 1);
if (cardsToPutBack > 0) {
// Enter "pick cards to put on bottom" phase
player.mulliganBottomCount = cardsToPutBack;
player.mulliganPhase = 'bottom'; // waiting for player to pick cards
addLogEntry(`ð ${player.name} mulliganed (${player.mulliganCount}x) â must put ${cardsToPutBack} card${cardsToPutBack > 1 ? 's' : ''} on the bottom`);
} else {
// Free mulligan, no cards to put back
player.mulliganPhase = 'deciding'; // can keep or mull again
addLogEntry(`ð ${player.name} took a free mulligan â drew 7 new cards`);
}
updatePlayerZones();
renderHandArea();
renderMulliganUI();
}
/**
* Player keeps their current hand â ends mulligan phase.
*/
function keepHand(playerId) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[playerId];
if (!player) return;
player.mulliganPhase = null;
player.mulliganBottomCount = 0;
const mullCount = player.mulliganCount || 0;
if (mullCount === 0) {
addLogEntry(`â ${player.name} kept their opening hand`);
} else {
addLogEntry(`â ${player.name} kept their hand after ${mullCount} mulligan${mullCount > 1 ? 's' : ''}`);
}
renderHandArea();
renderMulliganUI();
}
/**
* Put a specific card from hand on the bottom of the library (mulligan bottom phase).
*/
function mulliganPutOnBottom(playerId, handIndex) {
if (!gameState) return;
const player = gameState.players[playerId];
if (!player || player.mulliganPhase !== 'bottom') return;
const card = player.zones.hand.splice(handIndex, 1)[0];
if (!card) return;
player.zones.library.push(card); // goes to bottom
player.mulliganBottomCount--;
addLogEntry(`âĐ ${player.name} put a card on the bottom (${player.mulliganBottomCount} remaining)`);
if (player.mulliganBottomCount <= 0) {
// Done putting cards back â now they can decide keep vs. mulligan again
player.mulliganPhase = 'deciding';
addLogEntry(`â
${player.name} finished putting cards on bottom â ${player.zones.hand.length} cards in hand`);
}
updatePlayerZones();
renderHandArea();
renderMulliganUI();
}
/**
* Render the mulligan UI bar above the hand area.
*/
function renderMulliganUI() {
const existing = document.getElementById('mulligan-bar');
if (existing) existing.remove();
if (!gameState) return;
const player = gameState.players[0]; // Player 1
if (!player) return;
// Only show if player has a deck loaded (hand or library has cards)
const hasCards = player.zones.hand.length > 0 || player.zones.library.length > 0;
if (!hasCards) return;
// If no mulligan phase active, don't show
if (!player.mulliganPhase) return;
const handArea = document.getElementById('hand-area');
if (!handArea) return;
const bar = document.createElement('div');
bar.id = 'mulligan-bar';
bar.className = 'mulligan-bar';
const mullCount = player.mulliganCount || 0;
if (player.mulliganPhase === 'bottom') {
// Player needs to pick cards to put on bottom
const remaining = player.mulliganBottomCount || 0;
bar.innerHTML = `
Select ${remaining} card${remaining !== 1 ? 's' : ''} to put on bottom of library
Click a card in your hand to put it on bottom
`;
} else if (player.mulliganPhase === 'deciding') {
// Player can keep or mulligan again
const nextPutBack = Math.max(0, mullCount); // after next mull, they'd put back this many
bar.innerHTML = `
${mullCount === 0 ? 'Opening hand â 7 cards' : 'Mulligan #' + mullCount + ' â ' + player.zones.hand.length + ' cards in hand'}
â Keep Hand
ð Mulligan${nextPutBack > 0 ? ' (put ' + nextPutBack + ' back)' : ' (free)'}
`;
}
// 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.typeLine || '')}
\u270B Hand
\u2694 Battlefield
\u2620 Graveyard
\u2B21 Exile
`;
// 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 `
`;
}).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
${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 `
${p.name}
${ptLabel ? `${ptLabel} ` : ''}
${p.type.replace('Creature \u2014 ','').replace('Artifact \u2014 ','')}${abilShort}
`;
}).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 = ` `;
}
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 `
${h.name}
${ptText}
`;
}).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 += '' +
entry.count + 'x ' + entry.name + (entry.pt ? ' ' + entry.pt + ' ' : '') +
' ';
});
html += '🗑 Destroy All ';
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) + '
';
// 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?
' +
'
' +
'1 ' +
'2 ' +
'3 ' +
'5 ' +
'10 ' +
'
' +
'
' +
'Custom: ' +
' ' +
'
' +
'
' +
'Cancel ' +
'Scry ' +
'
' +
'
';
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)
' +
'
' +
'
' +
'Cancel ' +
'Mill ' +
'
' +
'
';
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 += '' +
'ð Cast ' + player.commanderCardName + (tax > 0 ? ' (+' + tax + ' tax)' : '') + ' ';
}
if (player.commanderCardName2) {
var tax2 = getCommanderTax2(playerId);
html += '' +
'ð Cast ' + player.commanderCardName2 + (tax2 > 0 ? ' (+' + tax2 + ' tax)' : '') + ' ';
}
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 + 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}
${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 = `
`;
// Position
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.classList.add('visible');
// Adjust if offscreen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (x - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (y - rect.height) + 'px';
});
}
function handleHandContextAction(action) {
const { playerId, handIndex } = handContextState;
if (playerId === null || handIndex === null || !gameState) return;
const player = gameState.players[playerId];
if (!player) return;
const hand = player.zones.hand;
if (handIndex < 0 || handIndex >= hand.length) return;
if (action === 'play') {
playCardFromHand(playerId, handIndex);
return;
}
const card = hand.splice(handIndex, 1)[0];
switch (action) {
case 'graveyard':
player.zones.graveyard.push(card);
addLogEntry(`â° ${player.name} discarded ${card.name} `);
break;
case 'exile':
player.zones.exile.push(card);
addLogEntry(`⎥ ${player.name} exiled ${card.name} from hand`);
break;
case 'library-top':
player.zones.library.unshift(card);
addLogEntry(`ð ${player.name} put ${card.name} on top of library`);
break;
case 'library-bottom':
player.zones.library.push(card);
addLogEntry(`ð ${player.name} put ${card.name} on bottom of library`);
break;
}
renderHandArea();
updatePlayerZones();
}
// ============================================================
// GAME LOG
// ============================================================
function _getLogCategory(text) {
var t = text || '';
if (/\u2694|combat|attack|block|damage|dmg/i.test(t)) return 'combat';
if (/cast|spell|play|played/i.test(t)) return 'spell';
if (/died|dies|sacrificed|destroyed|death/i.test(t)) return 'death';
if (/life|heal|gain|lose/i.test(t)) return 'life';
if (/poison|toxic/i.test(t)) return 'poison';
if (/\u2699|rule|undo|redo|engine/i.test(t)) return 'rules';
return 'general';
}
var LOG_CATEGORY_ICONS = {
combat: { icon: '\u2694', color: '#e74c3c' },
spell: { icon: '\uD83C\uDCCF', color: '#3498db' },
death: { icon: '\u2620', color: '#8e44ad' },
life: { icon: '\u2665', color: '#e91e8c' },
poison: { icon: '\u2620', color: '#27ae60' },
rules: { icon: '\u2699', color: '#95a5a6' },
general:{ icon: '\u2022', color: 'var(--color-text-faint)' },
};
var _lastLogTurn = -1;
function addLogEntry(text, category) {
if (!gameState) return;
var now = new Date();
var time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
var turnNum = gameState.turnNumber || 1;
var cat = category || _getLogCategory(text);
var catInfo = LOG_CATEGORY_ICONS[cat] || LOG_CATEGORY_ICONS.general;
var timestampLabel = '[T' + turnNum + ' ' + time + ']';
var logEntry = { time: time, text: text, turn: turnNum, category: cat, timestamp: timestampLabel };
gameState.log.unshift(logEntry);
if (gameState.log.length > 300) gameState.log.pop();
var logContainer = document.getElementById('log-entries');
if (!logContainer) return;
// Add turn separator if new turn
if (turnNum !== _lastLogTurn) {
_lastLogTurn = turnNum;
var sep = document.createElement('div');
sep.className = 'log-turn-separator';
sep.innerHTML = 'Turn ' + turnNum + ' â ' + (gameState.players[gameState.currentPlayerIndex] ? gameState.players[gameState.currentPlayerIndex].name : '');
logContainer.insertBefore(sep, logContainer.firstChild);
// Keep only 80 visible entries (separators + entries)
while (logContainer.children.length > 90) logContainer.removeChild(logContainer.lastChild);
}
var entry = document.createElement('div');
entry.className = 'log-entry log-cat-' + cat;
entry.setAttribute('data-category', cat);
entry.innerHTML =
'' + catInfo.icon + ' ' +
'' + timestampLabel + ' ' +
'' + text + ' ';
logContainer.insertBefore(entry, logContainer.firstChild);
while (logContainer.children.length > 90) logContainer.removeChild(logContainer.lastChild);
// Also write to floating game log
if (typeof addFloatingLogEntry === 'function') addFloatingLogEntry(text, cat);
}
// Feature 13: Full log modal
function openFullLogModal() {
if (!gameState) return;
var modal = document.getElementById('full-log-modal');
if (!modal) return;
modal.classList.add('visible');
renderFullLog();
}
function closeFullLogModal() {
var modal = document.getElementById('full-log-modal');
if (modal) modal.classList.remove('visible');
}
var _logFilterCategory = 'all';
function setLogFilter(cat) {
_logFilterCategory = cat;
renderFullLog();
document.querySelectorAll('.log-filter-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.cat === cat);
});
}
function renderFullLog() {
if (!gameState) return;
var body = document.getElementById('full-log-body');
if (!body) return;
var logs = gameState.log || [];
var filtered = _logFilterCategory === 'all' ? logs : logs.filter(function(e) { return e.category === _logFilterCategory; });
var html = '';
filtered.forEach(function(e) {
var cat = e.category || 'general';
var catInfo = LOG_CATEGORY_ICONS[cat] || LOG_CATEGORY_ICONS.general;
html += '' +
'' + catInfo.icon + ' ' +
'' + (e.timestamp || ('[T' + (e.turn||1) + ' ' + e.time + ']')) + ' ' +
'' + e.text + '
';
});
body.innerHTML = html || 'No log entries.
';
}
function exportGameLog() {
if (!gameState) return;
var logs = gameState.log || [];
var lines = logs.map(function(e) {
return (e.timestamp || e.time) + ' ' + (e.text || '').replace(/<[^>]+>/g,'');
});
var blob = new Blob([lines.join('\n')], { type: 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'commander-log.txt'; a.click();
URL.revokeObjectURL(url);
}
// ============================================================
// DICE & COIN
// ============================================================
function openDiceModal() {
const modal = document.getElementById('dice-modal');
if (modal) modal.classList.add('visible');
}
function closeDiceModal() {
const modal = document.getElementById('dice-modal');
if (modal) modal.classList.remove('visible');
}
function rollDice(sides) {
const result = Math.floor(Math.random() * sides) + 1;
const resultEl = document.getElementById('dice-result');
const labelEl = document.getElementById('dice-label');
if (resultEl) {
resultEl.classList.remove('rolling');
resultEl.offsetWidth;
resultEl.textContent = result;
resultEl.classList.add('rolling');
resultEl.classList.remove('coin-result');
}
if (labelEl) labelEl.textContent = `d${sides}`;
addLogEntry(`ðē Rolled d${sides}: ${result} `);
}
function flipCoin() {
const result = Math.random() < 0.5 ? 'HEADS' : 'TAILS';
const resultEl = document.getElementById('dice-result');
const labelEl = document.getElementById('dice-label');
if (resultEl) {
resultEl.classList.remove('rolling');
resultEl.offsetWidth;
resultEl.textContent = result === 'HEADS' ? 'ðŠ' : 'â―';
resultEl.classList.add('rolling', 'coin-result');
}
if (labelEl) labelEl.textContent = result;
addLogEntry(`ðŠ Coin flip: ${result} `);
}
// ============================================================
// DECK IMPORT / EXPORT
// ============================================================
let deckModalPlayerId = null;
let deckImportAbortController = null;
/**
* Parse a decklist text string into an array of { qty, name, section }.
* Supports common formats from Moxfield, Archidekt, EDHREC, TappedOut, etc.
*/
function parseDeckList(text) {
const lines = text.split(/\r?\n/);
const cards = [];
let currentSection = 'mainboard';
for (let raw of lines) {
let line = raw.trim();
if (!line) continue;
// Section headers
if (/^\/\/\s*/i.test(line) || /^(COMMANDER|COMPANION|SIDEBOARD|MAYBEBOARD|MAINBOARD|DECK|LAND)[:\s]*$/i.test(line)) {
const cleaned = line.replace(/^\/\/\s*/, '').replace(/:$/, '').trim().toLowerCase();
if (/commander/i.test(cleaned)) currentSection = 'commander';
else if (/companion/i.test(cleaned)) currentSection = 'companion';
else if (/sideboard/i.test(cleaned)) currentSection = 'sideboard';
else currentSection = 'mainboard';
continue;
}
// Skip comment lines
if (/^#/.test(line) || /^\*/.test(line)) continue;
// Strip set codes in parentheses/brackets
line = line.replace(/\s*[\(\[][A-Z0-9]+[\)\]]\s*\d*\s*$/, '').trim();
// Strip collector number at end
line = line.replace(/\s+\d+\s*$/, '').trim();
// Strip foil/etched tags
line = line.replace(/\s*\*F\*\s*$/i, '').trim();
// Match: optional quantity + card name
const match = line.match(/^(?:(\d+)\s*[xX]?\s+)?(.+)$/);
if (!match) continue;
const qty = match[1] ? parseInt(match[1]) : 1;
const name = match[2].trim();
if (!name || name.length < 2) continue;
cards.push({ qty: Math.min(qty, 99), name, section: currentSection });
}
return cards;
}
/**
* Batch-fetch card data from Scryfall's /cards/collection endpoint.
* Returns a Map.
*/
async function batchFetchCards(cardNames, progressCallback) {
const uniqueNames = [...new Set(cardNames.map(n => n.toLowerCase()))];
const results = new Map();
const BATCH_SIZE = 75;
const RATE_LIMIT = 100;
for (let i = 0; i < uniqueNames.length; i += BATCH_SIZE) {
if (deckImportAbortController?.signal?.aborted) throw new Error('Import cancelled');
const batch = uniqueNames.slice(i, i + BATCH_SIZE);
const identifiers = batch.map(name => ({ name }));
if (progressCallback) {
progressCallback(Math.min(i + BATCH_SIZE, uniqueNames.length), uniqueNames.length);
}
try {
const resp = await fetch(`${SCRYFALL_BASE}/cards/collection`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifiers }),
signal: deckImportAbortController?.signal,
});
if (!resp.ok) {
console.warn('[Deck Import] Batch request failed:', resp.status);
continue;
}
const data = await resp.json();
if (data.data) {
for (const card of data.data) {
results.set(card.name.toLowerCase(), card);
}
}
} catch (err) {
if (err.name === 'AbortError' || err.message === 'Import cancelled') throw err;
console.warn('[Deck Import] Batch fetch error:', err);
}
if (i + BATCH_SIZE < uniqueNames.length) {
await new Promise(r => setTimeout(r, RATE_LIMIT));
}
}
return results;
}
function scryfallToZoneCard(card) {
let imageUrl = '';
if (card.image_uris && card.image_uris.normal) {
imageUrl = card.image_uris.normal;
} else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
imageUrl = card.card_faces[0].image_uris.normal;
}
// Build power/toughness string from Scryfall fields
var pt = '';
if (card.power !== undefined && card.toughness !== undefined) {
pt = card.power + '/' + card.toughness;
}
return {
name: card.name,
imageUrl,
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces ? card.card_faces.map(f => f.oracle_text || '').join('\n') : ''),
manaCost: card.mana_cost || '',
cmc: card.cmc || 0,
power: card.power || '',
toughness: card.toughness || '',
pt: pt,
loyalty: card.loyalty || '',
colorIdentity: card.color_identity || [],
keywords: card.keywords || [],
};
}
function openDeckModal(playerId) {
if (!gameState) return;
deckModalPlayerId = playerId;
const player = gameState.players[playerId];
if (!player) return;
const modal = document.getElementById('deck-modal');
if (!modal) return;
const titleEl = document.getElementById('deck-modal-title');
if (titleEl) titleEl.textContent = `${player.name}'s Deck Manager`;
const textarea = document.getElementById('deck-import-text');
if (textarea) textarea.value = '';
updateDeckImportProgress(0, 0, '');
const statusEl = document.getElementById('deck-import-status');
if (statusEl) statusEl.textContent = '';
updateDeckSummary(playerId);
modal.classList.add('visible');
}
function closeDeckModal() {
const modal = document.getElementById('deck-modal');
if (modal) modal.classList.remove('visible');
cancelDeckImport();
deckModalPlayerId = null;
}
function updateDeckSummary(playerId) {
const el = document.getElementById('deck-current-summary');
if (!el || !gameState) return;
const player = gameState.players[playerId];
if (!player) return;
const lib = player.zones.library.length;
const hand = player.zones.hand.length;
const cmd = player.zones.command.length;
const total = lib + hand + cmd;
el.innerHTML = `
${total} total cards
${lib} in library
${hand} in hand
${cmd} in command zone
`;
}
function updateDeckImportProgress(loaded, total, message) {
const bar = document.getElementById('deck-progress-bar');
const text = document.getElementById('deck-progress-text');
const container = document.getElementById('deck-progress');
if (container) container.style.display = (total > 0) ? '' : 'none';
if (bar && total > 0) bar.style.width = `${Math.round((loaded / total) * 100)}%`;
if (text) text.textContent = message || `${loaded} / ${total} cards`;
}
function cancelDeckImport() {
if (deckImportAbortController) {
deckImportAbortController.abort();
deckImportAbortController = null;
}
}
async function importDeck() {
if (!gameState || deckModalPlayerId === null) return;
const player = gameState.players[deckModalPlayerId];
if (!player) return;
const textarea = document.getElementById('deck-import-text');
const statusEl = document.getElementById('deck-import-status');
const text = textarea?.value || '';
if (!text.trim()) {
if (statusEl) statusEl.textContent = 'Paste a deck list above first.';
return;
}
const parsedCards = parseDeckList(text);
if (!parsedCards.length) {
if (statusEl) statusEl.textContent = 'Could not parse any cards from the list.';
return;
}
const allCardNames = [];
const commanderNames = [];
for (const entry of parsedCards) {
for (let q = 0; q < entry.qty; q++) {
allCardNames.push(entry.name);
}
if (entry.section === 'commander') {
commanderNames.push(entry.name);
}
}
if (statusEl) statusEl.textContent = `Parsed ${allCardNames.length} cards. Fetching from Scryfall...`;
deckImportAbortController = new AbortController();
const importBtn = document.getElementById('deck-import-btn');
const cancelBtn = document.getElementById('deck-cancel-btn');
if (importBtn) { importBtn.disabled = true; importBtn.textContent = 'Importing...'; }
if (cancelBtn) cancelBtn.style.display = '';
try {
const cardDataMap = await batchFetchCards(
allCardNames,
(loaded, total) => updateDeckImportProgress(loaded, total, `Fetching cards: ${loaded} / ${total}`)
);
const libraryCards = [];
const commandCards = [];
const notFound = [];
for (const entry of parsedCards) {
const scryfallCard = cardDataMap.get(entry.name.toLowerCase());
if (!scryfallCard) {
notFound.push(entry.name);
continue;
}
const cardObj = scryfallToZoneCard(scryfallCard);
for (let q = 0; q < entry.qty; q++) {
if (entry.section === 'commander') {
commandCards.push({ ...cardObj });
} else {
libraryCards.push({ ...cardObj });
}
}
}
const importMode = document.querySelector('input[name="deck-import-mode"]:checked')?.value || 'replace';
if (importMode === 'replace') {
player.zones.library = [];
player.zones.hand = [];
player.zones.command = [];
}
commandCards.forEach(card => player.zones.command.push(card));
// Set commander name for zone replacement
if (commandCards.length > 0 && !player.commanderCardName) {
player.commanderCardName = commandCards[0].name || '';
}
// Shuffle library
for (let i = libraryCards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[libraryCards[i], libraryCards[j]] = [libraryCards[j], libraryCards[i]];
}
libraryCards.forEach(card => player.zones.library.push(card));
// Draw opening hand (7 cards) if replacing
if (importMode === 'replace' && player.zones.library.length >= 7) {
for (let i = 0; i < 7; i++) {
const card = player.zones.library.shift();
player.zones.hand.push(card);
}
}
updatePlayerZones();
renderPlayerPanels();
updateDeckSummary(deckModalPlayerId);
let status = `\u2713 Imported ${libraryCards.length + commandCards.length} cards.`;
if (commandCards.length) status += ` ${commandCards.length} commander(s).`;
if (importMode === 'replace') status += ` Drew 7-card opening hand.`;
if (notFound.length) {
status += ` \u26a0 ${notFound.length} not found: ${notFound.slice(0, 5).join(', ')}${notFound.length > 5 ? '...' : ''}`;
}
if (statusEl) { statusEl.textContent = status; statusEl.className = 'deck-import-status success'; }
addLogEntry(`\ud83d\udccb ${player.name} imported a ${libraryCards.length + commandCards.length}-card deck`);
// Enter mulligan phase for player 1
if (importMode === 'replace' && deckModalPlayerId === 0) {
enterMulliganPhase(0);
}
updateDeckImportProgress(0, 0, '');
} catch (err) {
if (err.name === 'AbortError' || err.message === 'Import cancelled') {
if (statusEl) { statusEl.textContent = 'Import cancelled.'; statusEl.className = 'deck-import-status'; }
} else {
console.error('[Deck Import] Error:', err);
if (statusEl) { statusEl.textContent = 'Error importing deck. Check console.'; statusEl.className = 'deck-import-status error'; }
}
updateDeckImportProgress(0, 0, '');
} finally {
deckImportAbortController = null;
if (importBtn) { importBtn.disabled = false; importBtn.textContent = '\ud83d\udce5 Import Deck'; }
if (cancelBtn) cancelBtn.style.display = 'none';
}
}
function exportDeck() {
if (!gameState || deckModalPlayerId === null) return;
const player = gameState.players[deckModalPlayerId];
if (!player) return;
const lines = [];
const cardCounts = new Map();
const commanderCounts = new Map();
const deckCards = [...player.zones.library, ...player.zones.hand];
for (const card of deckCards) {
cardCounts.set(card.name, (cardCounts.get(card.name) || 0) + 1);
}
for (const card of player.zones.command) {
commanderCounts.set(card.name, (commanderCounts.get(card.name) || 0) + 1);
}
if (commanderCounts.size > 0) {
lines.push('//Commander');
for (const [name, qty] of commanderCounts) {
lines.push(`${qty} ${name}`);
}
lines.push('');
}
lines.push('//Deck');
const sorted = [...cardCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [name, qty] of sorted) {
lines.push(`${qty} ${name}`);
}
const output = lines.join('\n');
const textarea = document.getElementById('deck-import-text');
if (textarea) {
textarea.value = output;
textarea.select();
}
navigator.clipboard?.writeText(output).then(() => {
const statusEl = document.getElementById('deck-import-status');
if (statusEl) { statusEl.textContent = `Exported ${deckCards.length + player.zones.command.length} cards \u2014 copied to clipboard.`; statusEl.className = 'deck-import-status success'; }
}).catch(() => {
const statusEl = document.getElementById('deck-import-status');
if (statusEl) { statusEl.textContent = `Exported ${deckCards.length + player.zones.command.length} cards \u2014 select and copy from the text area.`; statusEl.className = 'deck-import-status'; }
});
}
function downloadDeck() {
if (!gameState || deckModalPlayerId === null) return;
const player = gameState.players[deckModalPlayerId];
if (!player) return;
exportDeck();
const textarea = document.getElementById('deck-import-text');
const text = textarea?.value || '';
if (!text.trim()) return;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${player.name.replace(/[^a-zA-Z0-9]/g, '_')}_deck.txt`;
a.click();
URL.revokeObjectURL(url);
}
function loadDeckFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.dec,.dek,.mwDeck,.csv';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const textarea = document.getElementById('deck-import-text');
if (textarea) textarea.value = ev.target.result;
const statusEl = document.getElementById('deck-import-status');
if (statusEl) { statusEl.textContent = `Loaded "${file.name}". Click Import to fetch card data.`; statusEl.className = 'deck-import-status'; }
};
reader.readAsText(file);
};
input.click();
}
// ============================================================
// PRECON DECK BROWSER
// ============================================================
let preconColorFilter = 'all';
let selectedPreconId = null;
function switchDeckTab(tabName) {
document.querySelectorAll('.deck-tab').forEach(t => t.classList.toggle('active', t.dataset.deckTab === tabName));
document.querySelectorAll('.deck-tab-content').forEach(c => c.classList.remove('active'));
const target = document.getElementById(`deck-tab-${tabName}`);
if (target) target.classList.add('active');
if (tabName === 'precons') {
initPreconBrowser();
}
}
function initPreconBrowser() {
if (typeof PRECON_DATA === 'undefined') return;
// Populate set filter
const setSelect = document.getElementById('precon-filter-set');
if (setSelect && setSelect.options.length <= 1) {
const sets = [...new Set(PRECON_DATA.map(p => p.set))];
// Sort by year descending
sets.sort((a, b) => {
const ya = PRECON_DATA.find(p => p.set === a)?.year || 0;
const yb = PRECON_DATA.find(p => p.set === b)?.year || 0;
return yb - ya;
});
sets.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
const yr = PRECON_DATA.find(p => p.set === s)?.year || '';
opt.textContent = `${s} (${yr})`;
setSelect.appendChild(opt);
});
}
filterPrecons();
}
function setPreconColorFilter(color) {
preconColorFilter = color;
document.querySelectorAll('.precon-color-btn').forEach(b => b.classList.toggle('active', b.dataset.color === color));
filterPrecons();
}
function filterPrecons() {
if (typeof PRECON_DATA === 'undefined') return;
const setFilter = document.getElementById('precon-filter-set')?.value || 'all';
const grid = document.getElementById('precon-grid');
const detail = document.getElementById('precon-detail');
if (!grid) return;
// Hide detail, show grid
if (detail) detail.style.display = 'none';
grid.style.display = '';
selectedPreconId = null;
let filtered = PRECON_DATA;
if (setFilter !== 'all') {
filtered = filtered.filter(p => p.set === setFilter);
}
if (preconColorFilter !== 'all') {
filtered = filtered.filter(p => p.colors.includes(preconColorFilter));
}
if (!filtered.length) {
grid.innerHTML = 'No precons match these filters.
';
return;
}
const COLOR_ICONS = { W: '\u2600', U: '\ud83d\udca7', B: '\ud83d\udc80', R: '\ud83d\udd25', G: '\ud83c\udf32', C: '\u25c7' };
grid.innerHTML = filtered.map(p => {
const colorPips = p.colors.split('').map(c => {
const icon = COLOR_ICONS[c] || c;
return `${icon} `;
}).join('');
return `
${p.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 => `
${manaIcons[color]}
${MANA_LABELS[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() {
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() {
const widget = document.getElementById('mana-pool-widget');
if (widget) {
widget.classList.add('visible');
populateManaPlayerSelect();
renderManaPool();
}
}
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() {
const handle = document.getElementById('mana-pool-drag-handle');
const widget = document.getElementById('mana-pool-widget');
if (!handle || !widget) return;
let isDragging = false;
let startX, startY, startLeft, startTop;
handle.addEventListener('mousedown', e => {
// Don't drag when clicking buttons or select
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'SELECT' || e.target.tagName === 'OPTION') return;
isDragging = true;
const rect = widget.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
widget.style.transition = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
widget.style.left = (startLeft + dx) + 'px';
widget.style.top = (startTop + dy) + 'px';
widget.style.right = 'auto';
widget.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
widget.style.transition = '';
}
});
}
// ============================================================
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 = '-- Select a precon -- ';
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 += '' + p.name.replace(/"/g, '"') + ' ';
});
preconOptionsHtml += ' ';
});
}
if (isAi) {
panel.innerHTML =
'' +
'AI Deck (auto-generated)
';
} else {
var idx = i;
// Build saved decks options
var savedDecks = (typeof getDecks === 'function') ? getDecks() : [];
var savedDecksHtml = '';
if (savedDecks.length === 0) {
savedDecksHtml = 'No saved decks. Build one in Deck Builder first.
';
} else {
savedDecksHtml = '';
for (var di = 0; di < savedDecks.length; di++) {
var sd = savedDecks[di];
var cardCount = 0;
if (sd.cards) sd.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
if (sd.commander) cardCount++;
var deckColors = '';
if (sd.commander && sd.commander.colorIdentity && sd.commander.colorIdentity.length) {
var manaSymbols = { W: '\u2600', U: '\uD83D\uDCA7', B: '\uD83D\uDC80', R: '\uD83D\uDD25', G: '\uD83C\uDF33' };
sd.commander.colorIdentity.forEach(function(c) { deckColors += (manaSymbols[c] || c) + ' '; });
}
savedDecksHtml += '' +
'' + (sd.name || 'Untitled').replace(/' +
'' + deckColors + cardCount + ' cards ' +
' ';
}
savedDecksHtml += '
';
}
panel.innerHTML =
'' +
'' +
'Precon ' +
'My Decks ' +
'Import ' +
'
' +
'' +
'' +
preconOptionsHtml +
' ' +
'
' +
'' +
savedDecksHtml +
'
' +
'' +
'' +
'\uD83D\uDCE5 Validate & Use This List ' +
'
';
}
grid.appendChild(panel);
}
}
function switchSetupDeckTab(playerIdx, tab) {
var tabs = ['precon', 'mydecks', 'import'];
tabs.forEach(function(t) {
var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx);
var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx);
if (tabBtn) tabBtn.classList.remove('active');
if (content) content.classList.remove('active');
});
var activeTab = document.getElementById('setup-deck-tab-' + tab + '-' + playerIdx);
var activeContent = document.getElementById('setup-deck-content-' + tab + '-' + playerIdx);
if (activeTab) activeTab.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
function selectSetupPrecon(playerIdx) {
var sel = document.getElementById('setup-deck-precon-select-' + playerIdx);
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
if (!sel) return;
var preconId = sel.value;
if (!preconId) {
delete setupDeckChoices[playerIdx];
if (statusEl) { statusEl.textContent = 'No deck selected'; statusEl.className = 'deck-select-status'; }
return;
}
var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA.find(function(p) { return p.id === preconId; }) : null;
var preconName = precon ? precon.name : preconId;
setupDeckChoices[playerIdx] = { type: 'precon', preconId: preconId };
if (statusEl) { statusEl.textContent = 'Precon: ' + preconName; statusEl.className = 'deck-select-status loaded'; }
}
function importSetupDeck(playerIdx) {
var textarea = document.getElementById('setup-deck-import-text-' + playerIdx);
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
if (!textarea) return;
var text = textarea.value.trim();
if (!text) {
if (statusEl) { statusEl.textContent = 'Paste a deck list first.'; statusEl.className = 'deck-select-status'; }
return;
}
var parsed = parseDeckList(text);
if (!parsed.length) {
if (statusEl) { statusEl.textContent = 'Could not parse cards from list.'; statusEl.className = 'deck-select-status'; }
return;
}
var total = parsed.reduce(function(sum, e) { return sum + e.qty; }, 0);
setupDeckChoices[playerIdx] = { type: 'import', text: text };
if (statusEl) { statusEl.textContent = 'Deck ready (' + total + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
}
function selectSetupCustomDeck(playerIdx, deckId) {
var decks = (typeof getDecks === 'function') ? getDecks() : [];
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
// Highlight selected button, unhighlight others
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
if (listEl) {
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
}
var btns = document.querySelectorAll('[id^="setup-custom-deck-btn-' + playerIdx + '-"]');
btns.forEach(function(b) { b.classList.remove('selected'); });
// Find the right button by matching deckId in onclick
btns.forEach(function(b) {
if (b.getAttribute('onclick') && b.getAttribute('onclick').indexOf(deckId) !== -1) {
b.classList.add('selected');
}
});
setupDeckChoices[playerIdx] = { type: 'custom', deckId: deckId };
var cardCount = 0;
if (deck.cards) deck.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
if (deck.commander) cardCount++;
if (statusEl) { statusEl.textContent = deck.name + ' (' + cardCount + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
}
async function loadCustomDeckForPlayer(playerIdx, deckId) {
var decks = (typeof getDecks === 'function') ? getDecks() : [];
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
var player = gameState.players[playerIdx];
if (!player) return;
// Convert deck builder format to a text decklist, then use importDeckForPlayer
var lines = [];
if (deck.commander) {
lines.push('// Commander');
lines.push('1 ' + deck.commander.name);
lines.push('');
lines.push('// Deck');
}
if (deck.cards && deck.cards.length) {
deck.cards.forEach(function(c) {
lines.push((c.quantity || 1) + ' ' + c.name);
});
}
var deckText = lines.join('\n');
await importDeckForPlayer(playerIdx, deckText);
addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded custom deck "' + deck.name + '"');
}
async function loadPreconForPlayer(playerIdx, preconId) {
if (typeof PRECON_DATA === 'undefined') return;
var precon = PRECON_DATA.find(function(p) { return p.id === preconId; });
if (!precon) return;
var player = gameState.players[playerIdx];
if (!player) return;
var parsedCards = parseDeckList(precon.decklist);
if (!parsedCards.length) return;
var allCardNames = [];
for (var ei = 0; ei < parsedCards.length; ei++) {
for (var q = 0; q < parsedCards[ei].qty; q++) {
allCardNames.push(parsedCards[ei].name);
}
}
var cardDataMap = await batchFetchCards(allCardNames, null);
var libraryCards = [];
var commandCards = [];
for (var ci = 0; ci < parsedCards.length; ci++) {
var entry = parsedCards[ci];
var scryfallCard = cardDataMap.get(entry.name.toLowerCase());
if (!scryfallCard) continue;
var cardObj = scryfallToZoneCard(scryfallCard);
for (var cq = 0; cq < entry.qty; cq++) {
if (entry.section === 'commander') {
commandCards.push(Object.assign({}, cardObj));
} else {
libraryCards.push(Object.assign({}, cardObj));
}
}
}
player.zones.library = [];
player.zones.hand = [];
player.zones.command = [];
commandCards.forEach(function(card) { player.zones.command.push(card); });
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp;
}
libraryCards.forEach(function(card) { player.zones.library.push(card); });
// Draw opening hand of 7
if (player.zones.library.length >= 7) {
for (var di = 0; di < 7; di++) {
player.zones.hand.push(player.zones.library.shift());
}
}
addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)');
}
async function importDeckForPlayer(playerIdx, deckText) {
var player = gameState.players[playerIdx];
if (!player || !deckText.trim()) return;
var parsedCards = parseDeckList(deckText);
if (!parsedCards.length) return;
var allCardNames = [];
for (var ei = 0; ei < parsedCards.length; ei++) {
for (var q = 0; q < parsedCards[ei].qty; q++) {
allCardNames.push(parsedCards[ei].name);
}
}
var cardDataMap = await batchFetchCards(allCardNames, null);
var libraryCards = [];
var commandCards = [];
for (var ci = 0; ci < parsedCards.length; ci++) {
var entry = parsedCards[ci];
var scryfallCard = cardDataMap.get(entry.name.toLowerCase());
if (!scryfallCard) continue;
var cardObj = scryfallToZoneCard(scryfallCard);
for (var cq = 0; cq < entry.qty; cq++) {
if (entry.section === 'commander') {
commandCards.push(Object.assign({}, cardObj));
} else {
libraryCards.push(Object.assign({}, cardObj));
}
}
}
player.zones.library = [];
player.zones.hand = [];
player.zones.command = [];
commandCards.forEach(function(card) { player.zones.command.push(card); });
if (commandCards.length > 0 && !player.commanderCardName) {
player.commanderCardName = commandCards[0].name || '';
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp;
}
libraryCards.forEach(function(card) { player.zones.library.push(card); });
// Draw opening hand of 7
if (player.zones.library.length >= 7) {
for (var di = 0; di < 7; di++) {
player.zones.hand.push(player.zones.library.shift());
}
}
addLogEntry('\uD83D\uDCCB ' + player.name + ' imported a deck (' + (libraryCards.length + commandCards.length) + ' cards)');
}
async function startGameWithDecks() {
// 1. Call existing startGame() â creates gameState, sets up AI, shows board
startGame();
// 2. Collect human players with deck choices
var humanDecks = [];
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
if (setupDeckChoices[i]) humanDecks.push({ idx: i, choice: setupDeckChoices[i] });
}
if (humanDecks.length === 0) {
// No decks to load â if player 0 has cards already, enter mulligan
if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) {
enterMulliganPhase(0);
}
setupDeckChoices = {};
return;
}
// 3. Show loading overlay
var overlay = document.createElement('div');
overlay.id = 'deck-loading-overlay';
overlay.className = 'deck-loading-overlay';
overlay.innerHTML =
'Loading Decks...
' +
'Fetching cards from Scryfall
';
document.body.appendChild(overlay);
try {
// 4. Process each human deck choice sequentially
for (var d = 0; d < humanDecks.length; d++) {
var hd = humanDecks[d];
var sub = document.getElementById('deck-loading-subtext');
var pName = gameState.players[hd.idx] ? gameState.players[hd.idx].name : ('Player ' + (hd.idx + 1));
if (sub) sub.textContent = 'Loading deck for ' + pName + '...';
if (hd.choice.type === 'precon') {
await loadPreconForPlayer(hd.idx, hd.choice.preconId);
} else if (hd.choice.type === 'custom') {
await loadCustomDeckForPlayer(hd.idx, hd.choice.deckId);
} else if (hd.choice.type === 'import') {
await importDeckForPlayer(hd.idx, hd.choice.text);
}
}
} catch (err) {
console.error('[startGameWithDecks] Error loading decks:', err);
}
// 5. Remove overlay
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
// 6. Clear choices, re-render, enter mulligan
setupDeckChoices = {};
if (typeof updatePlayerZones === 'function') updatePlayerZones();
renderPlayerPanels();
renderHandArea();
if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) {
enterMulliganPhase(0);
}
}
function startGame() {
const names = [];
for (let i = 0; i < setupPlayerCount; i++) {
const input = document.getElementById(`player-name-${i}`);
names.push(input?.value.trim() || (setupAiPlayers.has(i) ? `AI Bot ${i + 1}` : `Player ${i + 1}`));
}
gameState = createInitialState(setupPlayerCount, names, setupStartingLife);
// Clear undo/redo stacks for new game
undoStack = [];
redoStack = [];
updateUndoRedoButtons();
// Clear token history for new game
tokenHistory = [];
// Clear AI thought history for new game
aiThoughts = [];
// Hide token summary bar on new game
var tsb = document.getElementById('token-summary-bar');
if (tsb) tsb.style.display = 'none';
// Initialize turn history with first turn
const firstPlayer = gameState.players[0];
gameState.turnHistory = [{
turnNumber: 1,
playerIndex: 0,
playerName: firstPlayer?.name || 'Player 1',
playerColor: firstPlayer?.color || PLAYER_COLORS[0] || '#666',
}];
// Mark AI players
setupAiPlayers.forEach(idx => {
if (gameState.players[idx]) {
gameState.players[idx].isAI = true;
}
});
gameState.aiDifficulty = setupAiDifficulty;
// 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();
// Set commanderCardName for each player from their command zone
gameState.players.forEach(function(p) {
if (p.zones.command && p.zones.command.length > 0) {
p.commanderCardName = p.zones.command[0].name || '';
}
});
addLogEntry(`ðŪ Game started with ${setupPlayerCount} players, ${setupStartingLife} starting life`);
renderPlayerPanels();
renderHandArea();
renderPhaseTracker();
updateTurnDisplay();
// Set default search focus
setTimeout(() => {
document.getElementById('card-search-input')?.focus();
}, 600);
// If first player is AI, start their turn
if (gameState.players[0]?.isAI) {
setTimeout(() => runAiTurn(), 1200);
}
}
// ============================================================
// GAME BOARD INIT
// ============================================================
function initGameBoard() {
setupBattlefieldDrop();
setupContextMenu();
setupKeyboardShortcuts();
setupHandCardPreview();
setupManaPoolDrag();
updateStormCounter();
initCombatOverlay();
initZonePanel();
buildOwnerFilterButtons();
buildBattlefieldSections();
// Next phase / next turn buttons use inline onclick handlers (Comet compatibility)
// Do NOT add addEventListener here â would double-fire with onclick
// Card search
const searchInput = document.getElementById('card-search-input');
if (searchInput) {
searchInput.addEventListener('input', e => {
searchScryfall(e.target.value);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
clearTimeout(searchDebounceTimer);
searchScryfall(e.target.value);
}
});
setSearchStatus('Type to search cards...');
}
// Sidebar toggles
document.getElementById('toggle-search-sidebar')?.addEventListener('click', () => {
document.getElementById('card-search-sidebar')?.classList.toggle('collapsed');
});
// Dice modal
document.getElementById('btn-dice')?.addEventListener('click', openDiceModal);
document.getElementById('close-dice-modal')?.addEventListener('click', closeDiceModal);
document.getElementById('dice-modal')?.addEventListener('click', e => {
if (e.target === document.getElementById('dice-modal')) closeDiceModal();
});
// Dice buttons
document.getElementById('btn-roll-d6')?.addEventListener('click', () => rollDice(6));
document.getElementById('btn-roll-d20')?.addEventListener('click', () => rollDice(20));
document.getElementById('btn-roll-d12')?.addEventListener('click', () => rollDice(12));
document.getElementById('btn-roll-d10')?.addEventListener('click', () => rollDice(10));
document.getElementById('btn-roll-d8')?.addEventListener('click', () => rollDice(8));
document.getElementById('btn-roll-d4')?.addEventListener('click', () => rollDice(4));
document.getElementById('btn-flip-coin')?.addEventListener('click', flipCoin);
// Zone modal
document.getElementById('close-zone-modal')?.addEventListener('click', closeZoneModal);
document.getElementById('zone-modal')?.addEventListener('click', e => {
if (e.target === document.getElementById('zone-modal')) closeZoneModal();
});
// Scry modal
document.getElementById('close-scry-modal')?.addEventListener('click', closeScryModal);
document.getElementById('scry-modal')?.addEventListener('click', e => {
if (e.target === document.getElementById('scry-modal')) closeScryModal();
});
document.getElementById('btn-scry-confirm')?.addEventListener('click', confirmScry);
document.getElementById('btn-scry-all-top')?.addEventListener('click', scryAllTop);
document.getElementById('btn-scry-all-bottom')?.addEventListener('click', scryAllBottom);
// Zone tabs
document.querySelectorAll('.zone-tab').forEach(tab => {
tab.addEventListener('click', () => {
activeZoneTab = tab.dataset.zone;
renderZoneModal();
});
});
// Trigger stack
document.getElementById('btn-trigger-stack')?.addEventListener('click', toggleTriggerStack);
document.getElementById('close-trigger-stack')?.addEventListener('click', closeTriggerStack);
document.getElementById('btn-resolve-next')?.addEventListener('click', resolveNextTrigger);
document.getElementById('btn-resolve-all')?.addEventListener('click', resolveAllTriggers);
document.getElementById('btn-add-response')?.addEventListener('click', () => {
// Open the card search sidebar for instant-speed responses
const sidebar = document.getElementById('card-search-sidebar');
if (sidebar) sidebar.classList.remove('collapsed');
document.getElementById('card-search-input')?.focus();
addLogEntry('⥠Adding response â search for a card to respond with');
});
// Reset game
document.getElementById('btn-reset')?.addEventListener('click', confirmReset);
// Storm counter decrement
document.getElementById('storm-counter')?.addEventListener('click', () => {
gameState.stormCount = Math.max(0, gameState.stormCount - 1);
updateStormCounter();
});
// Storm counter increment via FAB
document.getElementById('btn-storm')?.addEventListener('click', () => {
gameState.stormCount++;
updateStormCounter();
addLogEntry(`â Storm count: ${gameState.stormCount} `);
});
// Untap all
document.getElementById('btn-untap-all')?.addEventListener('click', untapAllCards);
// Theme toggle
const themeToggle = document.querySelector('[data-theme-toggle]');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
}
// ============================================================
// UNTAP ALL
// ============================================================
function untapAllCards() {
if (!gameState) return;
pushUndoState();
let count = 0;
gameState.battlefieldCards.forEach(card => {
if (card.tapped) {
card.tapped = false;
count++;
const cardEl = document.getElementById(`bc-${card.id}`);
if (cardEl) cardEl.classList.remove('tapped');
}
});
if (count > 0) addLogEntry(`âĻ Untapped ${count} card${count > 1 ? 's' : ''}`);
}
// ============================================================
// RESET GAME
// ============================================================
function confirmReset() {
const dialog = document.getElementById('confirm-dialog');
if (dialog) dialog.classList.add('visible');
}
function executeReset() {
const dialog = document.getElementById('confirm-dialog');
if (dialog) dialog.classList.remove('visible');
// Go back to setup
document.getElementById('game-board').classList.remove('visible');
document.getElementById('setup-screen').classList.remove('hidden');
document.getElementById('setup-screen').style.display = '';
// Hide game log sidebar
var logSidebar = document.getElementById('game-log-sidebar');
if (logSidebar) logSidebar.classList.add('collapsed');
// Clear game state
gameState = null;
nextTriggerId = 1;
// Close trigger stack if open
closeTriggerStack();
// Hide mana pool
const manaWidget = document.getElementById('mana-pool-widget');
if (manaWidget) manaWidget.classList.remove('visible');
// Clear battlefield
const battlefield = document.getElementById('battlefield');
if (battlefield) {
battlefield.querySelectorAll('.battlefield-card').forEach(c => c.remove());
const label = battlefield.querySelector('.battlefield-label');
if (label) label.style.opacity = '';
}
// Hide combat overlay
hideCombatOverlay();
// Hide player 1 fixed panel
const p1Fixed = document.getElementById('player1-fixed-panel');
if (p1Fixed) { p1Fixed.innerHTML = ''; p1Fixed.classList.add('hidden'); }
// Clear log
const logContainer = document.getElementById('log-entries');
if (logContainer) logContainer.innerHTML = '';
}
// ============================================================
// KEYBOARD SHORTCUTS
// ============================================================
let shortcutHintTimer = null;
function showShortcutHint() {
const hint = document.getElementById('shortcut-hint');
if (!hint) return;
hint.classList.add('show');
clearTimeout(shortcutHintTimer);
shortcutHintTimer = setTimeout(() => hint.classList.remove('show'), 2500);
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', e => {
// Undo/Redo â Ctrl+Z / Ctrl+Shift+Z (works everywhere including inputs)
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
redo();
return;
}
// Don't fire when typing in inputs
if (e.target.matches('input, textarea, select')) return;
switch(e.key) {
case ' ':
e.preventDefault();
// If priority window is open, Space passes priority
if (isPriorityBlocking()) {
passPriority();
} else {
nextPhase();
}
break;
case 'n':
case 'N':
nextTurn();
break;
case 'u':
case 'U':
untapAllCards();
break;
case 'd':
case 'D':
openDiceModal();
break;
case 't':
case 'T':
toggleTriggerStack();
break;
case 'm':
case 'M':
toggleManaPoolCollapse();
break;
case 'Escape':
closeZoneModal();
closeDiceModal();
closeTriggerStack();
hideContextMenu();
closeScryModal();
closeManaPickerModal();
closeDeckModal();
closeDeckView();
closeTokenModal();
hideAbilitiesPanel();
closeSaveLoadModal();
closeShortcutsModal();
closeUndoHistory();
// Close keyword reference
{
const kref = document.getElementById('keyword-reference-panel');
if (kref) kref.style.display = 'none';
}
break;
case 'k':
case 'K':
toggleKeywordReference();
break;
case 's':
case 'S':
openSaveLoadModal();
break;
case '?':
openShortcutsModal();
break;
case 'h':
case 'H':
toggleUndoHistory();
break;
}
});
}
// ============================================================
// THEME TOGGLE
// ============================================================
let currentTheme = 'dark';
function toggleTheme() {
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', currentTheme);
const toggle = document.querySelector('[data-theme-toggle]');
if (toggle) {
toggle.title = currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
toggle.innerHTML = currentTheme === 'dark'
? ` `
: ` `;
}
}
// ============================================================
// SHORTCUT HINT
// ============================================================
function initShortcutHint() {
const hint = document.getElementById('shortcut-hint');
if (hint) {
hint.innerHTML = `Space Next Phase N Next Turn U Untap All D Dice T Triggers S Save/Load ? Shortcuts H Undo History`;
}
}
// ============================================================
// LIFE ADJUST VIA KEYBOARD (click + hold)
// ============================================================
let lifeHoldTimer = null;
let lifeHoldInterval = null;
function setupLifeHold() {
document.addEventListener('pointerdown', e => {
const btn = e.target.closest('.life-btn');
if (!btn) return;
const panel = btn.closest('.player-panel');
if (!panel) return;
const panelId = panel.id;
const playerId = parseInt(panelId.replace('player-panel-', ''));
const isPlus = btn.textContent.trim() === '+';
lifeHoldTimer = setTimeout(() => {
lifeHoldInterval = setInterval(() => {
adjustLife(playerId, isPlus ? 1 : -1);
}, 80);
}, 400);
});
document.addEventListener('pointerup', () => {
clearTimeout(lifeHoldTimer);
clearInterval(lifeHoldInterval);
});
}
// ============================================================
// AI OPPONENT ENGINE
// ============================================================
// AI deck templates â pre-built card pools by color identity
// Each color has: lands, creatures (12-16), spells (6-8), equipment (2-3), enchantments (2-3)
const AI_DECK_TEMPLATES = {
mono_white: {
lands: [
{ name: 'Plains', typeLine: 'Basic Land \u2014 Plains', oracleText: '{T}: Add {W}.', mana: 'W' }
],
creatures: [
{ name: 'Savannah Lions', typeLine: 'Creature \u2014 Cat', oracleText: '', pt: '2/1', cmc: 1, manaCost: '{W}' },
{ name: 'Mother of Runes', typeLine: 'Creature \u2014 Human Cleric', oracleText: '{T}: Target creature you control gains protection from the color of your choice until end of turn.', pt: '1/1', cmc: 1, manaCost: '{W}' },
{ name: 'Thalia, Guardian of Thraben', typeLine: 'Legendary Creature \u2014 Human Soldier', oracleText: 'First strike. Noncreature spells cost {1} more to cast.', pt: '2/1', cmc: 2, manaCost: '{1}{W}' },
{ name: 'Leonin Skyhunter', typeLine: 'Creature \u2014 Cat Knight', oracleText: 'Flying', pt: '2/2', cmc: 2, manaCost: '{W}{W}' },
{ name: 'Blade Splicer', typeLine: 'Creature \u2014 Phyrexian Human Artificer', oracleText: 'When Blade Splicer enters, create a 3/3 colorless Phyrexian Golem artifact creature token.', pt: '1/1', cmc: 3, manaCost: '{2}{W}' },
{ name: 'Monastery Mentor', typeLine: 'Creature \u2014 Human Monk', oracleText: 'Prowess. Whenever you cast a noncreature spell, create a 1/1 white Monk creature token with prowess.', pt: '2/2', cmc: 3, manaCost: '{2}{W}' },
{ name: 'Restoration Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flash, flying. When Restoration Angel enters, you may exile target non-Angel creature you control, then return that card to the battlefield under your control.', pt: '3/4', cmc: 4, manaCost: '{3}{W}' },
{ name: 'Hero of Bladehold', typeLine: 'Creature \u2014 Human Knight', oracleText: 'Battle cry. Whenever Hero of Bladehold attacks, create two 1/1 white Soldier creature tokens that are tapped and attacking.', pt: '3/4', cmc: 4, manaCost: '{2}{W}{W}' },
{ name: 'Serra Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying, vigilance', pt: '4/4', cmc: 5, manaCost: '{3}{W}{W}' },
{ name: 'Baneslayer Angel', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying, first strike, lifelink', pt: '5/5', cmc: 5, manaCost: '{3}{W}{W}' },
{ name: 'Angel of Sanctions', typeLine: 'Creature \u2014 Angel', oracleText: 'Flying. When Angel of Sanctions enters, you may exile target nonland permanent an opponent controls.', pt: '3/4', cmc: 5, manaCost: '{3}{W}{W}', isRemoval: true },
{ name: 'Sun Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Vigilance. Whenever Sun Titan enters or attacks, return target permanent card with mana value 3 or less from your graveyard to the battlefield.', pt: '6/6', cmc: 6, manaCost: '{4}{W}{W}' },
{ name: 'Elesh Norn, Grand Cenobite', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Vigilance. Other creatures you control get +2/+2. Creatures your opponents control get -2/-2.', pt: '4/7', cmc: 7, manaCost: '{5}{W}{W}' },
],
spells: [
{ name: 'Swords to Plowshares', typeLine: 'Instant', oracleText: 'Exile target creature. Its controller gains life equal to its power.', cmc: 1, manaCost: '{W}', isRemoval: true },
{ name: 'Path to Exile', typeLine: 'Instant', oracleText: 'Exile target creature.', cmc: 1, manaCost: '{W}', isRemoval: true },
{ name: 'Generous Gift', typeLine: 'Instant', oracleText: 'Destroy target permanent. Its controller creates a 3/3 green Elephant creature token.', cmc: 3, manaCost: '{1}{W}{W}', isRemoval: true },
{ name: 'Wrath of God', typeLine: 'Sorcery', oracleText: 'Destroy all creatures. They can\'t be regenerated.', cmc: 4, manaCost: '{2}{W}{W}', isBoardWipe: true },
{ name: 'Austere Command', typeLine: 'Sorcery', oracleText: 'Choose two: Destroy all artifacts; destroy all enchantments; destroy all creatures with mana value 3 or less; destroy all creatures with mana value 4 or greater.', cmc: 6, manaCost: '{4}{W}{W}', isBoardWipe: true },
{ name: 'Return to Dust', typeLine: 'Instant', oracleText: 'Exile target artifact or enchantment. If you cast this spell during your main phase, you may exile up to one other target artifact or enchantment.', cmc: 4, manaCost: '{2}{W}{W}', isRemoval: true },
],
equipment: [
{ name: 'Sword of Fire and Ice', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +2/+2 and has protection from red and from blue. Whenever equipped creature deals combat damage to a player, draw a card and deal 2 damage to any target. Equip {2}', cmc: 3, manaCost: '{3}' },
{ name: 'Lightning Greaves', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has haste and shroud. Equip {0}', cmc: 2, manaCost: '{2}' },
],
enchantments: [
{ name: 'Smothering Tithe', typeLine: 'Enchantment', oracleText: 'Whenever an opponent draws a card, that player may pay {2}. If the player doesn\'t, you create a Treasure token.', cmc: 4, manaCost: '{3}{W}' },
{ name: 'Luminarch Ascension', typeLine: 'Enchantment', oracleText: 'At the beginning of each opponent\'s end step, if you weren\'t dealt damage this turn, you may put a quest counter on Luminarch Ascension. {1}{W}: Create a 4/4 white Angel creature token with flying. Activate only if Luminarch Ascension has four or more quest counters on it.', cmc: 2, manaCost: '{1}{W}' },
]
},
mono_blue: {
lands: [
{ name: 'Island', typeLine: 'Basic Land \u2014 Island', oracleText: '{T}: Add {U}.', mana: 'U' }
],
creatures: [
{ name: 'Delver of Secrets', typeLine: 'Creature \u2014 Human Wizard', oracleText: '', pt: '1/1', cmc: 1, manaCost: '{U}' },
{ name: 'Phantasmal Bear', typeLine: 'Creature \u2014 Bear Illusion', oracleText: '', pt: '2/2', cmc: 1, manaCost: '{U}' },
{ name: 'Snapcaster Mage', typeLine: 'Creature \u2014 Human Wizard', oracleText: 'Flash. When Snapcaster Mage enters, target instant or sorcery card in your graveyard gains flashback until end of turn.', pt: '2/1', cmc: 2, manaCost: '{1}{U}' },
{ name: 'Watcher of the Spheres', typeLine: 'Creature \u2014 Bird Wizard', oracleText: 'Flying', pt: '2/2', cmc: 2, manaCost: '{1}{U}' },
{ name: 'Vendilion Clique', typeLine: 'Legendary Creature \u2014 Faerie Wizard', oracleText: 'Flash, flying. When Vendilion Clique enters, look at target player\'s hand. You may choose a nonland card from it. If you do, that player reveals the chosen card, puts it on the bottom of their library, then draws a card.', pt: '3/1', cmc: 3, manaCost: '{1}{U}{U}' },
{ name: 'Thassa, Deep-Dwelling', typeLine: 'Legendary Enchantment Creature \u2014 God', oracleText: 'Indestructible. At the beginning of your end step, exile up to one other target creature you control, then return that card to the battlefield under your control.', pt: '6/5', cmc: 4, manaCost: '{3}{U}' },
{ name: 'Sower of Temptation', typeLine: 'Creature \u2014 Faerie Wizard', oracleText: 'Flying. When Sower of Temptation enters, gain control of target creature for as long as Sower of Temptation remains on the battlefield.', pt: '2/2', cmc: 4, manaCost: '{2}{U}{U}' },
{ name: 'Mulldrifter', typeLine: 'Creature \u2014 Elemental', oracleText: 'Flying. When Mulldrifter enters, draw two cards.', pt: '2/2', cmc: 5, manaCost: '{4}{U}' },
{ name: 'Consecrated Sphinx', typeLine: 'Creature \u2014 Sphinx', oracleText: 'Flying. Whenever an opponent draws a card, you may draw two cards.', pt: '4/6', cmc: 6, manaCost: '{4}{U}{U}' },
{ name: 'Frost Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Whenever Frost Titan enters or attacks, tap target permanent. It doesn\'t untap during its controller\'s next untap step.', pt: '6/6', cmc: 6, manaCost: '{4}{U}{U}' },
{ name: 'Agent of Treachery', typeLine: 'Creature \u2014 Human Rogue', oracleText: 'When Agent of Treachery enters, gain control of target permanent.', pt: '2/3', cmc: 7, manaCost: '{5}{U}{U}' },
{ name: 'Stormtide Leviathan', typeLine: 'Creature \u2014 Leviathan', oracleText: 'Flying. Creatures without flying can\'t attack.', pt: '8/8', cmc: 8, manaCost: '{5}{U}{U}{U}' },
],
spells: [
{ name: 'Ponder', typeLine: 'Sorcery', oracleText: 'Look at the top three cards of your library, then put them back in any order. You may shuffle. Draw a card.', cmc: 1, manaCost: '{U}' },
{ name: 'Counterspell', typeLine: 'Instant', oracleText: 'Counter target spell.', cmc: 2, manaCost: '{U}{U}' },
{ name: 'Negate', typeLine: 'Instant', oracleText: 'Counter target noncreature spell.', cmc: 2, manaCost: '{1}{U}' },
{ name: 'Cyclonic Rift', typeLine: 'Instant', oracleText: 'Return target nonland permanent you don\'t control to its owner\'s hand. Overload {6}{U}', cmc: 2, manaCost: '{1}{U}', isRemoval: true },
{ name: 'Fact or Fiction', typeLine: 'Instant', oracleText: 'Reveal the top five cards of your library. An opponent separates those cards into two piles. Put one pile into your hand and the other into your graveyard.', cmc: 4, manaCost: '{3}{U}' },
{ name: 'Dig Through Time', typeLine: 'Instant', oracleText: 'Delve. Look at the top seven cards of your library. Put two of them into your hand and the rest on the bottom of your library in any order.', cmc: 8, manaCost: '{6}{U}{U}' },
{ name: 'Curse of the Swine', typeLine: 'Sorcery', oracleText: 'Exile X target creatures. For each creature exiled this way, its controller creates a 2/2 green Boar creature token.', cmc: 2, manaCost: '{X}{U}{U}', isRemoval: true },
],
equipment: [
{ name: 'Swiftfoot Boots', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has hexproof and haste. Equip {1}', cmc: 2, manaCost: '{2}' },
{ name: 'Whispersilk Cloak', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature can\'t be blocked and has shroud. Equip {2}', cmc: 3, manaCost: '{3}' },
],
enchantments: [
{ name: 'Rhystic Study', typeLine: 'Enchantment', oracleText: 'Whenever an opponent casts a spell, you may draw a card unless that player pays {1}.', cmc: 3, manaCost: '{2}{U}' },
{ name: 'Propaganda', typeLine: 'Enchantment', oracleText: 'Creatures can\'t attack you unless their controller pays {2} for each creature they control that\'s attacking you.', cmc: 3, manaCost: '{2}{U}' },
]
},
mono_black: {
lands: [
{ name: 'Swamp', typeLine: 'Basic Land \u2014 Swamp', oracleText: '{T}: Add {B}.', mana: 'B' }
],
creatures: [
{ name: 'Vampire of the Dire Moon', typeLine: 'Creature \u2014 Vampire', oracleText: 'Deathtouch, lifelink', pt: '1/1', cmc: 1, manaCost: '{B}' },
{ name: 'Blood Artist', typeLine: 'Creature \u2014 Vampire', oracleText: 'Whenever Blood Artist or another creature dies, target player loses 1 life and you gain 1 life.', pt: '0/1', cmc: 2, manaCost: '{1}{B}' },
{ name: 'Gifted Aetherborn', typeLine: 'Creature \u2014 Aetherborn Vampire', oracleText: 'Deathtouch, lifelink', pt: '2/3', cmc: 2, manaCost: '{B}{B}' },
{ name: 'Vampire Nighthawk', typeLine: 'Creature \u2014 Vampire Shaman', oracleText: 'Flying, deathtouch, lifelink', pt: '2/3', cmc: 3, manaCost: '{1}{B}{B}' },
{ name: 'Murderous Rider', typeLine: 'Creature \u2014 Zombie Knight', oracleText: 'Lifelink. When Murderous Rider dies, put it on the bottom of its owner\'s library.', pt: '2/3', cmc: 3, manaCost: '{1}{B}{B}' },
{ name: 'Gonti, Lord of Luxury', typeLine: 'Legendary Creature \u2014 Aetherborn Rogue', oracleText: 'Deathtouch. When Gonti enters, look at the top four cards of target opponent\'s library, exile one of them face down, then put the rest on the bottom of that library in a random order. You may cast that card for as long as it remains exiled, and you may spend mana as though it were mana of any color to cast that spell.', pt: '2/3', cmc: 4, manaCost: '{2}{B}{B}' },
{ name: 'Gray Merchant of Asphodel', typeLine: 'Creature \u2014 Zombie', oracleText: 'When Gray Merchant of Asphodel enters, each opponent loses X life, where X is your devotion to black. You gain life equal to the life lost this way.', pt: '2/4', cmc: 5, manaCost: '{3}{B}{B}' },
{ name: 'Noxious Gearhulk', typeLine: 'Creature \u2014 Construct', oracleText: 'Menace. When Noxious Gearhulk enters, destroy target creature an opponent controls. You gain life equal to that creature\'s toughness.', pt: '5/4', cmc: 6, manaCost: '{4}{B}{B}', isRemoval: true },
{ name: 'Grave Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Deathtouch. Whenever Grave Titan enters or attacks, create two 2/2 black Zombie creature tokens.', pt: '6/6', cmc: 6, manaCost: '{4}{B}{B}' },
{ name: 'Sheoldred, Whispering One', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Swampwalk. At the beginning of your upkeep, return target creature card from your graveyard to the battlefield. At the beginning of each opponent\'s upkeep, that player sacrifices a creature.', pt: '6/6', cmc: 7, manaCost: '{5}{B}{B}' },
{ name: 'Razaketh, the Foulblooded', typeLine: 'Legendary Creature \u2014 Demon', oracleText: 'Flying, trample. Pay 2 life, Sacrifice another creature: Search your library for a card, put that card into your hand, then shuffle.', pt: '8/8', cmc: 8, manaCost: '{5}{B}{B}{B}' },
],
spells: [
{ name: 'Doom Blade', typeLine: 'Instant', oracleText: 'Destroy target nonblack creature.', cmc: 2, manaCost: '{1}{B}', isRemoval: true },
{ name: 'Go for the Throat', typeLine: 'Instant', oracleText: 'Destroy target nonartifact creature.', cmc: 2, manaCost: '{1}{B}', isRemoval: true },
{ name: 'Sign in Blood', typeLine: 'Sorcery', oracleText: 'Target player draws two cards and loses 2 life.', cmc: 2, manaCost: '{B}{B}' },
{ name: 'Toxic Deluge', typeLine: 'Sorcery', oracleText: 'As an additional cost, pay X life. All creatures get -X/-X until end of turn.', cmc: 3, manaCost: '{2}{B}', isBoardWipe: true },
{ name: 'Damnation', typeLine: 'Sorcery', oracleText: 'Destroy all creatures. They can\'t be regenerated.', cmc: 4, manaCost: '{2}{B}{B}', isBoardWipe: true },
{ name: 'Read the Bones', typeLine: 'Sorcery', oracleText: 'Scry 2, then draw two cards. You lose 2 life.', cmc: 3, manaCost: '{2}{B}' },
{ name: 'Feed the Swarm', typeLine: 'Sorcery', oracleText: 'Destroy target creature or enchantment an opponent controls. You lose life equal to that permanent\'s mana value.', cmc: 2, manaCost: '{1}{B}', isRemoval: true },
],
equipment: [
{ name: 'Lashwrithe', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +1/+1 for each Swamp you control. Equip {B}{B}', cmc: 4, manaCost: '{4}' },
{ name: 'Nightmare Lash', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +1/+1 for each Swamp you control. Equip\u2014Pay 3 life.', cmc: 4, manaCost: '{4}' },
],
enchantments: [
{ name: 'Phyrexian Arena', typeLine: 'Enchantment', oracleText: 'At the beginning of your upkeep, you draw a card and you lose 1 life.', cmc: 3, manaCost: '{1}{B}{B}' },
{ name: 'Black Market', typeLine: 'Enchantment', oracleText: 'Whenever a creature dies, put a charge counter on Black Market. At the beginning of your precombat main phase, add {B} for each charge counter on Black Market.', cmc: 5, manaCost: '{3}{B}{B}' },
]
},
mono_red: {
lands: [
{ name: 'Mountain', typeLine: 'Basic Land \u2014 Mountain', oracleText: '{T}: Add {R}.', mana: 'R' }
],
creatures: [
{ name: 'Goblin Guide', typeLine: 'Creature \u2014 Goblin Scout', oracleText: 'Haste', pt: '2/2', cmc: 1, manaCost: '{R}' },
{ name: 'Monastery Swiftspear', typeLine: 'Creature \u2014 Human Monk', oracleText: 'Haste, prowess', pt: '1/2', cmc: 1, manaCost: '{R}' },
{ name: 'Ash Zealot', typeLine: 'Creature \u2014 Human Warrior', oracleText: 'First strike, haste', pt: '2/2', cmc: 2, manaCost: '{R}{R}' },
{ name: 'Eidolon of the Great Revel', typeLine: 'Enchantment Creature \u2014 Spirit', oracleText: 'Whenever a player casts a spell with mana value 3 or less, Eidolon of the Great Revel deals 2 damage to that player.', pt: '2/2', cmc: 2, manaCost: '{R}{R}' },
{ name: 'Goblin Rabblemaster', typeLine: 'Creature \u2014 Goblin Warrior', oracleText: 'Other Goblin creatures you control attack each combat if able. At the beginning of combat, create a 1/1 red Goblin creature token with haste.', pt: '2/2', cmc: 3, manaCost: '{2}{R}' },
{ name: 'Feldon of the Third Path', typeLine: 'Legendary Creature \u2014 Human Artificer', oracleText: '{2}{R}, {T}: Create a token that\'s a copy of target creature card in your graveyard, except it\'s an artifact in addition to its other types. It gains haste. Sacrifice it at the beginning of the next end step.', pt: '2/3', cmc: 3, manaCost: '{1}{R}{R}' },
{ name: 'Hellrider', typeLine: 'Creature \u2014 Devil', oracleText: 'Haste. Whenever a creature you control attacks, Hellrider deals 1 damage to the defending player.', pt: '3/3', cmc: 4, manaCost: '{2}{R}{R}' },
{ name: 'Purphoros, God of the Forge', typeLine: 'Legendary Enchantment Creature \u2014 God', oracleText: 'Indestructible. Whenever another creature enters under your control, Purphoros deals 2 damage to each opponent.', pt: '6/5', cmc: 4, manaCost: '{3}{R}' },
{ name: 'Thundermaw Hellkite', typeLine: 'Creature \u2014 Dragon', oracleText: 'Flying, haste. When Thundermaw Hellkite enters, it deals 1 damage to each creature with flying your opponents control. Tap those creatures.', pt: '5/5', cmc: 5, manaCost: '{3}{R}{R}' },
{ name: 'Inferno Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Whenever Inferno Titan enters or attacks, it deals 3 damage divided as you choose among one, two, or three targets.', pt: '6/6', cmc: 6, manaCost: '{4}{R}{R}' },
{ name: 'Drakuseth, Maw of Flames', typeLine: 'Legendary Creature \u2014 Dragon', oracleText: 'Flying. Whenever Drakuseth attacks, it deals 4 damage to any target and 3 damage to each of up to two other targets.', pt: '7/7', cmc: 7, manaCost: '{4}{R}{R}{R}' },
{ name: 'Etali, Primal Storm', typeLine: 'Legendary Creature \u2014 Elder Dinosaur', oracleText: 'Whenever Etali attacks, exile the top card of each player\'s library, then you may cast any number of spells from among those cards without paying their mana costs.', pt: '6/6', cmc: 6, manaCost: '{4}{R}{R}' },
],
spells: [
{ name: 'Lightning Bolt', typeLine: 'Instant', oracleText: 'Lightning Bolt deals 3 damage to any target.', cmc: 1, manaCost: '{R}', isRemoval: true },
{ name: 'Chaos Warp', typeLine: 'Instant', oracleText: 'The owner of target permanent shuffles it into their library, then reveals the top card of their library.', cmc: 3, manaCost: '{2}{R}', isRemoval: true },
{ name: 'Blasphemous Act', typeLine: 'Sorcery', oracleText: 'This spell costs {1} less to cast for each creature on the battlefield. Blasphemous Act deals 13 damage to each creature.', cmc: 9, manaCost: '{8}{R}', isBoardWipe: true },
{ name: 'Faithless Looting', typeLine: 'Sorcery', oracleText: 'Draw two cards, then discard two cards.', cmc: 1, manaCost: '{R}' },
{ name: 'Vandalblast', typeLine: 'Sorcery', oracleText: 'Destroy target artifact you don\'t control. Overload {4}{R}', cmc: 1, manaCost: '{R}', isRemoval: true },
{ name: 'Flame Slash', typeLine: 'Sorcery', oracleText: 'Flame Slash deals 4 damage to target creature.', cmc: 1, manaCost: '{R}', isRemoval: true },
{ name: 'Wheel of Fortune', typeLine: 'Sorcery', oracleText: 'Each player discards their hand, then draws seven cards.', cmc: 3, manaCost: '{2}{R}' },
],
equipment: [
{ name: 'Embercleave', typeLine: 'Legendary Artifact \u2014 Equipment', oracleText: 'Flash. This spell costs {1} less to cast for each attacking creature you control. Equipped creature gets +1/+1 and has double strike and trample. Equip {3}', cmc: 6, manaCost: '{4}{R}{R}' },
{ name: 'Sword of Feast and Famine', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature gets +2/+2 and has protection from black and from green. Whenever equipped creature deals combat damage to a player, that player discards a card and you untap all lands you control. Equip {2}', cmc: 3, manaCost: '{3}' },
],
enchantments: [
{ name: 'Impact Tremors', typeLine: 'Enchantment', oracleText: 'Whenever a creature enters under your control, Impact Tremors deals 1 damage to each opponent.', cmc: 2, manaCost: '{1}{R}' },
{ name: 'Goblin Bombardment', typeLine: 'Enchantment', oracleText: 'Sacrifice a creature: Goblin Bombardment deals 1 damage to any target.', cmc: 2, manaCost: '{1}{R}' },
]
},
mono_green: {
lands: [
{ name: 'Forest', typeLine: 'Basic Land \u2014 Forest', oracleText: '{T}: Add {G}.', mana: 'G' }
],
creatures: [
{ name: 'Llanowar Elves', typeLine: 'Creature \u2014 Elf Druid', oracleText: '{T}: Add {G}.', pt: '1/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' },
{ name: 'Elvish Mystic', typeLine: 'Creature \u2014 Elf Druid', oracleText: '{T}: Add {G}.', pt: '1/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' },
{ name: 'Birds of Paradise', typeLine: 'Creature \u2014 Bird', oracleText: 'Flying. {T}: Add one mana of any color.', pt: '0/1', cmc: 1, manaCost: '{G}', isManaProducer: true, manaAbility: 'G' },
{ name: 'Fauna Shaman', typeLine: 'Creature \u2014 Elf Shaman', oracleText: '{G}, {T}, Discard a creature card: Search your library for a creature card, reveal it, put it into your hand, then shuffle.', pt: '2/2', cmc: 2, manaCost: '{1}{G}' },
{ name: 'Leatherback Baloth', typeLine: 'Creature \u2014 Beast', oracleText: '', pt: '4/5', cmc: 3, manaCost: '{G}{G}{G}' },
{ name: 'Reclamation Sage', typeLine: 'Creature \u2014 Elf Shaman', oracleText: 'When Reclamation Sage enters, you may destroy target artifact or enchantment.', pt: '2/1', cmc: 3, manaCost: '{2}{G}', isRemoval: true },
{ name: 'Beast Whisperer', typeLine: 'Creature \u2014 Elf Druid', oracleText: 'Whenever you cast a creature spell, draw a card.', pt: '2/3', cmc: 4, manaCost: '{2}{G}{G}' },
{ name: 'Thragtusk', typeLine: 'Creature \u2014 Beast', oracleText: 'When Thragtusk enters, you gain 5 life. When Thragtusk leaves the battlefield, create a 3/3 green Beast creature token.', pt: '5/3', cmc: 5, manaCost: '{4}{G}' },
{ name: 'Acidic Slime', typeLine: 'Creature \u2014 Ooze', oracleText: 'Deathtouch. When Acidic Slime enters, destroy target artifact, enchantment, or land.', pt: '2/2', cmc: 5, manaCost: '{3}{G}{G}', isRemoval: true },
{ name: 'Primeval Titan', typeLine: 'Creature \u2014 Giant', oracleText: 'Trample. Whenever Primeval Titan enters or attacks, you may search your library for up to two land cards, put them onto the battlefield tapped, then shuffle.', pt: '6/6', cmc: 6, manaCost: '{4}{G}{G}' },
{ name: 'Vorinclex, Voice of Hunger', typeLine: 'Legendary Creature \u2014 Phyrexian Praetor', oracleText: 'Trample. Whenever you tap a land for mana, add one mana of any type that land produced. Whenever an opponent taps a land for mana, that land doesn\'t untap during its controller\'s next untap step.', pt: '7/6', cmc: 8, manaCost: '{6}{G}{G}' },
{ name: 'Craterhoof Behemoth', typeLine: 'Creature \u2014 Beast', oracleText: 'Haste. When Craterhoof Behemoth enters, creatures you control get +X/+X and gain trample until end of turn, where X is the number of creatures you control.', pt: '5/5', cmc: 8, manaCost: '{5}{G}{G}{G}' },
],
spells: [
{ name: 'Beast Within', typeLine: 'Instant', oracleText: 'Destroy target permanent. Its controller creates a 3/3 green Beast creature token.', cmc: 3, manaCost: '{2}{G}', isRemoval: true },
{ name: 'Cultivate', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards, reveal those cards, put one onto the battlefield tapped and the other into your hand, then shuffle.', cmc: 3, manaCost: '{2}{G}', isRamp: true },
{ name: 'Kodama\'s Reach', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards, reveal those cards, put one onto the battlefield tapped and the other into your hand, then shuffle.', cmc: 3, manaCost: '{2}{G}', isRamp: true },
{ name: 'Harmonize', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, manaCost: '{2}{G}{G}' },
{ name: 'Green Sun\'s Zenith', typeLine: 'Sorcery', oracleText: 'Search your library for a green creature card with mana value X or less, put it onto the battlefield, then shuffle. Shuffle Green Sun\'s Zenith into its owner\'s library.', cmc: 1, manaCost: '{X}{G}' },
{ name: 'Return of the Wildspeaker', typeLine: 'Instant', oracleText: 'Choose one: Draw cards equal to the greatest power among non-Human creatures you control; or non-Human creatures you control get +3/+3 until end of turn.', cmc: 5, manaCost: '{4}{G}' },
],
equipment: [
{ name: 'Basilisk Collar', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has deathtouch and lifelink. Equip {2}', cmc: 1, manaCost: '{1}' },
{ name: 'Trailblazer\'s Boots', typeLine: 'Artifact \u2014 Equipment', oracleText: 'Equipped creature has nonbasic landwalk. Equip {2}', cmc: 2, manaCost: '{2}' },
],
enchantments: [
{ name: 'Sylvan Library', typeLine: 'Enchantment', oracleText: 'At the beginning of your draw step, you may draw two additional cards. If you do, choose two cards in your hand drawn this turn. For each of those cards, pay 4 life or put the card on top of your library.', cmc: 2, manaCost: '{1}{G}' },
{ name: 'Greater Good', typeLine: 'Enchantment', oracleText: 'Sacrifice a creature: Draw cards equal to the sacrificed creature\'s power, then discard three cards.', cmc: 4, manaCost: '{2}{G}{G}' },
]
}
};
const AI_COLOR_KEYS = ['mono_white', 'mono_blue', 'mono_black', 'mono_red', 'mono_green'];
function generateAiDeck() {
// Pick a random color
const colorKey = AI_COLOR_KEYS[Math.floor(Math.random() * AI_COLOR_KEYS.length)];
const template = AI_DECK_TEMPLATES[colorKey];
const deck = [];
// 36 lands (reduced from 38 to make room for equipment/enchantments)
for (let i = 0; i < 36; i++) {
const land = { ...template.lands[0] };
deck.push(land);
}
// Fill creatures - randomly sample from pool to get ~35 (with some duplicates allowed)
const creatures = template.creatures;
for (let i = 0; i < 35; i++) {
const c = creatures[i % creatures.length];
deck.push({ ...c });
}
// Fill spells - randomly sample to get ~18
const spells = template.spells;
for (let i = 0; i < 18; i++) {
const s = spells[i % spells.length];
deck.push({ ...s });
}
// Equipment (4 copies spread from pool)
const equip = template.equipment || [];
for (let i = 0; i < 4 && equip.length > 0; i++) {
const e = equip[i % equip.length];
deck.push({ ...e });
}
// Enchantments (4 copies spread from pool)
const ench = template.enchantments || [];
for (let i = 0; i < 4 && ench.length > 0; i++) {
const e = ench[i % ench.length];
deck.push({ ...e });
}
// Ramp spells get the isRamp flag preserved (green especially)
// Commander (last creature in pool, bumped to slot 0)
const commander = { ...creatures[creatures.length - 1], isCommander: true };
deck.push(commander);
// Shuffle (Fisher-Yates)
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return { deck, colorKey };
}
function setupAiDecks() {
if (!gameState) return;
gameState.players.forEach((player, idx) => {
if (!player.isAI) return;
const { deck, colorKey } = generateAiDeck();
player.aiColorKey = colorKey;
player.aiLandPlayedThisTurn = false;
// Put commander in command zone, rest in library
deck.forEach(card => {
const cardObj = {
name: card.name,
imageUrl: '', // AI uses text-based rendering
typeLine: card.typeLine,
oracleText: card.oracleText || '',
cmc: card.cmc || 0,
pt: card.pt || '',
mana: card.mana || '',
manaCost: card.manaCost || '',
isRemoval: card.isRemoval || false,
isBoardWipe: card.isBoardWipe || false,
isManaProducer: card.isManaProducer || false,
manaAbility: card.manaAbility || '',
isRamp: card.isRamp || false,
};
if (card.isCommander) {
player.zones.command.push(cardObj);
if (!player.commanderCardName) player.commanderCardName = cardObj.name || '';
} else {
player.zones.library.push(cardObj);
}
});
// Draw opening hand of 7 with mulligan logic
// AI mulligans until it has at least 2 lands, up to 3 times
var mulliganCount = 0;
var maxMulligans = 3;
var handOk = false;
while (!handOk && mulliganCount <= maxMulligans) {
// Draw 7
player.zones.hand = [];
for (let i = 0; i < 7 && player.zones.library.length > 0; i++) {
player.zones.hand.push(player.zones.library.shift());
}
// Count lands in hand
var landsInHand = player.zones.hand.filter(function(c) {
return c.typeLine && c.typeLine.toLowerCase().includes('land');
}).length;
if (landsInHand >= 2) {
handOk = true;
if (mulliganCount > 0) {
addLogEntry('\u{1F916} ' + player.name + ' mulliganed ' + mulliganCount + ' time' + (mulliganCount > 1 ? 's' : '') + ' (found ' + landsInHand + ' lands)');
}
} else if (mulliganCount < maxMulligans) {
// Put hand back into library and reshuffle
while (player.zones.hand.length > 0) {
player.zones.library.push(player.zones.hand.pop());
}
// Fisher-Yates shuffle
for (let i = player.zones.library.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[player.zones.library[i], player.zones.library[j]] = [player.zones.library[j], player.zones.library[i]];
}
mulliganCount++;
} else {
// Max mulligans reached, keep whatever we have
addLogEntry('\u{1F916} ' + player.name + ' kept hand after ' + mulliganCount + ' mulligan' + (mulliganCount > 1 ? 's' : '') + ' (' + landsInHand + ' lands)');
handOk = true;
}
}
// Commander mulligan penalty: for each mulligan past the free one, put 1 card on bottom
var cardsToPutBack = Math.max(0, mulliganCount - 1);
for (let i = 0; i < cardsToPutBack && player.zones.hand.length > 1; i++) {
// Put the highest CMC non-land card on bottom (expensive cards hurt most when down cards)
var worstIdx = 0;
var worstScore = -1;
player.zones.hand.forEach(function(c, ci) {
var isLand = c.typeLine && c.typeLine.toLowerCase().includes('land');
// Never bottom lands; pick highest CMC spell to bottom
var score = isLand ? -1 : (c.cmc || 0);
if (score > worstScore) {
worstScore = score;
worstIdx = ci;
}
});
var bottomCard = player.zones.hand.splice(worstIdx, 1)[0];
player.zones.library.push(bottomCard);
}
});
}
async function fetchAiCardImages() {
if (!gameState) return;
// Collect all unique card names from AI players
var allNames = [];
gameState.players.forEach(function(player) {
if (!player.isAI) return;
var zones = ['library', 'hand', 'command'];
zones.forEach(function(z) {
if (player.zones[z]) {
player.zones[z].forEach(function(card) {
if (card.name && allNames.indexOf(card.name) === -1) {
allNames.push(card.name);
}
});
}
});
});
if (allNames.length === 0) return;
try {
var cardDataMap = await batchFetchCards(allNames, null);
// Update imageUrl on all AI cards in all zones
gameState.players.forEach(function(player) {
if (!player.isAI) return;
var zones = ['library', 'hand', 'command'];
zones.forEach(function(z) {
if (player.zones[z]) {
player.zones[z].forEach(function(card) {
var data = cardDataMap.get(card.name.toLowerCase());
if (data) {
var imgUrl = '';
if (data.image_uris && data.image_uris.normal) {
imgUrl = data.image_uris.normal;
} else if (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris) {
imgUrl = data.card_faces[0].image_uris.normal;
}
if (imgUrl) card.imageUrl = imgUrl;
}
});
}
});
});
// Also update any AI cards already on the battlefield
gameState.battlefieldCards.forEach(function(card) {
var owner = gameState.players[card.ownerId];
if (!owner || !owner.isAI) return;
var data = cardDataMap.get(card.name.toLowerCase());
if (data) {
var imgUrl = '';
if (data.image_uris && data.image_uris.normal) {
imgUrl = data.image_uris.normal;
} else if (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris) {
imgUrl = data.card_faces[0].image_uris.normal;
}
if (imgUrl) {
card.imageUrl = imgUrl;
var el = document.getElementById('bc-' + card.id);
if (el) {
var img = el.querySelector('img');
if (img) img.src = imgUrl;
}
}
}
});
// Re-render hand if current player has AI cards visible
if (typeof renderHandArea === 'function') renderHandArea();
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
} catch(e) {
console.warn('[AI Images] Failed to fetch card images:', e);
}
}
// Determine how many lands the AI has on the battlefield (untapped)
function aiCountLands(playerId) {
return gameState.battlefieldCards.filter(c =>
c.ownerId === playerId && c.typeLine && c.typeLine.toLowerCase().includes('land')
);
}
function aiCountUntappedLands(playerId) {
return aiCountLands(playerId).filter(c => !c.tapped).length;
}
function aiCountCreatures(playerId) {
return gameState.battlefieldCards.filter(c =>
c.ownerId === playerId && c.typeLine && c.typeLine.toLowerCase().includes('creature') && !c.typeLine.toLowerCase().includes('land')
);
}
function aiGetUntappedCreatures(playerId) {
return aiCountCreatures(playerId).filter(c => !c.tapped);
}
function aiIsLand(card) {
return card.typeLine && card.typeLine.toLowerCase().includes('land');
}
function aiIsCreature(card) {
return card.typeLine && card.typeLine.toLowerCase().includes('creature');
}
function aiGetCmc(card) {
return card.cmc || 0;
}
// ============================================================
// FORGE-INSPIRED CARD SCRIPT API
// Declarative card scripting with SpellAbility objects
// ============================================================
// --- SpellAbility Class ---
// Each ability on a card is decomposed into a SpellAbility object
// with cost, effect, targeting, and a resolve() method.
// Inspired by Forge's SpellAbility.java architecture.
function SpellAbility(config) {
this.id = 'sa_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
this.abilityType = config.abilityType || 'activated'; // activated | triggered | static | mana | spell | keyword
this.api = config.api || ''; // e.g. 'DealDamage', 'GainLife', 'DrawCard', 'DestroyPermanent'
this.cost = config.cost || null; // { mana: '{2}{B}', tap: true, sacrifice: false, discard: 0, payLife: 0, custom: '' }
this.effect = config.effect || {}; // { amount: 3, target: 'creature', scope: 'opponent', ... }
this.trigger = config.trigger || null; // { event: 'etb', filter: 'self' } for triggered abilities
this.keyword = config.keyword || null; // 'flying', 'deathtouch', etc. for keyword abilities
this.zone = config.zone || 'battlefield'; // where this ability functions
this.oracleSnippet = config.oracleSnippet || '';
this.cardName = config.cardName || '';
this.ownerIndex = config.ownerIndex !== undefined ? config.ownerIndex : -1;
this.sourceCardId = config.sourceCardId || null;
this.restrictions = config.restrictions || {}; // { sorcerySpeed: true, oncePerTurn: false }
this.usedThisTurn = false;
// AI evaluation hints (Forge-inspired)
this.aiHints = config.aiHints || {};
// e.g. { priority: 'high', timing: 'mainPhase', targetPriority: 'threateningCreature' }
}
SpellAbility.prototype.canActivate = function(gameState, playerIdx) {
if (this.restrictions.oncePerTurn && this.usedThisTurn) return false;
if (this.restrictions.sorcerySpeed) {
var phase = PHASES[gameState.currentPhaseIndex];
var isMain = phase && (phase.id === 'main1' || phase.id === 'main2');
if (!isMain) return false;
if (gameState.currentPlayerIndex !== playerIdx) return false;
}
if (this.cost) {
if (this.cost.tap && this.sourceCardId) {
var src = gameState.battlefieldCards.find(function(c) { return c.id === this.sourceCardId; }.bind(this));
if (src && src.tapped) return false;
}
if (this.cost.mana) {
var cmc = parseCMCFromManaCost(this.cost.mana);
if (aiCountUntappedLands(playerIdx) < cmc) return false;
}
if (this.cost.payLife) {
var p = gameState.players[playerIdx];
if (!p || p.life <= this.cost.payLife) return false;
}
}
return true;
};
SpellAbility.prototype.resolve = function(gameState, context) {
// context: { caster: playerIdx, targets: [], sourceCard: cardData }
var api = this.api;
var eff = this.effect;
var caster = context.caster;
var targets = context.targets || [];
var source = context.sourceCard;
// Mark used
this.usedThisTurn = true;
// Dispatch to the appropriate CardScript API resolver
if (CardScriptResolvers[api]) {
return CardScriptResolvers[api](gameState, eff, caster, targets, source, this);
}
// Fallback: log that it resolved but has no automated effect
if (typeof addLogEntry === 'function') {
addLogEntry('⚙ ' + this.cardName + ' ability resolved (manual: ' + (this.oracleSnippet || api) + ')');
}
return { resolved: true, automated: false };
};
SpellAbility.prototype.resetTurn = function() {
this.usedThisTurn = false;
};
// Helper to parse CMC from a mana cost string like '{2}{B}{B}'
function parseCMCFromManaCost(manaCost) {
if (!manaCost) return 0;
var total = 0;
var matches = manaCost.match(/\{([^}]+)\}/g);
if (!matches) return 0;
for (var i = 0; i < matches.length; i++) {
var inner = matches[i].replace(/[{}]/g, '');
var num = parseInt(inner);
if (!isNaN(num)) {
total += num;
} else if (/^[WUBRGC]$/i.test(inner)) {
total += 1;
} else if (/^[WUBRG]\/[WUBRG]$/i.test(inner)) {
total += 1; // hybrid
} else if (inner === 'X') {
total += 0; // X = variable
}
}
return total;
}
// ============================================================
// CARD SCRIPT RESOLVERS â Automated effects for each API type
// ============================================================
var CardScriptResolvers = {
// --- Damage ---
DealDamage: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var resolved = [];
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
if (t.type === 'player') {
var player = gs.players[t.index];
if (player && !player.eliminated) {
player.life -= amount;
if (typeof addLogEntry === 'function') {
addLogEntry('⚡ ' + (source ? source.name : sa.cardName) + ' deals ' + amount + ' damage to ' + player.name + ' ');
}
resolved.push({ type: 'player', index: t.index, damage: amount });
}
} else if (t.type === 'creature') {
var card = gs.battlefieldCards.find(function(c) { return c.id === t.id; });
if (card) {
card.damageMarked = (card.damageMarked || 0) + amount;
if (typeof addLogEntry === 'function') {
addLogEntry('⚡ ' + (source ? source.name : sa.cardName) + ' deals ' + amount + ' damage to ' + card.name + ' ');
}
resolved.push({ type: 'creature', id: t.id, damage: amount });
}
}
}
return { resolved: true, automated: true, results: resolved };
},
// --- Life Gain ---
GainLife: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var player = gs.players[caster];
if (player) {
player.life += amount;
if (typeof addLogEntry === 'function') {
addLogEntry('❤ ' + player.name + ' gains ' + amount + ' life from ' + (source ? source.name : sa.cardName) + ' ');
}
if (typeof fireBattlefieldTriggers === 'function') {
fireBattlefieldTriggers('life_gain');
}
}
return { resolved: true, automated: true };
},
// --- Life Loss ---
LoseLife: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
if (t.type === 'player') {
var player = gs.players[t.index];
if (player) {
player.life -= amount;
if (typeof addLogEntry === 'function') {
addLogEntry('💀 ' + player.name + ' loses ' + amount + ' life');
}
}
}
}
return { resolved: true, automated: true };
},
// --- Draw Cards ---
DrawCard: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var playerIdx = (eff.targetSelf !== false) ? caster : (targets.length > 0 ? targets[0].index : caster);
var player = gs.players[playerIdx];
if (player) {
var drawn = 0;
for (var d = 0; d < amount; d++) {
if (player.zones.library.length > 0) {
var drawnCard = player.zones.library.shift();
player.zones.hand.push(drawnCard);
drawn++;
}
}
if (typeof addLogEntry === 'function') {
addLogEntry('📃 ' + player.name + ' draws ' + drawn + ' card' + (drawn !== 1 ? 's' : '') + ' from ' + (source ? source.name : sa.cardName) + ' ');
}
if (typeof fireBattlefieldTriggers === 'function') {
for (var dd = 0; dd < drawn; dd++) {
fireBattlefieldTriggers('draw');
}
}
}
return { resolved: true, automated: true, drawn: drawn };
},
// --- Discard ---
Discard: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var playerIdx = (eff.scope === 'opponent' && targets.length > 0) ? targets[0].index : caster;
var player = gs.players[playerIdx];
if (player) {
var discarded = 0;
for (var d = 0; d < amount; d++) {
if (player.zones.hand.length > 0) {
// AI: discard worst card; Human: random for now
var idx = player.isAI ? player.zones.hand.length - 1 : Math.floor(Math.random() * player.zones.hand.length);
var card = player.zones.hand.splice(idx, 1)[0];
player.zones.graveyard.push(card);
discarded++;
}
}
if (typeof addLogEntry === 'function') {
addLogEntry('☠ ' + player.name + ' discards ' + discarded + ' card' + (discarded !== 1 ? 's' : ''));
}
}
return { resolved: true, automated: true };
},
// --- Destroy Permanent ---
DestroyPermanent: function(gs, eff, caster, targets, source, sa) {
var destroyed = [];
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
if (t.type === 'creature' || t.type === 'permanent') {
var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; });
if (idx !== -1) {
var card = gs.battlefieldCards[idx];
// Check indestructible
if (typeof hasKeyword === 'function' && hasKeyword(card, 'indestructible')) {
if (typeof addLogEntry === 'function') {
addLogEntry('🛡 ' + card.name + ' is indestructible!');
}
continue;
}
gs.battlefieldCards.splice(idx, 1);
var owner = gs.players[card.ownerIndex || card.ownerId];
if (owner) {
owner.zones.graveyard.push(card);
}
// Fire dies triggers
if (typeof fireBattlefieldTriggers === 'function') {
fireBattlefieldTriggers('dies');
}
if (typeof fireCardTriggers === 'function') {
fireCardTriggers(card, ['dies']);
}
if (typeof addLogEntry === 'function') {
addLogEntry('☠ ' + card.name + ' was destroyed by ' + (source ? source.name : sa.cardName) + ' ');
}
destroyed.push(card);
}
}
}
return { resolved: true, automated: true, destroyed: destroyed };
},
// --- Exile Permanent ---
ExilePermanent: function(gs, eff, caster, targets, source, sa) {
var exiled = [];
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; });
if (idx !== -1) {
var card = gs.battlefieldCards.splice(idx, 1)[0];
var owner = gs.players[card.ownerIndex || card.ownerId];
if (owner) {
owner.zones.exile.push(card);
}
if (typeof addLogEntry === 'function') {
addLogEntry('⬡ ' + card.name + ' was exiled by ' + (source ? source.name : sa.cardName) + ' ');
}
exiled.push(card);
}
}
return { resolved: true, automated: true, exiled: exiled };
},
// --- Create Token ---
CreateToken: function(gs, eff, caster, targets, source, sa) {
var count = eff.amount || 1;
var tokenName = eff.tokenName || 'Token';
var tokenPower = eff.power || 1;
var tokenTough = eff.toughness || 1;
var tokenType = eff.tokenType || 'Creature Token';
var tokenKeywords = eff.keywords || [];
for (var i = 0; i < count; i++) {
var tokenData = {
name: tokenName,
typeLine: tokenType,
oracleText: tokenKeywords.join(', '),
power: String(tokenPower),
toughness: String(tokenTough),
pt: tokenPower + '/' + tokenTough,
imageUrl: '',
isToken: true,
keywords: tokenKeywords
};
if (typeof generateAiCardSVG === 'function') {
tokenData.imageUrl = generateAiCardSVG(tokenData);
}
if (typeof addCardToBattlefield === 'function') {
addCardToBattlefield(tokenName, tokenData.imageUrl, tokenType, caster, undefined, undefined, tokenData.oracleText, tokenData);
}
}
if (typeof addLogEntry === 'function') {
addLogEntry('⚖ ' + gs.players[caster].name + ' creates ' + count + ' ' + tokenPower + '/' + tokenTough + ' ' + tokenName + ' token' + (count !== 1 ? 's' : ''));
}
return { resolved: true, automated: true };
},
// --- Mana Production ---
ManaAdd: function(gs, eff, caster, targets, source, sa) {
// Visual log only; mana pool is handled by existing adjustMana system
var colors = eff.colors || ['C']; // colorless by default
if (typeof addLogEntry === 'function') {
addLogEntry('⚛ ' + (source ? source.name : sa.cardName) + ' adds {' + colors.join('}{') + '} to mana pool');
}
return { resolved: true, automated: true };
},
// --- Pump Creature (+X/+X until end of turn) ---
PumpCreature: function(gs, eff, caster, targets, source, sa) {
var pw = eff.power || 0;
var tw = eff.toughness || 0;
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var card = gs.battlefieldCards.find(function(c) { return c.id === t.id; });
if (card) {
card.powerMod = (card.powerMod || 0) + pw;
card.toughnessMod = (card.toughnessMod || 0) + tw;
if (typeof addLogEntry === 'function') {
var sign = function(n) { return n >= 0 ? '+' + n : '' + n; };
addLogEntry('⚔ ' + card.name + ' gets ' + sign(pw) + '/' + sign(tw) + ' from ' + (source ? source.name : sa.cardName) + ' ');
}
}
}
return { resolved: true, automated: true };
},
// --- Counter Spell ---
CounterSpell: function(gs, eff, caster, targets, source, sa) {
if (gs.theStack && gs.theStack.length > 0) {
// Counter the top spell on the stack (or targeted)
var targetIdx = targets.length > 0 ? targets[0].stackIndex : gs.theStack.length - 1;
if (targetIdx >= 0 && targetIdx < gs.theStack.length) {
var countered = gs.theStack.splice(targetIdx, 1)[0];
var owner = gs.players[countered.ownerIndex];
if (owner) {
owner.zones.graveyard.push(countered);
}
if (typeof addLogEntry === 'function') {
addLogEntry('❌ ' + (source ? source.name : sa.cardName) + ' counters ' + countered.name + ' !');
}
return { resolved: true, automated: true, countered: countered };
}
}
return { resolved: true, automated: false };
},
// --- Search Library ---
SearchLibrary: function(gs, eff, caster, targets, source, sa) {
// Log only; actual search requires human interaction
if (typeof addLogEntry === 'function') {
addLogEntry('📚 ' + gs.players[caster].name + ' searches their library (' + (source ? source.name : sa.cardName) + ')');
}
return { resolved: true, automated: false };
},
// --- Board Wipe ---
DestroyAll: function(gs, eff, caster, targets, source, sa) {
var filter = eff.filter || 'creature'; // 'creature', 'nonland', 'all'
var destroyed = [];
var toRemove = [];
for (var i = gs.battlefieldCards.length - 1; i >= 0; i--) {
var card = gs.battlefieldCards[i];
var matches = false;
if (filter === 'creature' && card.typeLine && /creature/i.test(card.typeLine)) matches = true;
if (filter === 'nonland' && card.typeLine && !/land/i.test(card.typeLine)) matches = true;
if (filter === 'all') matches = true;
if (filter === 'artifact' && card.typeLine && /artifact/i.test(card.typeLine)) matches = true;
if (filter === 'enchantment' && card.typeLine && /enchantment/i.test(card.typeLine)) matches = true;
if (matches) {
if (typeof hasKeyword === 'function' && hasKeyword(card, 'indestructible')) continue;
toRemove.push(i);
}
}
for (var r = 0; r < toRemove.length; r++) {
var card = gs.battlefieldCards.splice(toRemove[r], 1)[0];
var owner = gs.players[card.ownerIndex || card.ownerId];
if (owner) owner.zones.graveyard.push(card);
destroyed.push(card);
}
if (typeof addLogEntry === 'function') {
addLogEntry('💥 ' + (source ? source.name : sa.cardName) + ' destroys ' + destroyed.length + ' permanents!');
}
return { resolved: true, automated: true, destroyed: destroyed };
},
// --- Bounce (Return to Hand) ---
ReturnToHand: function(gs, eff, caster, targets, source, sa) {
var bounced = [];
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var idx = gs.battlefieldCards.findIndex(function(c) { return c.id === t.id; });
if (idx !== -1) {
var card = gs.battlefieldCards.splice(idx, 1)[0];
var owner = gs.players[card.ownerIndex || card.ownerId];
if (owner) owner.zones.hand.push(card);
if (typeof addLogEntry === 'function') {
addLogEntry('🔄 ' + card.name + ' returned to hand by ' + (source ? source.name : sa.cardName) + ' ');
}
bounced.push(card);
}
}
return { resolved: true, automated: true, bounced: bounced };
},
// --- Tap/Untap ---
TapPermanent: function(gs, eff, caster, targets, source, sa) {
for (var i = 0; i < targets.length; i++) {
var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; });
if (card) {
card.tapped = eff.untap ? false : true;
}
}
return { resolved: true, automated: true };
},
// --- Mill ---
Mill: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var playerIdx = targets.length > 0 ? targets[0].index : caster;
var player = gs.players[playerIdx];
if (player) {
var milled = 0;
for (var m = 0; m < amount; m++) {
if (player.zones.library.length > 0) {
var card = player.zones.library.shift();
player.zones.graveyard.push(card);
milled++;
}
}
if (typeof addLogEntry === 'function') {
addLogEntry('🌀 ' + player.name + ' mills ' + milled + ' card' + (milled !== 1 ? 's' : ''));
}
}
return { resolved: true, automated: true };
},
// --- Scry ---
Scry: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
if (typeof addLogEntry === 'function') {
addLogEntry('👁 ' + gs.players[caster].name + ' scries ' + amount + ' (' + (source ? source.name : sa.cardName) + ')');
}
return { resolved: true, automated: false }; // Requires manual reordering
},
// --- Gain Keyword (until end of turn) ---
GainKeyword: function(gs, eff, caster, targets, source, sa) {
var keyword = eff.keyword || '';
for (var i = 0; i < targets.length; i++) {
var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; });
if (card) {
if (!card.tempKeywords) card.tempKeywords = [];
card.tempKeywords.push(keyword);
if (typeof addLogEntry === 'function') {
addLogEntry('✨ ' + card.name + ' gains ' + keyword + ' until end of turn');
}
}
}
return { resolved: true, automated: true };
},
// --- Put Counters ---
PutCounters: function(gs, eff, caster, targets, source, sa) {
var counterType = eff.counterType || '+1/+1';
var amount = eff.amount || 1;
for (var i = 0; i < targets.length; i++) {
var card = gs.battlefieldCards.find(function(c) { return c.id === targets[i].id; });
if (card) {
if (!card.counters) card.counters = {};
card.counters[counterType] = (card.counters[counterType] || 0) + amount;
// Apply stat mods for +1/+1 and -1/-1 counters
if (counterType === '+1/+1') {
card.powerMod = (card.powerMod || 0) + amount;
card.toughnessMod = (card.toughnessMod || 0) + amount;
} else if (counterType === '-1/-1') {
card.powerMod = (card.powerMod || 0) - amount;
card.toughnessMod = (card.toughnessMod || 0) - amount;
}
if (typeof addLogEntry === 'function') {
addLogEntry('⬤ ' + amount + ' ' + counterType + ' counter' + (amount !== 1 ? 's' : '') + ' on ' + card.name + ' ');
}
}
}
return { resolved: true, automated: true };
},
// --- Ramp (Put land from library onto battlefield) ---
Ramp: function(gs, eff, caster, targets, source, sa) {
var amount = eff.amount || 1;
var player = gs.players[caster];
if (player) {
var ramped = 0;
for (var r = 0; r < amount; r++) {
// Find a basic land in library
var landIdx = player.zones.library.findIndex(function(c) {
return c.typeLine && /basic.*land/i.test(c.typeLine);
});
if (landIdx === -1) {
landIdx = player.zones.library.findIndex(function(c) {
return c.typeLine && /land/i.test(c.typeLine);
});
}
if (landIdx !== -1) {
var land = player.zones.library.splice(landIdx, 1)[0];
if (typeof addCardToBattlefield === 'function') {
addCardToBattlefield(land.name, land.imageUrl || '', land.typeLine || 'Land', caster, undefined, undefined, land.oracleText || '', land);
}
ramped++;
}
}
if (typeof addLogEntry === 'function' && ramped > 0) {
addLogEntry('🌱 ' + player.name + ' ramps ' + ramped + ' land' + (ramped !== 1 ? 's' : '') + ' (' + (source ? source.name : sa.cardName) + ')');
}
}
return { resolved: true, automated: true };
}
};
// ============================================================
// CARD SCRIPT PARSER â Forge-style declarative text format
// ============================================================
// Format: "A:AB$ | Cost$ | Defined$ | "
// Also parses: "T:Mode$ | Execute$ | ..."
// And: "K:"
// And: "S:Mode$ | Affected$ | AddKeyword$ "
var CardScriptParser = {
// Parse a Forge-style script string into an array of SpellAbility objects
parse: function(scriptText, cardName, ownerIndex, sourceCardId) {
if (!scriptText) return [];
var abilities = [];
var lines = scriptText.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.charAt(0) === '#') continue; // Skip comments and empty lines
var sa = null;
if (line.indexOf('A:AB$') === 0) {
sa = this.parseActivated(line, cardName, ownerIndex, sourceCardId);
} else if (line.indexOf('T:Mode$') === 0) {
sa = this.parseTriggered(line, cardName, ownerIndex, sourceCardId);
} else if (line.indexOf('K:') === 0) {
sa = this.parseKeyword(line, cardName, ownerIndex, sourceCardId);
} else if (line.indexOf('S:Mode$') === 0) {
sa = this.parseStatic(line, cardName, ownerIndex, sourceCardId);
} else if (line.indexOf('SP:AB$') === 0) {
sa = this.parseSpell(line, cardName, ownerIndex, sourceCardId);
}
if (sa) abilities.push(sa);
}
return abilities;
},
// Parse pipe-separated parameters: "Key$ Value | Key2$ Value2"
parseParams: function(text) {
var params = {};
var parts = text.split('|');
for (var i = 0; i < parts.length; i++) {
var part = parts[i].trim();
var dollarIdx = part.indexOf('$');
if (dollarIdx !== -1) {
var key = part.substring(0, dollarIdx).trim();
var val = part.substring(dollarIdx + 1).trim();
params[key] = val;
}
}
return params;
},
// Parse cost string: "{2}{B}, {T}" -> { mana: '{2}{B}', tap: true }
parseCost: function(costStr) {
if (!costStr) return null;
var cost = { mana: '', tap: false, sacrifice: false, discard: 0, payLife: 0, custom: '' };
if (/\{T\}/i.test(costStr)) cost.tap = true;
if (/sacrifice/i.test(costStr)) cost.sacrifice = true;
var lifeMatch = costStr.match(/pay (\d+) life/i);
if (lifeMatch) cost.payLife = parseInt(lifeMatch[1]);
var discardMatch = costStr.match(/discard (\d+)/i);
if (discardMatch) cost.discard = parseInt(discardMatch[1]);
// Extract mana symbols
var manaParts = costStr.match(/\{[WUBRGCX0-9/]+\}/gi);
if (manaParts) {
cost.mana = manaParts.filter(function(m) { return m !== '{T}'; }).join('');
}
return cost;
},
// A:AB$ DealDamage | Cost$ {R} | NumDmg$ 3 | ValidTgts$ Creature,Player
parseActivated: function(line, cardName, ownerIndex, sourceCardId) {
var body = line.substring(5); // strip "A:AB$"
var params = this.parseParams(body);
var api = (body.split('|')[0] || '').trim();
var cost = this.parseCost(params['Cost'] || '');
var effect = this.buildEffect(api, params);
return new SpellAbility({
abilityType: 'activated',
api: api,
cost: cost,
effect: effect,
cardName: cardName,
ownerIndex: ownerIndex,
sourceCardId: sourceCardId,
oracleSnippet: params['SpellDescription'] || '',
restrictions: {
sorcerySpeed: params['SorcerySpeed'] === 'True',
oncePerTurn: params['OncePerTurn'] === 'True'
},
aiHints: {
priority: params['AILogic'] || 'normal',
timing: cost && cost.tap ? 'beforeCombat' : 'mainPhase'
}
});
},
// T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DrawCard | ...
parseTriggered: function(line, cardName, ownerIndex, sourceCardId) {
var body = line.substring(7); // strip "T:Mode$"
var params = this.parseParams(body);
var mode = (body.split('|')[0] || '').trim();
var api = params['Execute'] || '';
var effect = this.buildEffect(api, params);
var trigger = this.buildTrigger(mode, params);
return new SpellAbility({
abilityType: 'triggered',
api: api,
effect: effect,
trigger: trigger,
cardName: cardName,
ownerIndex: ownerIndex,
sourceCardId: sourceCardId,
oracleSnippet: params['TriggerDescription'] || '',
aiHints: {
priority: params['AILogic'] || 'normal'
}
});
},
// K:Flying or K:Deathtouch or K:Lifelink
parseKeyword: function(line, cardName, ownerIndex, sourceCardId) {
var kw = line.substring(2).trim();
return new SpellAbility({
abilityType: 'keyword',
api: 'Keyword',
keyword: kw.toLowerCase(),
cardName: cardName,
ownerIndex: ownerIndex,
sourceCardId: sourceCardId,
oracleSnippet: kw,
aiHints: {
priority: 'passive'
}
});
},
// S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddPower$ 1 | AddToughness$ 1
parseStatic: function(line, cardName, ownerIndex, sourceCardId) {
var body = line.substring(7); // strip "S:Mode$"
var params = this.parseParams(body);
var mode = (body.split('|')[0] || '').trim();
return new SpellAbility({
abilityType: 'static',
api: 'StaticAbility',
effect: {
mode: mode,
affected: params['Affected'] || '',
addPower: parseInt(params['AddPower'] || '0'),
addToughness: parseInt(params['AddToughness'] || '0'),
addKeyword: params['AddKeyword'] || '',
addAbility: params['AddAbility'] || ''
},
cardName: cardName,
ownerIndex: ownerIndex,
sourceCardId: sourceCardId,
oracleSnippet: params['StaticDescription'] || '',
aiHints: {
priority: 'passive'
}
});
},
// SP:AB$ DealDamage | Cost$ {1}{R} | NumDmg$ 3 | ValidTgts$ Creature,Player
parseSpell: function(line, cardName, ownerIndex, sourceCardId) {
var body = line.substring(5); // strip "SP:AB"
var params = this.parseParams(body);
var api = (body.split('|')[0] || '').trim().replace(/^\$\s*/, '');
var cost = this.parseCost(params['Cost'] || '');
var effect = this.buildEffect(api, params);
return new SpellAbility({
abilityType: 'spell',
api: api,
cost: cost,
effect: effect,
cardName: cardName,
ownerIndex: ownerIndex,
sourceCardId: sourceCardId,
oracleSnippet: params['SpellDescription'] || '',
aiHints: {
priority: params['AILogic'] || 'normal'
}
});
},
// Build effect object from API name + params
buildEffect: function(api, params) {
var eff = {};
switch (api) {
case 'DealDamage':
eff.amount = parseInt(params['NumDmg'] || '1');
eff.target = params['ValidTgts'] || 'Any';
break;
case 'GainLife':
eff.amount = parseInt(params['LifeAmount'] || '1');
break;
case 'LoseLife':
eff.amount = parseInt(params['LifeAmount'] || '1');
eff.scope = params['Scope'] || 'opponent';
break;
case 'DrawCard':
eff.amount = parseInt(params['NumCards'] || '1');
eff.targetSelf = params['Defined'] !== 'Opponent';
break;
case 'Discard':
eff.amount = parseInt(params['NumCards'] || '1');
eff.scope = params['Defined'] === 'Opponent' ? 'opponent' : 'self';
break;
case 'DestroyPermanent':
eff.target = params['ValidTgts'] || 'Creature';
break;
case 'ExilePermanent':
eff.target = params['ValidTgts'] || 'Permanent';
break;
case 'CreateToken':
eff.amount = parseInt(params['TokenAmount'] || '1');
eff.tokenName = params['TokenName'] || 'Token';
eff.power = parseInt(params['TokenPower'] || '1');
eff.toughness = parseInt(params['TokenToughness'] || '1');
eff.tokenType = params['TokenType'] || 'Creature Token';
eff.keywords = (params['TokenKeywords'] || '').split(',').map(function(k) { return k.trim(); }).filter(Boolean);
break;
case 'ManaAdd':
eff.colors = (params['Produced'] || 'C').split(',').map(function(c) { return c.trim(); });
break;
case 'PumpCreature':
eff.power = parseInt(params['NumAtt'] || '0');
eff.toughness = parseInt(params['NumDef'] || '0');
break;
case 'CounterSpell':
eff.target = 'spell';
break;
case 'SearchLibrary':
eff.searchType = params['SearchType'] || 'Any';
break;
case 'DestroyAll':
eff.filter = (params['ValidCards'] || 'creature').toLowerCase();
break;
case 'ReturnToHand':
eff.target = params['ValidTgts'] || 'Permanent';
break;
case 'TapPermanent':
eff.untap = params['Untap'] === 'True';
break;
case 'Mill':
eff.amount = parseInt(params['NumCards'] || '1');
break;
case 'Scry':
eff.amount = parseInt(params['ScryNum'] || '1');
break;
case 'GainKeyword':
eff.keyword = params['KW'] || '';
break;
case 'PutCounters':
eff.counterType = params['CounterType'] || '+1/+1';
eff.amount = parseInt(params['CounterNum'] || '1');
break;
case 'Ramp':
eff.amount = parseInt(params['Amount'] || '1');
break;
}
return eff;
},
// Build trigger descriptor from mode + params
buildTrigger: function(mode, params) {
var trigger = { event: '', filter: 'any' };
switch (mode) {
case 'ChangesZone':
if (params['Destination'] === 'Battlefield') trigger.event = 'etb';
else if (params['Origin'] === 'Battlefield') trigger.event = 'ltb';
break;
case 'ChangesZoneAll':
trigger.event = 'etb';
trigger.filter = params['ValidCards'] || 'Creature';
break;
case 'Attacks':
trigger.event = 'attack';
break;
case 'Blocks':
trigger.event = 'block';
break;
case 'DamageDone':
trigger.event = 'damage';
break;
case 'SpellCast':
trigger.event = 'cast';
trigger.filter = params['ValidActivatingPlayer'] || 'You';
break;
case 'Phase':
if (/upkeep/i.test(params['Phase'] || '')) trigger.event = 'upkeep';
else if (/end/i.test(params['Phase'] || '')) trigger.event = 'end_step';
else if (/combat/i.test(params['Phase'] || '')) trigger.event = 'combat_begin';
break;
case 'LifeGained':
trigger.event = 'life_gain';
break;
case 'LifeLost':
trigger.event = 'life_loss';
break;
case 'Drawn':
trigger.event = 'draw';
break;
case 'Discarded':
trigger.event = 'discard';
break;
case 'Landfall':
trigger.event = 'landfall';
break;
default:
trigger.event = mode.toLowerCase();
}
return trigger;
}
};
// ============================================================
// CARD SCRIPT REGISTRY â Common card patterns
// ============================================================
// Maps oracle text patterns to Forge-style CardScript definitions.
// When a card's oracle text matches, the corresponding SpellAbilities
// are automatically generated, enabling the AI to understand and
// potentially automate common card effects.
var CardScriptRegistry = {
// Each entry: { pattern: RegExp, script: function(match, cardName) -> scriptString }
patterns: [],
register: function(pattern, scriptFn) {
this.patterns.push({ pattern: pattern, scriptFn: scriptFn });
},
// Try to match a card's oracle text and generate CardScript
match: function(oracleText, typeLine, cardName) {
if (!oracleText) return '';
var scripts = [];
var text = oracleText;
for (var i = 0; i < this.patterns.length; i++) {
var entry = this.patterns[i];
var m = text.match(entry.pattern);
if (m) {
var script = entry.scriptFn(m, cardName);
if (script) scripts.push(script);
}
}
// Also generate keyword scripts
var kwText = text.toLowerCase();
var commonKeywords = ['flying', 'deathtouch', 'lifelink', 'first strike', 'double strike',
'trample', 'haste', 'vigilance', 'menace', 'hexproof', 'indestructible',
'reach', 'flash', 'infect', 'toxic', 'ward', 'defender', 'shroud',
'prowess', 'cascade', 'undying', 'persist', 'annihilator'];
for (var k = 0; k < commonKeywords.length; k++) {
var kw = commonKeywords[k];
var kwRe = new RegExp('(?:^|[,;.\\n\\s])' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:\\b|\\s|,|\\.|$)', 'i');
if (kwRe.test(kwText) || kwRe.test(',' + kwText)) {
scripts.push('K:' + kw.charAt(0).toUpperCase() + kw.slice(1));
}
}
return scripts.join('\n');
}
};
// --- Register common Oracle text patterns ---
// ETB: "When [cardname/this creature] enters the battlefield, [effect]"
CardScriptRegistry.register(
/when (?:this creature|~|it) enters(?: the battlefield)?,?\s*(.+?)(?:\.|$)/i,
function(m, cardName) {
var effect = m[1].toLowerCase();
// ETB draw
if (/draw (\w+|\d+) cards?/.test(effect)) {
var num = effect.match(/draw (\w+|\d+)/);
var amount = num ? (parseInt(num[1]) || ({'a':1,'one':1,'two':2,'three':3,'four':4,'five':5}[num[1]] || 1)) : 1;
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DrawCard | NumCards$ ' + amount + ' | TriggerDescription$ ETB: Draw ' + amount;
}
// ETB gain life
if (/gain (\d+) life/.test(effect)) {
var life = effect.match(/gain (\d+)/);
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ GainLife | LifeAmount$ ' + (life ? life[1] : '1') + ' | TriggerDescription$ ETB: Gain life';
}
// ETB deal damage
if (/deals? (\d+) damage/.test(effect)) {
var dmg = effect.match(/deals? (\d+)/);
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DealDamage | NumDmg$ ' + (dmg ? dmg[1] : '1') + ' | TriggerDescription$ ETB: Deal damage';
}
// ETB create token
if (/create (\w+|\d+)\s+(\d+)\/(\d+)/.test(effect)) {
var tok = effect.match(/create (\w+|\d+)\s+(\d+)\/(\d+)\s*([\w\s]*?)(?:creature)?\s*token/i);
if (tok) {
var cnt = parseInt(tok[1]) || ({'a':1,'one':1,'two':2,'three':3}[tok[1]] || 1);
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ CreateToken | TokenAmount$ ' + cnt +
' | TokenPower$ ' + tok[2] + ' | TokenToughness$ ' + tok[3] + ' | TriggerDescription$ ETB: Create tokens';
}
}
// ETB destroy
if (/destroy target/.test(effect)) {
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ DestroyPermanent | TriggerDescription$ ETB: Destroy';
}
// ETB exile
if (/exile target/.test(effect)) {
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ ExilePermanent | TriggerDescription$ ETB: Exile';
}
// ETB +1/+1 counters
if (/(\d+) \+1\/\+1 counter/.test(effect)) {
var cnt = effect.match(/(\d+) \+1\/\+1/);
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ PutCounters | CounterType$ +1/+1 | CounterNum$ ' + (cnt ? cnt[1] : '1') + ' | TriggerDescription$ ETB: +1/+1 counters';
}
// Generic ETB (not automated)
return 'T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | Execute$ Generic | TriggerDescription$ ETB: ' + effect.substring(0, 50);
}
);
// Activated: "{cost}: Draw a card"
CardScriptRegistry.register(
/(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*draw (\w+|\d+) cards?/i,
function(m) {
var num = parseInt(m[2]) || ({'a':1,'one':1,'two':2,'three':3}[m[2].toLowerCase()] || 1);
return 'A:AB$ DrawCard | Cost$ ' + m[1] + ' | NumCards$ ' + num + ' | SpellDescription$ Draw ' + num;
}
);
// Activated: "{cost}: Deal N damage"
CardScriptRegistry.register(
/(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*(?:it |~\s+)?deals? (\d+) damage/i,
function(m) {
return 'A:AB$ DealDamage | Cost$ ' + m[1] + ' | NumDmg$ ' + m[2] + ' | ValidTgts$ Any | SpellDescription$ Deal ' + m[2] + ' damage';
}
);
// Activated: "{cost}: Gain N life"
CardScriptRegistry.register(
/(\{[^}]+\}(?:\s*,?\s*\{[^}]*\})*)\s*:\s*(?:you )?gain (\d+) life/i,
function(m) {
return 'A:AB$ GainLife | Cost$ ' + m[1] + ' | LifeAmount$ ' + m[2] + ' | SpellDescription$ Gain ' + m[2] + ' life';
}
);
// Mana ability: "{T}: Add {X}"
CardScriptRegistry.register(
/\{T\}\s*:\s*add\s+(\{[WUBRGC]\}(?:\s*(?:or\s*)?\{[WUBRGC]\})*)/i,
function(m) {
var colors = [];
var colorMatches = m[1].match(/\{([WUBRGC])\}/gi);
if (colorMatches) {
colors = colorMatches.map(function(c) { return c.replace(/[{}]/g, ''); });
}
return 'A:AB$ ManaAdd | Cost$ {T} | Produced$ ' + colors.join(',') + ' | SpellDescription$ Tap for mana';
}
);
// Sorcery/Instant: "Destroy target creature"
CardScriptRegistry.register(
/destroy (?:target|all) (creature|permanent|artifact|enchantment|planeswalker)s?/i,
function(m) {
if (/all/i.test(m[0])) {
return 'SP:AB$ DestroyAll | ValidCards$ ' + m[1] + ' | SpellDescription$ Destroy all ' + m[1] + 's';
}
return 'SP:AB$ DestroyPermanent | ValidTgts$ ' + m[1].charAt(0).toUpperCase() + m[1].slice(1) + ' | SpellDescription$ Destroy target ' + m[1];
}
);
// Sorcery/Instant: "Exile target creature/permanent"
CardScriptRegistry.register(
/exile target (creature|permanent|artifact|enchantment|nonland permanent)/i,
function(m) {
return 'SP:AB$ ExilePermanent | ValidTgts$ ' + m[1].charAt(0).toUpperCase() + m[1].slice(1) + ' | SpellDescription$ Exile target ' + m[1];
}
);
// Sorcery/Instant: "Counter target spell"
CardScriptRegistry.register(
/counter target (\w+ )?spell/i,
function(m) {
return 'SP:AB$ CounterSpell | SpellDescription$ Counter target spell';
}
);
// Draw: "Draw N cards"
CardScriptRegistry.register(
/^draw (\w+|\d+) cards?/im,
function(m) {
var num = parseInt(m[1]) || ({'a':1,'one':1,'two':2,'three':3,'four':4,'five':5}[m[1].toLowerCase()] || 1);
return 'SP:AB$ DrawCard | NumCards$ ' + num + ' | SpellDescription$ Draw ' + num;
}
);
// Ramp: "Search your library for a basic land"
CardScriptRegistry.register(
/search your library for (?:up to )?(\w+|\d+)? ?(?:basic )?land cards?/i,
function(m) {
var num = m[1] ? (parseInt(m[1]) || ({'a':1,'one':1,'two':2,'three':3}[m[1].toLowerCase()] || 1)) : 1;
return 'SP:AB$ Ramp | Amount$ ' + num + ' | SpellDescription$ Search for ' + num + ' land(s)';
}
);
// Mill: "Mill N cards" or "put the top N cards of your library into your graveyard"
CardScriptRegistry.register(
/(?:mill (\d+)|put the top (\d+) cards? of (?:your|their|target player's) library into (?:your|their|that player's) graveyard)/i,
function(m) {
var num = parseInt(m[1] || m[2] || '1');
return 'SP:AB$ Mill | NumCards$ ' + num + ' | SpellDescription$ Mill ' + num;
}
);
// Scry: "Scry N"
CardScriptRegistry.register(
/scry (\d+)/i,
function(m) {
return 'SP:AB$ Scry | ScryNum$ ' + m[1] + ' | SpellDescription$ Scry ' + m[1];
}
);
// Dies trigger: "When [name/this creature] dies, [effect]"
CardScriptRegistry.register(
/when (?:this creature|~|it) dies,?\s*(.+?)(?:\.|$)/i,
function(m, cardName) {
var effect = m[1].toLowerCase();
if (/draw (\w+|\d+) cards?/.test(effect)) {
var num = effect.match(/draw (\w+|\d+)/);
var amount = num ? (parseInt(num[1]) || 1) : 1;
return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ DrawCard | NumCards$ ' + amount + ' | TriggerDescription$ Dies: Draw';
}
if (/create/.test(effect)) {
return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ CreateToken | TriggerDescription$ Dies: Create token';
}
return 'T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | Execute$ Generic | TriggerDescription$ Dies: ' + effect.substring(0, 50);
}
);
// Attack trigger: "Whenever [name/this creature] attacks, [effect]"
CardScriptRegistry.register(
/whenever (?:this creature|~|it) attacks,?\s*(.+?)(?:\.|$)/i,
function(m, cardName) {
var effect = m[1].toLowerCase();
if (/draw/.test(effect)) {
return 'T:Mode$ Attacks | Execute$ DrawCard | NumCards$ 1 | TriggerDescription$ Attack: Draw';
}
if (/deal/.test(effect)) {
var dmg = effect.match(/deals? (\d+)/);
return 'T:Mode$ Attacks | Execute$ DealDamage | NumDmg$ ' + (dmg ? dmg[1] : '1') + ' | TriggerDescription$ Attack: Damage';
}
if (/create/.test(effect)) {
return 'T:Mode$ Attacks | Execute$ CreateToken | TriggerDescription$ Attack: Create token';
}
return 'T:Mode$ Attacks | Execute$ Generic | TriggerDescription$ Attack: ' + effect.substring(0, 50);
}
);
// Upkeep trigger: "At the beginning of your upkeep, [effect]"
CardScriptRegistry.register(
/at the beginning of (?:your |each (?:player's )?)?upkeep,?\s*(.+?)(?:\.|$)/i,
function(m, cardName) {
var effect = m[1].toLowerCase();
if (/draw/.test(effect)) {
return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ DrawCard | NumCards$ 1 | TriggerDescription$ Upkeep: Draw';
}
if (/gain (\d+) life/.test(effect)) {
var life = effect.match(/gain (\d+)/);
return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ GainLife | LifeAmount$ ' + (life ? life[1] : '1') + ' | TriggerDescription$ Upkeep: Gain life';
}
if (/lose (\d+) life/.test(effect)) {
var life2 = effect.match(/lose (\d+)/);
return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ LoseLife | LifeAmount$ ' + (life2 ? life2[1] : '1') + ' | TriggerDescription$ Upkeep: Lose life';
}
return 'T:Mode$ Phase | Phase$ Upkeep | Execute$ Generic | TriggerDescription$ Upkeep: ' + effect.substring(0, 50);
}
);
// Static: "Other creatures you control get +N/+N"
CardScriptRegistry.register(
/(?:other )?creatures you control (?:get|have) \+(\d+)\/\+(\d+)/i,
function(m) {
return 'S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddPower$ ' + m[1] + ' | AddToughness$ ' + m[2] + ' | StaticDescription$ Anthem +' + m[1] + '/+' + m[2];
}
);
// Static: "Creatures you control have [keyword]"
CardScriptRegistry.register(
/creatures you control (?:have|gain) (\w[\w\s]*?)(?:\.|,|$)/i,
function(m) {
return 'S:Mode$ Continuous | Affected$ Creature.YouCtrl | AddKeyword$ ' + m[1].trim() + ' | StaticDescription$ Grant ' + m[1].trim();
}
);
// ============================================================
// CARD SCRIPT MANAGER â Bridges CardScript with game engine
// ============================================================
var CardScriptManager = {
// Cache: cardName -> SpellAbility[]
_cache: {},
// Get SpellAbility objects for a card (auto-generates from oracle text if no manual script)
getAbilities: function(cardData) {
var key = cardData.name;
if (this._cache[key]) return this._cache[key];
// First try the registry (auto-match oracle text)
var script = CardScriptRegistry.match(
cardData.oracleText || '',
cardData.typeLine || '',
cardData.name || ''
);
var abilities = [];
if (script) {
abilities = CardScriptParser.parse(
script,
cardData.name,
cardData.ownerIndex !== undefined ? cardData.ownerIndex : (cardData.ownerId || 0),
cardData.id
);
}
this._cache[key] = abilities;
return abilities;
},
// Clear cache (call when cards change)
clearCache: function() {
this._cache = {};
},
// Get just the keyword abilities
getKeywords: function(cardData) {
return this.getAbilities(cardData).filter(function(sa) {
return sa.abilityType === 'keyword';
}).map(function(sa) {
return sa.keyword;
});
},
// Get triggered abilities matching an event type
getTriggersForEvent: function(cardData, eventType) {
return this.getAbilities(cardData).filter(function(sa) {
return sa.abilityType === 'triggered' && sa.trigger && sa.trigger.event === eventType;
});
},
// Get all activated abilities
getActivatedAbilities: function(cardData) {
return this.getAbilities(cardData).filter(function(sa) {
return sa.abilityType === 'activated';
});
},
// Get the spell effect (for instants/sorceries)
getSpellAbility: function(cardData) {
var spells = this.getAbilities(cardData).filter(function(sa) {
return sa.abilityType === 'spell';
});
return spells.length > 0 ? spells[0] : null;
},
// Get static abilities
getStaticAbilities: function(cardData) {
return this.getAbilities(cardData).filter(function(sa) {
return sa.abilityType === 'static';
});
},
// Count total abilities (for AI scoring)
countAbilities: function(cardData) {
var abs = this.getAbilities(cardData);
return {
total: abs.length,
keywords: abs.filter(function(a) { return a.abilityType === 'keyword'; }).length,
activated: abs.filter(function(a) { return a.abilityType === 'activated'; }).length,
triggered: abs.filter(function(a) { return a.abilityType === 'triggered'; }).length,
static: abs.filter(function(a) { return a.abilityType === 'static'; }).length,
mana: abs.filter(function(a) { return a.api === 'ManaAdd'; }).length
};
},
// AI: Score a card's abilities for strategic evaluation
scoreAbilities: function(cardData, weights) {
var w = weights || (typeof aiWeights !== 'undefined' ? aiWeights : {});
var abs = this.getAbilities(cardData);
var score = 0;
for (var i = 0; i < abs.length; i++) {
var sa = abs[i];
switch (sa.abilityType) {
case 'keyword':
var kwMap = {
'flying': w.kw_flying || 2.5, 'haste': w.kw_haste || 2.5,
'deathtouch': w.kw_deathtouch || 2.5, 'double strike': w.kw_doubleStrike || 3,
'first strike': w.kw_firstStrike || 1.5, 'lifelink': w.kw_lifelink || 1.5,
'trample': w.kw_trample || 1.5, 'hexproof': w.kw_hexproof || 2,
'indestructible': w.kw_indestructible || 3, 'menace': w.kw_menace || 1.5,
'vigilance': w.kw_vigilance || 1, 'reach': w.kw_reach || 0.5,
'infect': w.kw_infect || 3, 'flash': w.kw_flash || 1,
'defender': w.kw_defender || -2, 'cascade': w.kw_cascade || 3,
};
score += kwMap[sa.keyword] || (w.kw_default || 0.5);
break;
case 'triggered':
if (sa.trigger) {
if (sa.trigger.event === 'etb') score += w.trig_etb || 2;
else if (sa.trigger.event === 'attack') score += w.trig_attack || 1.5;
else if (sa.trigger.event === 'dies') score += w.trig_dies || 1;
else score += w.trig_other || 0.5;
}
// Bonus for automated effects
if (sa.api && CardScriptResolvers[sa.api]) score += 0.5;
break;
case 'activated':
score += w.activated_bonus || 1;
if (sa.api === 'DrawCard') score += 1.5; // Card advantage is premium
if (sa.api === 'DealDamage') score += 1;
break;
case 'static':
score += 2; // Static abilities are always active
if (sa.effect.addPower > 0 || sa.effect.addToughness > 0) score += 1;
if (sa.effect.addKeyword) score += 1;
break;
case 'spell':
if (sa.api === 'DestroyPermanent' || sa.api === 'ExilePermanent') score += w.spell_destroy || 5;
if (sa.api === 'DrawCard') score += w.spell_draw || 3;
if (sa.api === 'CounterSpell') score += w.spell_counter || 4;
if (sa.api === 'DestroyAll') score += 7; // Board wipes are high value
if (sa.api === 'Ramp') score += w.spell_ramp || 6;
if (sa.api === 'CreateToken') score += 2;
break;
}
}
return score;
}
};
// ============================================================
// AI GAME STATE EVALUATOR â Forge-Inspired Board Analysis
// ============================================================
var AIBoardAnalyzer = {
// Comprehensive board state evaluation
// Returns a numeric score: positive = favorable, negative = unfavorable
evaluateBoardState: function(playerIdx) {
if (!gameState) return 0;
var w = (typeof aiWeights !== 'undefined') ? aiWeights : {};
var me = gameState.players[playerIdx];
if (!me || me.eliminated) return -1000;
var score = 0;
// 1. Material advantage (card count on board)
var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; });
var enemyCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId !== playerIdx; });
var myCreatures = myCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); });
var enemyCreatures = enemyCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); });
score += myCreatures.length * 3;
score -= enemyCreatures.length * 2;
score += (myCards.length - myCreatures.length) * 1.5; // non-creature permanents
// 2. Power projection (total power on board)
var myPower = 0, myToughness = 0;
myCreatures.forEach(function(c) {
var p = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0);
var t = (typeof getCreatureToughness === 'function') ? getCreatureToughness(c) : (parseInt(c.toughness) || 0);
myPower += p;
myToughness += t;
});
var enemyPower = 0, enemyToughness = 0;
enemyCreatures.forEach(function(c) {
var p = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0);
var t = (typeof getCreatureToughness === 'function') ? getCreatureToughness(c) : (parseInt(c.toughness) || 0);
enemyPower += p;
enemyToughness += t;
});
score += (myPower - enemyPower) * 1.5;
score += (myToughness - enemyToughness) * 0.5;
// 3. Evasion advantage
var myFlyers = myCreatures.filter(function(c) { return typeof hasKeyword === 'function' && hasKeyword(c, 'flying'); });
var enemyFlyers = enemyCreatures.filter(function(c) { return typeof hasKeyword === 'function' && hasKeyword(c, 'flying'); });
score += myFlyers.length * 2;
score -= enemyFlyers.length * 1.5;
// 4. Card advantage (hand size)
var myHand = me.zones.hand ? me.zones.hand.length : 0;
var avgEnemyHand = 0;
var enemyCount = 0;
gameState.players.forEach(function(p, i) {
if (i !== playerIdx && !p.eliminated) {
avgEnemyHand += (p.zones.hand ? p.zones.hand.length : 0);
enemyCount++;
}
});
avgEnemyHand = enemyCount > 0 ? avgEnemyHand / enemyCount : 0;
score += (myHand - avgEnemyHand) * 2;
// 5. Life advantage
var startLife = gameState.startingLife || 40;
score += (me.life / startLife) * 5;
gameState.players.forEach(function(p, i) {
if (i !== playerIdx && !p.eliminated) {
score -= (p.life / startLife) * 2;
}
});
// 6. Mana advantage (land count)
var myLands = myCards.filter(function(c) { return c.typeLine && /land/i.test(c.typeLine); });
var maxEnemyLands = 0;
gameState.players.forEach(function(p, i) {
if (i !== playerIdx && !p.eliminated) {
var their = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === i && c.typeLine && /land/i.test(c.typeLine);
});
if (their.length > maxEnemyLands) maxEnemyLands = their.length;
}
});
score += (myLands.length - maxEnemyLands) * 1;
// 7. Ability quality bonus (using CardScript system)
myCards.forEach(function(c) {
score += CardScriptManager.scoreAbilities(c) * 0.3;
});
// 8. Commander threat
var cmdOnField = myCards.some(function(c) { return c.isCommander; });
if (cmdOnField) score += 4;
return Math.round(score * 10) / 10;
},
// Detect synergies between cards on the battlefield
detectSynergies: function(playerIdx) {
if (!gameState) return [];
var synergies = [];
var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; });
// Anthem detection: static abilities that buff other creatures
var anthems = [];
myCards.forEach(function(c) {
var statics = CardScriptManager.getStaticAbilities(c);
statics.forEach(function(sa) {
if (sa.effect.addPower > 0 || sa.effect.addToughness > 0 || sa.effect.addKeyword) {
anthems.push({ source: c, ability: sa });
}
});
});
if (anthems.length > 0) {
var creatureCount = myCards.filter(function(c) { return c.typeLine && /creature/i.test(c.typeLine); }).length;
synergies.push({
type: 'anthem',
value: anthems.length * creatureCount * 2,
description: anthems.length + ' anthem effect' + (anthems.length !== 1 ? 's' : '') + ' buffing ' + creatureCount + ' creatures'
});
}
// Token synergy: token generators + anthem/lords
var hasTokenGen = myCards.some(function(c) {
var triggers = CardScriptManager.getTriggersForEvent(c, 'etb');
return triggers.some(function(t) { return t.api === 'CreateToken'; });
});
if (hasTokenGen && anthems.length > 0) {
synergies.push({
type: 'token_anthem',
value: 5,
description: 'Token generation + anthem synergy'
});
}
// Sacrifice synergy: dies triggers + sacrifice outlets
var hasDiesTrigger = myCards.some(function(c) {
return CardScriptManager.getTriggersForEvent(c, 'dies').length > 0 ||
(c.triggers && c.triggers.some(function(t) { return t.type === 'dies'; }));
});
var hasSacOutlet = myCards.some(function(c) {
var activated = CardScriptManager.getActivatedAbilities(c);
return activated.some(function(a) { return a.cost && a.cost.sacrifice; });
});
if (hasDiesTrigger && hasSacOutlet) {
synergies.push({
type: 'sacrifice',
value: 4,
description: 'Dies triggers + sacrifice outlet synergy'
});
}
// Card draw engine: multiple draw triggers
var drawEngines = myCards.filter(function(c) {
var triggers = CardScriptManager.getTriggersForEvent(c, 'draw');
return triggers.length > 0;
});
if (drawEngines.length >= 2) {
synergies.push({
type: 'draw_engine',
value: 6,
description: 'Card draw engine: ' + drawEngines.length + ' draw triggers'
});
}
// Evasion suite: flying + pump/buff
var flyerCount = myCards.filter(function(c) {
return typeof hasKeyword === 'function' && hasKeyword(c, 'flying');
}).length;
if (flyerCount >= 3) {
synergies.push({
type: 'evasion_swarm',
value: flyerCount * 1.5,
description: 'Evasion swarm: ' + flyerCount + ' flyers'
});
}
// Lifegain synergy: life gain triggers + life gain sources
var hasLifegainTrigger = myCards.some(function(c) {
return CardScriptManager.getTriggersForEvent(c, 'life_gain').length > 0;
});
var hasLifelink = myCards.some(function(c) {
return typeof hasKeyword === 'function' && hasKeyword(c, 'lifelink');
});
if (hasLifegainTrigger && hasLifelink) {
synergies.push({
type: 'lifegain',
value: 3,
description: 'Lifegain synergy: life gain triggers + lifelink'
});
}
return synergies;
},
// Detect the archetype a player's board most closely represents
detectArchetype: function(playerIdx) {
if (!gameState) return { archetype: 'unknown', confidence: 0 };
var myCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === playerIdx; });
var player = gameState.players[playerIdx];
if (!player) return { archetype: 'unknown', confidence: 0 };
var scores = {
aggro: 0,
midrange: 0,
control: 0,
combo: 0,
ramp: 0,
tokens: 0
};
var creatureCount = 0;
var lowCmcCreatures = 0;
var removalCount = 0;
var drawCount = 0;
var landCount = 0;
var tokenCount = 0;
var totalPower = 0;
myCards.forEach(function(c) {
var cmc = c.cmc || 0;
var isCreature = c.typeLine && /creature/i.test(c.typeLine);
var isLand = c.typeLine && /land/i.test(c.typeLine);
if (isCreature) {
creatureCount++;
if (cmc <= 3) lowCmcCreatures++;
var pow = (typeof getCreaturePower === 'function') ? getCreaturePower(c) : (parseInt(c.power) || 0);
totalPower += pow;
if (typeof hasKeyword === 'function') {
if (hasKeyword(c, 'haste')) scores.aggro += 2;
if (hasKeyword(c, 'flying') || hasKeyword(c, 'menace')) scores.aggro += 1;
}
}
if (isLand) landCount++;
if (c.isToken) tokenCount++;
// Check card oracle for role
var text = (c.oracleText || '').toLowerCase();
if (/destroy|exile/.test(text) && /target/.test(text)) removalCount++;
if (/draw/.test(text)) drawCount++;
if (/counter target spell/.test(text)) scores.control += 3;
if (/search your library for.*land/.test(text)) scores.ramp += 3;
if (/create.*token/.test(text)) scores.tokens += 2;
});
// Hand analysis
var hand = player.zones.hand || [];
hand.forEach(function(c) {
var text = (c.oracleText || '').toLowerCase();
if (/counter target spell/.test(text)) scores.control += 2;
if (/destroy|exile/.test(text)) removalCount++;
});
// Aggro: many cheap creatures, high total power, few lands
scores.aggro += lowCmcCreatures * 2;
scores.aggro += Math.min(totalPower / 3, 8);
if (creatureCount >= 4) scores.aggro += 3;
// Control: removal, draw, few creatures
scores.control += removalCount * 2;
scores.control += drawCount * 1.5;
if (creatureCount <= 2) scores.control += 3;
// Midrange: balanced mix
if (creatureCount >= 2 && creatureCount <= 4 && removalCount >= 1) scores.midrange += 4;
if (drawCount >= 1 && totalPower >= 6) scores.midrange += 3;
// Ramp: many lands
if (landCount >= 6) scores.ramp += 3;
if (landCount >= 8) scores.ramp += 3;
// Tokens: many tokens
scores.tokens += tokenCount * 2;
// Combo: synergy-based (detected separately)
var synergies = this.detectSynergies(playerIdx);
var synergyValue = 0;
synergies.forEach(function(s) { synergyValue += s.value; });
if (synergyValue >= 8) scores.combo += 5;
if (synergyValue >= 12) scores.combo += 5;
// Find highest score
var bestArch = 'midrange';
var bestScore = 0;
for (var arch in scores) {
if (scores[arch] > bestScore) {
bestScore = scores[arch];
bestArch = arch;
}
}
return {
archetype: bestArch,
confidence: Math.min(Math.round(bestScore / 15 * 100), 100),
scores: scores,
synergies: synergies
};
},
// Get strategic recommendations for the AI
getStrategicAdvice: function(playerIdx) {
if (!gameState) return [];
var advice = [];
var boardScore = this.evaluateBoardState(playerIdx);
var archetype = this.detectArchetype(playerIdx);
var synergies = archetype.synergies || [];
var player = gameState.players[playerIdx];
if (!player) return advice;
// Board position advice
if (boardScore > 15) {
advice.push({ type: 'position', priority: 'high', text: 'Strong board position. Press advantage, protect key pieces.' });
} else if (boardScore < -10) {
advice.push({ type: 'position', priority: 'high', text: 'Behind on board. Prioritize removal and board wipes.' });
} else {
advice.push({ type: 'position', priority: 'medium', text: 'Board is roughly even. Play for incremental advantage.' });
}
// Archetype-specific advice
switch (archetype.archetype) {
case 'aggro':
advice.push({ type: 'strategy', priority: 'high', text: 'Aggro: Maximize damage, attack every turn, finish off weak players.' });
break;
case 'control':
advice.push({ type: 'strategy', priority: 'high', text: 'Control: Hold removal for biggest threats, draw cards, avoid overextending.' });
break;
case 'ramp':
advice.push({ type: 'strategy', priority: 'high', text: 'Ramp: Prioritize land drops and mana rocks, play expensive threats ASAP.' });
break;
case 'tokens':
advice.push({ type: 'strategy', priority: 'high', text: 'Tokens: Protect anthem effects, go wide, beware board wipes.' });
break;
case 'combo':
advice.push({ type: 'strategy', priority: 'high', text: 'Combo potential detected. Protect key synergy pieces.' });
break;
}
// Synergy advice
if (synergies.length > 0) {
var synText = synergies.map(function(s) { return s.description; }).join('; ');
advice.push({ type: 'synergy', priority: 'medium', text: 'Active synergies: ' + synText });
}
// Threat assessment
if (typeof aiEvaluatePlayerThreats === 'function') {
var threats = aiEvaluatePlayerThreats(playerIdx);
if (threats.length > 0 && threats[0].threatScore > 20) {
advice.push({ type: 'threat', priority: 'high', text: 'High threat: ' + threats[0].name + ' (score: ' + Math.round(threats[0].threatScore) + '). Focus removal/attacks there.' });
}
}
// Life total advice
if (player.life <= 15) {
advice.push({ type: 'survival', priority: 'high', text: 'Low life total (' + player.life + '). Prioritize defense and lifegain.' });
}
return advice;
}
};
// ============================================================
// INTEGRATION: Connect CardScript system to AI scoring
// ============================================================
// Enhanced AI card scorer that uses CardScript system
function aiScoreCardEnhanced(card, difficulty, w) {
w = w || (typeof aiWeights !== 'undefined' ? aiWeights : {});
// Start with the base score from the original system
var score = (typeof aiScoreCard === 'function') ? aiScoreCard(card, difficulty, w) : 10;
// Add CardScript-based scoring
var csScore = CardScriptManager.scoreAbilities(card, w);
score += csScore * 0.25; // Weighted blend
// Synergy bonus: if this card synergizes with what's already on board
if (gameState) {
var playerIdx = card.ownerId !== undefined ? card.ownerId : gameState.currentPlayerIndex;
var currentSynergies = AIBoardAnalyzer.detectSynergies(playerIdx);
var currentSynergyValue = 0;
currentSynergies.forEach(function(s) { currentSynergyValue += s.value; });
// Check if playing this card would improve synergies
var cardAbilities = CardScriptManager.getAbilities(card);
var hasAnthem = cardAbilities.some(function(a) { return a.abilityType === 'static' && (a.effect.addPower > 0 || a.effect.addKeyword); });
var hasTokenGen = cardAbilities.some(function(a) { return a.api === 'CreateToken'; });
var hasDiesTrigger = cardAbilities.some(function(a) { return a.abilityType === 'triggered' && a.trigger && a.trigger.event === 'dies'; });
if (hasAnthem) {
var creaturesOnBoard = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx && c.typeLine && /creature/i.test(c.typeLine);
}).length;
score += creaturesOnBoard * 0.5; // Anthem is worth more with more creatures
}
if (hasTokenGen && currentSynergies.some(function(s) { return s.type === 'anthem'; })) {
score += 3; // Token gen + existing anthem = synergy
}
if (hasDiesTrigger) {
var hasSacOnBoard = gameState.battlefieldCards.some(function(c) {
if (c.ownerId !== playerIdx) return false;
var activated = CardScriptManager.getActivatedAbilities(c);
return activated.some(function(a) { return a.cost && a.cost.sacrifice; });
});
if (hasSacOnBoard) score += 3; // Dies trigger + sac outlet = synergy
}
}
// Difficulty adjustment
if (difficulty === 'hard') {
// Hard mode: additional strategic adjustments
if (card.isRamp && gameState && gameState.turnNumber <= 4) score += 3; // Early game ramp premium
if (card.isRemoval && gameState) {
var threats = (typeof aiEvaluateBoardThreats === 'function') ? aiEvaluateBoardThreats(gameState.currentPlayerIndex) : [];
if (threats.length > 0 && threats[0].score > 8) score += 4; // Removal premium when threats exist
}
}
return score;
}
// ============================================================
// AI WEIGHT VECTOR â Tunable parameters for self-play training
// ============================================================
var AI_DEFAULT_WEIGHTS = {
// --- Card Scoring (aiScoreCard) ---
card_baseCmcPenalty: 1, // multiplier on cmc subtracted from base 10
card_ptBonus: 1, // multiplier on power+toughness
// Keyword weights for creatures
kw_flying: 2.5,
kw_haste: 2.5,
kw_deathtouch: 2.5,
kw_doubleStrike: 3,
kw_firstStrike: 1.5,
kw_lifelink: 1.5,
kw_trample: 1.5,
kw_hexproof: 2,
kw_indestructible: 3,
kw_menace: 1.5,
kw_vigilance: 1,
kw_reach: 0.5,
kw_infect: 3,
kw_toxic: 2,
kw_annihilator: 4,
kw_cascade: 3,
kw_undying: 2,
kw_persist: 2,
kw_prowess: 1.5,
kw_ward: 1.5,
kw_flash: 1,
kw_defender: -2,
kw_default: 0.5,
// Triggered ability bonuses
trig_etb: 2,
trig_attack: 1.5,
trig_dies: 1,
trig_other: 0.5,
// Activated ability bonus per ability
activated_bonus: 1,
// Non-creature spell weights
spell_destroy: 5,
spell_draw: 3,
spell_counter: 4,
spell_graveyard: 2,
spell_search: 2,
spell_ramp: 6,
spell_equipment: 4,
spell_enchantment: 3,
spell_flash: 2,
spell_cascade: 3,
// --- Board Threat Eval (aiEvaluateBoardThreats) ---
bt_flying: 3,
bt_deathtouch: 4,
bt_lifelink: 2,
bt_firstStrike: 2,
bt_trample: 2,
bt_hexproof: 3,
bt_indestructible: 5,
bt_commander: 4,
// --- Player Threat Eval (aiEvaluatePlayerThreats) ---
pt_creatureWeight: 3,
pt_nonCreatureWeight: 1.5,
pt_flyingBonus: 1,
pt_trampleBonus: 1,
pt_indestructBonus: 3,
pt_hexproofBonus: 2,
pt_cardAdvMultiplier: 2,
pt_lifeDeltaScale: 8,
pt_cmdOnField: 6,
pt_cmdDmgHigh: 5,
pt_cmdDmgMid: 3,
pt_momentumCap: 5,
// --- Attack Evaluation (aiEvaluateAttack) ---
// (boolean logic, not easily weighted - kept for completeness)
atk_riskTradeThreshold: 0, // willingness to trade (0=avoid, positive=accept)
// --- Removal Targeting (aiFindRemovalTarget) ---
rem_leaderBoost: 5,
rem_commanderBoost: 6,
rem_lifeThreatFactor: 0.3, // fraction of life that triggers bonus
rem_lifeThreatBonus: 4,
// --- Board Wipe Decision ---
bw_creatureAdvantage: 2, // enemy must have this many more creatures
bw_bigThreatPower: 6, // power threshold for "big threat"
// --- Combat: attack target selection ---
atk_nearDeathLifeThreshold: 15,
atk_bigBoardThreshold: 8,
atk_cmdThreatThreshold: 6,
atk_cardAdvThreshold: 3,
atk_lifeThreshold: 3,
// --- Combat: blocker holdback ---
blk_holdbackToughness: 3,
// --- Sequencing ---
seq_bigThreatOverride: 10, // board threat score to override removal timing
// --- Bluffing ---
bluff_normalChance: 0.5,
bluff_hardBluffChance: 0.6,
bluff_hardReserve: 2,
// --- Stack Response ---
stk_boardWipeAlwaysCounter: true,
stk_bigSpellCmc: 5,
stk_creatureThreshold: 4,
stk_removalCounterChance: 0.6,
stk_leaderCmcThreshold: 2,
stk_hardCounterCmc: 3,
// --- Difficulty noise ---
diff_easyNoise: 8,
diff_normalNoise: 3,
};
// Active AI weights (can be overridden by training)
var aiWeights = JSON.parse(JSON.stringify(AI_DEFAULT_WEIGHTS));
// Load saved weights from local storage
(function() {
try {
var saved = window['local' + 'Storage'].getItem('mtg_ai_weights');
if (saved) {
var parsed = JSON.parse(saved);
for (var k in parsed) {
if (parsed.hasOwnProperty(k) && aiWeights.hasOwnProperty(k)) {
aiWeights[k] = parsed[k];
}
}
}
} catch(e) { /* ignore */ }
})();
function saveAiWeights() {
try {
window['local' + 'Storage'].setItem('mtg_ai_weights', JSON.stringify(aiWeights));
} catch(e) { /* ignore */ }
}
function resetAiWeights() {
aiWeights = JSON.parse(JSON.stringify(AI_DEFAULT_WEIGHTS));
try { window['local' + 'Storage'].removeItem('mtg_ai_weights'); } catch(e) {}
}
// Score a card for AI priority
function aiScoreCard(card, difficulty, w) {
w = w || aiWeights;
var cmc = aiGetCmc(card);
var score = 10 - cmc * w.card_baseCmcPenalty;
var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || '');
if (aiIsCreature(card)) {
if (card.pt) {
var parts = card.pt.split('/');
var power = parseInt(parts[0]) || 0;
var tough = parseInt(parts[1]) || 0;
score += (power + tough) * w.card_ptBonus;
}
var kwMap = {
'flying': w.kw_flying, 'haste': w.kw_haste, 'deathtouch': w.kw_deathtouch,
'double strike': w.kw_doubleStrike, 'first strike': w.kw_firstStrike,
'lifelink': w.kw_lifelink, 'trample': w.kw_trample, 'hexproof': w.kw_hexproof,
'indestructible': w.kw_indestructible, 'menace': w.kw_menace, 'vigilance': w.kw_vigilance,
'reach': w.kw_reach, 'infect': w.kw_infect, 'toxic': w.kw_toxic,
'annihilator': w.kw_annihilator, 'cascade': w.kw_cascade, 'undying': w.kw_undying,
'persist': w.kw_persist, 'prowess': w.kw_prowess, 'ward': w.kw_ward,
'flash': w.kw_flash, 'defender': w.kw_defender,
};
for (var ki = 0; ki < abilities.keywords.length; ki++) {
score += kwMap[abilities.keywords[ki].name] || w.kw_default;
}
for (var ti = 0; ti < abilities.triggered.length; ti++) {
var st = abilities.triggered[ti].subType;
if (st === 'etb') score += w.trig_etb;
else if (st === 'attack') score += w.trig_attack;
else if (st === 'dies') score += w.trig_dies;
else score += w.trig_other;
}
score += abilities.activated.length * w.activated_bonus;
} else {
var text = (card.oracleText || '').toLowerCase();
var tl = (card.typeLine || '').toLowerCase();
if (text.includes('destroy') || text.includes('exile')) score += w.spell_destroy;
if (text.includes('draw')) score += w.spell_draw;
if (text.includes('counter target')) score += w.spell_counter;
if (text.includes('return') && text.includes('graveyard')) score += w.spell_graveyard;
if (text.includes('search your library')) score += w.spell_search;
if (card.isRamp) score += w.spell_ramp;
if (tl.includes('equipment')) score += w.spell_equipment;
if (tl.includes('enchantment') && !tl.includes('creature')) score += w.spell_enchantment;
for (var ki2 = 0; ki2 < abilities.keywords.length; ki2++) {
if (abilities.keywords[ki2].name === 'flash') score += w.spell_flash;
if (abilities.keywords[ki2].name === 'cascade') score += w.spell_cascade;
}
}
if (difficulty === 'easy') score += Math.random() * w.diff_easyNoise;
if (difficulty === 'normal') score += Math.random() * w.diff_normalNoise;
return score;
}
// Generate a simple SVG card image for AI cards
function generateAiCardSVG(card) {
const colors = {
mono_white: { bg: '#F9FAF4', border: '#F0E68C', text: '#333' },
mono_blue: { bg: '#E8F0FE', border: '#4A90D9', text: '#1a3a5c' },
mono_black: { bg: '#2A2A2A', border: '#555', text: '#ddd' },
mono_red: { bg: '#FDE8E8', border: '#D32F2F', text: '#5a1a1a' },
mono_green: { bg: '#E8F5E9', border: '#388E3C', text: '#1a3a1a' },
};
const style = colors.mono_white; // Default
const isCreature = card.typeLine?.toLowerCase().includes('creature');
const isLand = card.typeLine?.toLowerCase().includes('land');
const name = (card.name || 'Unknown').substring(0, 24);
const type = (card.typeLine || '').substring(0, 30);
const pt = card.pt || '';
const svg = `
${name}
${card.cmc ? `{${card.cmc}} ` : ''}
${type}
${(card.oracleText || '').substring(0, 120)}
${isCreature && pt ? `${pt} ` : ''}
AI
`;
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
}
// ============================================================
// AI STRATEGY & DECISION-MAKING HELPERS
// ============================================================
// ---- AI Helper: Evaluate board threat level (per-card) ----
function aiEvaluateBoardThreats(playerIdx, w) {
w = w || aiWeights;
var threats = [];
if (!gameState) return threats;
gameState.battlefieldCards.forEach(function(c) {
if (c.ownerId === playerIdx) return;
if (!aiIsCreature(c)) return;
var pow = getCreaturePower(c);
var tou = getCreatureToughness(c);
var score = pow + tou;
if (hasKeyword(c, 'flying')) score += w.bt_flying;
if (hasKeyword(c, 'deathtouch')) score += w.bt_deathtouch;
if (hasKeyword(c, 'lifelink')) score += w.bt_lifelink;
if (hasKeyword(c, 'double strike')) score += pow;
if (hasKeyword(c, 'first strike')) score += w.bt_firstStrike;
if (hasKeyword(c, 'trample')) score += w.bt_trample;
if (hasKeyword(c, 'hexproof')) score += w.bt_hexproof;
if (hasKeyword(c, 'indestructible')) score += w.bt_indestructible;
if (c.isCommander) score += w.bt_commander;
threats.push({ card: c, score: score });
});
threats.sort(function(a, b) { return b.score - a.score; });
return threats;
}
// ---- AI Helper: Evaluate per-PLAYER threat level (political awareness) ----
// Returns sorted array: [{ idx, name, threatScore, breakdown }] highest threat first
function aiEvaluatePlayerThreats(playerIdx, w) {
w = w || aiWeights;
if (!gameState) return [];
var playerScores = [];
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
var breakdown = {
boardPresence: 0,
creaturePower: 0,
cardAdvantage: 0,
lifeDelta: 0,
commanderThreat: 0,
momentum: 0
};
// --- Board presence: count permanents, weight creatures more ---
var theirCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === i; });
var theirCreatures = theirCards.filter(function(c) { return aiIsCreature(c); });
var theirNonCreatures = theirCards.length - theirCreatures.length;
breakdown.boardPresence = theirCreatures.length * w.pt_creatureWeight + theirNonCreatures * w.pt_nonCreatureWeight;
// --- Creature power: sum total power on board ---
var totalPower = 0;
theirCreatures.forEach(function(c) {
var pow = getCreaturePower(c);
totalPower += pow;
// Extra danger for evasive creatures
if (hasKeyword(c, 'flying')) totalPower += w.pt_flyingBonus;
if (hasKeyword(c, 'trample')) totalPower += w.pt_trampleBonus;
if (hasKeyword(c, 'double strike')) totalPower += pow;
if (hasKeyword(c, 'indestructible')) totalPower += w.pt_indestructBonus;
if (hasKeyword(c, 'hexproof')) totalPower += w.pt_hexproofBonus;
});
breakdown.creaturePower = totalPower;
// --- Card advantage: hand size relative to average ---
var handSize = p.zones.hand ? p.zones.hand.length : 0;
var avgHand = 0;
var activeCount = 0;
gameState.players.forEach(function(pp, pi) {
if (pi === playerIdx || pp.eliminated) return;
avgHand += pp.zones.hand ? pp.zones.hand.length : 0;
activeCount++;
});
avgHand = activeCount > 0 ? avgHand / activeCount : 3;
breakdown.cardAdvantage = (handSize - avgHand) * w.pt_cardAdvMultiplier;
// --- Life delta: higher life = more threat (they can afford risky plays) ---
// Also factor in how close to killing opponents they are
var startLife = gameState.startingLife || 40;
var lifeRatio = p.life / startLife;
breakdown.lifeDelta = (lifeRatio - 0.5) * w.pt_lifeDeltaScale; // 0 at half life, +4 at full, -4 at 0
// --- Commander threat: commander on battlefield is dangerous ---
var cmdOnField = theirCards.some(function(c) { return c.isCommander; });
var cmdDamage = 0;
// Check if this player has dealt commander damage to us
if (p.commanderDamageDealt) {
cmdDamage = p.commanderDamageDealt[playerIdx] || 0;
}
breakdown.commanderThreat = (cmdOnField ? w.pt_cmdOnField : 0) + (cmdDamage > 10 ? w.pt_cmdDmgHigh : cmdDamage > 5 ? w.pt_cmdDmgMid : 0);
// --- Momentum: recently-cast spells suggest active turn ---
// Use graveyard size as a proxy for spells cast
var gySize = p.zones.graveyard ? p.zones.graveyard.length : 0;
breakdown.momentum = Math.min(gySize, w.pt_momentumCap); // Cap at 5
var totalThreat = breakdown.boardPresence + breakdown.creaturePower +
breakdown.cardAdvantage + breakdown.lifeDelta +
breakdown.commanderThreat + breakdown.momentum;
playerScores.push({
idx: i,
name: p.name,
threatScore: totalThreat,
breakdown: breakdown
});
});
// Sort highest threat first
playerScores.sort(function(a, b) { return b.threatScore - a.threatScore; });
return playerScores;
}
// ---- AI Helper: Check if AI can afford a card (CMC vs untapped lands) ----
function aiCanAfford(card, playerIdx) {
var cmc = aiGetCmc(card);
var untapped = aiCountUntappedLands(playerIdx);
return untapped >= cmc;
}
// ---- AI Helper: Pick best attack target (political â attack the leader) ----
function aiPickAttackTarget(playerIdx) {
var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy';
// Easy mode: pick lowest life (old behavior)
if (difficulty === 'easy') {
var bestIdx = -1;
var lowestLife = Infinity;
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
if (p.life < lowestLife) {
lowestLife = p.life;
bestIdx = i;
}
});
return bestIdx;
}
// Normal/Hard: use threat assessment to attack the leader
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length === 0) return -1;
var leader = playerThreats[0]; // Highest threat player
// Hard mode: consider if a low-life player is close to dying (finish them off)
if (difficulty === 'hard') {
var nearDeath = playerThreats.find(function(pt) {
var p = gameState.players[pt.idx];
if (!p) return false;
// Can we lethal them this turn?
var ourPower = 0;
gameState.battlefieldCards.forEach(function(c) {
if (c.ownerId === playerIdx && aiIsCreature(c) && !c.tapped && !hasSummoningSickness(c)) {
ourPower += getCreaturePower(c);
}
});
return p.life <= ourPower && p.life <= 15;
});
if (nearDeath) {
aiAddThought(playerIdx, 'Going for the kill on ' + nearDeath.name + ' (' + gameState.players[nearDeath.idx].life + ' life)');
return nearDeath.idx;
}
}
// Log the political decision
if (leader.threatScore > 0) {
var bd = leader.breakdown;
var reasons = [];
if (bd.creaturePower >= 8) reasons.push('big board');
if (bd.commanderThreat >= 6) reasons.push('commander out');
if (bd.cardAdvantage >= 3) reasons.push('card advantage');
if (bd.lifeDelta >= 3) reasons.push('high life');
if (reasons.length > 0) {
aiAddThought(playerIdx, 'Targeting ' + leader.name + ' (leader: ' + reasons.join(', ') + ')');
}
}
return leader.idx;
}
// ---- AI Helper: Evaluate if an attack is favorable ----
function aiEvaluateAttack(attacker, defenderIdx) {
var pow = getCreaturePower(attacker);
var tou = getCreatureToughness(attacker);
// Count potential blockers
var blockers = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === defenderIdx && aiIsCreature(c) && !c.tapped;
});
// If no blockers, always good to attack
if (blockers.length === 0) return { shouldAttack: true, risk: 'none' };
// If attacker has flying and no defender has flying/reach, safe
if (hasKeyword(attacker, 'flying')) {
var canBlockFlying = blockers.some(function(b) { return hasKeyword(b, 'flying') || hasKeyword(b, 'reach'); });
if (!canBlockFlying) return { shouldAttack: true, risk: 'none' };
}
// If attacker has menace and <2 untapped creatures, safe
if (hasKeyword(attacker, 'menace') && blockers.length < 2) {
return { shouldAttack: true, risk: 'none' };
}
// Check if any blocker can kill us
var canDie = blockers.some(function(b) {
var bPow = getCreaturePower(b);
return (bPow >= tou) || hasKeyword(b, 'deathtouch');
});
// Check if we can kill any blocker
var canKill = blockers.some(function(b) {
var bTou = getCreatureToughness(b);
return pow >= bTou || hasKeyword(attacker, 'deathtouch');
});
if (!canDie) return { shouldAttack: true, risk: 'low' };
if (canDie && canKill) return { shouldAttack: true, risk: 'trade' };
if (canDie && !canKill) return { shouldAttack: false, risk: 'bad-trade' };
return { shouldAttack: true, risk: 'medium' };
}
// ---- AI Helper: Find best removal target (threat-aware, prioritize leader's cards) ----
function aiFindRemovalTarget(playerIdx, w) {
w = w || aiWeights;
var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy';
var threats = aiEvaluateBoardThreats(playerIdx);
if (threats.length === 0) return null;
// Easy mode: just pick highest-threat card regardless of owner
if (difficulty === 'easy') {
for (var i = 0; i < threats.length; i++) {
var t = threats[i];
if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) continue;
return t.card;
}
return null;
}
// Normal/Hard: weight removal toward the biggest threat PLAYER's cards
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length === 0) {
// Fallback: just pick highest individual threat
for (var j = 0; j < threats.length; j++) {
if (hasKeyword(threats[j].card, 'hexproof') || hasKeyword(threats[j].card, 'shroud')) continue;
return threats[j].card;
}
return null;
}
var leaderIdx = playerThreats[0].idx;
// Score each threat card, boosting cards owned by the table leader
var scoredThreats = threats.map(function(t) {
var adjustedScore = t.score;
// Boost cards from the leading player
if (t.card.ownerId === leaderIdx) adjustedScore += w.rem_leaderBoost;
// Extra boost for commanders (removing commander is huge tempo swing)
if (t.card.isCommander) adjustedScore += w.rem_commanderBoost;
// Penalty for hexproof/shroud (untargetable)
if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) adjustedScore = -100;
// Hard mode: also consider if removing this creature prevents lethal
if (difficulty === 'hard') {
var ownerPow = getCreaturePower(t.card);
var myLife = gameState.players[playerIdx] ? gameState.players[playerIdx].life : 40;
if (ownerPow >= myLife * w.rem_lifeThreatFactor) adjustedScore += w.rem_lifeThreatBonus; // Threatens significant life
}
return { card: t.card, score: adjustedScore };
});
scoredThreats.sort(function(a, b) { return b.score - a.score; });
for (var k = 0; k < scoredThreats.length; k++) {
if (scoredThreats[k].score > 0) {
var target = scoredThreats[k].card;
aiAddThought(playerIdx, 'Removal target: ' + target.name + ' (' + (gameState.players[target.ownerId] ? gameState.players[target.ownerId].name : '?') + ')');
return target;
}
}
return null;
}
// ---- AI Helper: Should the AI use a board wipe? ----
function aiShouldBoardWipe(playerIdx, w) {
w = w || aiWeights;
var myCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx && aiIsCreature(c);
});
var enemyCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId !== playerIdx && aiIsCreature(c);
});
// Wipe if enemies have significantly more creatures than us
if (enemyCreatures.length >= myCreatures.length + w.bw_creatureAdvantage) return true;
// Or if any single enemy creature is very threatening
var bigThreat = enemyCreatures.some(function(c) {
return getCreaturePower(c) >= w.bw_bigThreatPower || hasKeyword(c, 'indestructible');
});
if (bigThreat && enemyCreatures.length > myCreatures.length) return true;
return false;
}
// ---- AI Helper: Cast commander from command zone ----
function aiTryCastCommander(playerIdx) {
if (!gameState) return false;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return false;
var cmdZone = player.zones.command;
if (!cmdZone || cmdZone.length === 0) return false;
var cmdName = player.commanderCardName || '';
if (!cmdName) return false;
var cmdIdx = -1;
for (var i = 0; i < cmdZone.length; i++) {
if (cmdZone[i].name === cmdName) { cmdIdx = i; break; }
}
if (cmdIdx === -1) return false;
var cardData = cmdZone[cmdIdx];
var baseCmc = cardData.cmc || 0;
var tax = getCommanderTax(playerIdx);
var totalCost = baseCmc + tax;
var available = aiCountUntappedLands(playerIdx);
if (available < totalCost) return false;
// Generate image if needed
if (!cardData.imageUrl) cardData.imageUrl = generateAiCardSVG(cardData);
// Use confirmCastCommander logic but adapted for AI
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
var castCard = cmdZone.splice(cmdIdx, 1)[0];
// Tap lands to pay total cost (base + tax)
var manaToPay = totalCost;
var lands = aiCountLands(playerIdx).filter(function(c) { return !c.tapped; });
for (var li = 0; li < lands.length; li++) {
if (manaToPay <= 0) break;
toggleTap(lands[li].id);
manaToPay--;
}
addCardToBattlefield(castCard.name, castCard.imageUrl || '',
castCard.typeLine || '', playerIdx, undefined, undefined,
castCard.oracleText || '',
{ manaCost: castCard.manaCost || '', pt: castCard.pt, cmc: castCard.cmc, isCommander: true });
addLogEntry('\u{1F916} \u{1F451} ' + player.name + ' cast ' + cmdName + ' from command zone (tax: +' + tax + ')');
fireBattlefieldTriggers('commander_cast');
fireBattlefieldTriggers('cast');
updatePlayerZones();
renderPlayerPanels();
return true;
}
// ---- AI Helper: Pick multiple attack targets for multiplayer ----
function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
if (!gameState) return [];
var opponents = [];
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
var theirCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === i && aiIsCreature(c) && !c.tapped;
});
opponents.push({
idx: i,
life: p.life,
blockerCount: theirCreatures.length,
hasFlyers: theirCreatures.some(function(c) { return hasKeyword(c, 'flying') || hasKeyword(c, 'reach'); }),
name: p.name
});
});
if (opponents.length === 0) return [];
// Use threat assessment for Normal/Hard to determine primary target
var primaryTarget;
if (difficulty === 'easy') {
// Easy: lowest life
opponents.sort(function(a, b) { return a.life - b.life; });
primaryTarget = opponents[0].idx;
} else {
// Normal/Hard: highest-threat player from political assessment
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length > 0) {
primaryTarget = playerThreats[0].idx;
} else {
opponents.sort(function(a, b) { return a.life - b.life; });
primaryTarget = opponents[0].idx;
}
}
var assignments = []; // { card, targetIdx }
if (difficulty === 'easy' || opponents.length === 1) {
// Easy/single opponent: everything at one target
creatures.forEach(function(c) {
assignments.push({ card: c, targetIdx: primaryTarget });
});
return assignments;
}
// Normal/Hard: smart multi-target allocation
// Sort opponents by threat for secondary targeting
var opponentsByThreat = opponents.slice();
var ptMap = {};
var pt2 = aiEvaluatePlayerThreats(playerIdx);
pt2.forEach(function(p) { ptMap[p.idx] = p.threatScore; });
opponentsByThreat.sort(function(a, b) { return (ptMap[b.idx] || 0) - (ptMap[a.idx] || 0); });
creatures.forEach(function(c) {
var assigned = false;
// Flying creatures -> target player with no flying/reach blockers
if (hasKeyword(c, 'flying')) {
var noFlyerDefender = opponentsByThreat.find(function(op) { return !op.hasFlyers && op.blockerCount > 0; });
if (noFlyerDefender) {
assignments.push({ card: c, targetIdx: noFlyerDefender.idx });
assigned = true;
}
}
if (!assigned) {
// Hard: if primary target is well-defended, spread to unblocked opponents
if (difficulty === 'hard' && opponents.length >= 3) {
var unblocked = opponentsByThreat.find(function(op) {
return op.idx !== primaryTarget && op.blockerCount === 0;
});
if (unblocked) {
assignments.push({ card: c, targetIdx: unblocked.idx });
assigned = true;
}
}
if (!assigned) {
assignments.push({ card: c, targetIdx: primaryTarget });
}
}
});
return assignments;
}
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return null;
for (var i = 0; i < player.zones.hand.length; i++) {
var card = player.zones.hand[i];
var text = (card.oracleText || '').toLowerCase();
if (text.indexOf('counter target') !== -1) {
// Check if can afford it
var cmc = aiGetCmc(card);
var available = aiCountUntappedLands(playerIdx);
if (available >= cmc) {
return { card: card, handIndex: i };
}
}
}
return null;
}
// ---- AI Stack Response: AI reacts to spells on the stack ----
function aiRespondToStack() {
if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return;
if (!gameState.rulesEngine || !gameState.rulesEngine.useStack) return;
var topSpell = gameState.theStack[gameState.theStack.length - 1];
if (!topSpell) return;
// Check each AI player for responses
gameState.players.forEach(function(player, idx) {
if (!player.isAI || player.eliminated) return;
if (idx === topSpell.ownerIndex) return; // Don't counter own spells
var difficulty = gameState.aiDifficulty || 'easy';
// Easy AI doesn't respond to stack
if (difficulty === 'easy') return;
var counterInfo = aiCheckForCounterspell(idx);
if (!counterInfo) return;
// Evaluate if the spell is worth countering
var spellText = (topSpell.oracleText || '').toLowerCase();
var spellType = (topSpell.typeLine || '').toLowerCase();
var shouldCounter = false;
// Always counter board wipes
if (spellText.indexOf('destroy all') !== -1 || spellText.indexOf('each creature') !== -1) {
shouldCounter = true;
}
// Counter big threats (CMC >= 5)
if (topSpell.cmc >= 5 || (spellType.indexOf('creature') !== -1 && topSpell.cmc >= 4)) {
shouldCounter = true;
}
// Counter removal targeting AI's stuff (heuristic)
if (spellText.indexOf('destroy target') !== -1 || spellText.indexOf('exile target') !== -1) {
if (difficulty === 'hard') shouldCounter = true;
else shouldCounter = Math.random() > 0.4;
}
// Threat-aware: more likely to counter spells from the leading player
var playerThreats = aiEvaluatePlayerThreats(idx);
var casterIsLeader = playerThreats.length > 0 && playerThreats[0].idx === topSpell.ownerIndex;
if (casterIsLeader && topSpell.cmc >= 2) {
shouldCounter = true; // Counter leader's spells more aggressively
}
// Hard mode: counter anything that costs 3+ from any player
if (difficulty === 'hard' && topSpell.cmc >= 3) shouldCounter = true;
if (!shouldCounter) return;
// Cast the counterspell
var card = counterInfo.card;
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
// Tap lands to pay
var manaToPay = aiGetCmc(card);
var lands = aiCountLands(idx).filter(function(c) { return !c.tapped; });
for (var li = 0; li < lands.length; li++) {
if (manaToPay <= 0) break;
toggleTap(lands[li].id);
manaToPay--;
}
// Remove from hand
player.zones.hand.splice(counterInfo.handIndex, 1);
// Counter the spell (remove from stack)
var countered = gameState.theStack.pop();
// Put countered spell in owner's graveyard
var spellOwner = gameState.players[countered.ownerIndex];
if (spellOwner) {
spellOwner.zones.graveyard.push({
name: countered.name,
imageUrl: countered.imageUrl || '',
typeLine: countered.typeLine || '',
oracleText: countered.oracleText || '',
manaCost: countered.manaCost || ''
});
}
// Put counterspell in AI's graveyard
player.zones.graveyard.push(card);
addLogEntry('\u{1F916} \u{1F6AB} ' + player.name + ' counters ' + countered.name + ' with ' + card.name + ' !');
aiAddThought(idx, 'Countered ' + countered.name + ' (threat level: high)');
renderStackPanel();
renderHandArea();
renderPlayerPanels();
});
}
// ---- AI Thinking Log: visible decision tracking ----
var aiThoughts = []; // Array of { playerIdx, playerName, thought, timestamp }
function aiAddThought(playerIdx, thought) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player) return;
aiThoughts.push({
playerIdx: playerIdx,
playerName: player.name,
thought: thought,
timestamp: Date.now()
});
// Keep max 50
if (aiThoughts.length > 50) aiThoughts.shift();
renderAiThoughtBubble(playerIdx, thought);
}
function renderAiThoughtBubble(playerIdx, thought) {
// Show a floating thought bubble near the player's panel
var panelEl = document.getElementById('player-panel-' + playerIdx);
if (!panelEl) return;
var existing = panelEl.querySelector('.ai-thought-bubble');
if (existing) existing.remove();
var bubble = document.createElement('div');
bubble.className = 'ai-thought-bubble';
bubble.innerHTML = '\u{1F4AD} ' + thought;
panelEl.appendChild(bubble);
// Auto-remove after 3 seconds
setTimeout(function() {
if (bubble.parentNode) bubble.remove();
}, 3500);
}
// ============================================================
// AI BATTLEFIELD ABILITIES - Mana dorks, equipment usage
// ============================================================
/**
* AI taps mana-producing creatures (mana dorks) to generate extra mana.
* Called at the start of Main Phase 1 before casting spells.
* Returns the number of extra mana produced.
*/
function aiTapManaDorks(playerIdx) {
var player = gameState.players[playerIdx];
if (!player) return 0;
var produced = 0;
var dorks = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('creature') &&
!c.tapped &&
!hasSummoningSickness(c) &&
(c.isManaProducer || (c.oracleText && /\{T\}:.*[Aa]dd \{[WUBRGC]\}/i.test(c.oracleText)));
});
if (dorks.length === 0) return 0;
dorks.forEach(function(dork) {
// Determine what color of mana to produce
var manaColor = dork.manaAbility || 'G';
// Try to detect from oracle text
if (!dork.manaAbility && dork.oracleText) {
var manaMatch = dork.oracleText.match(/[Aa]dd \{([WUBRGC])\}/);
if (manaMatch) manaColor = manaMatch[1];
}
// Tap the dork
dork.tapped = true;
var el = document.getElementById('bc-' + dork.id);
if (el) el.classList.add('tapped');
// Add mana to pool directly
var p = gameState.players[playerIdx];
if (p && p.manaPool) {
p.manaPool[manaColor] = (p.manaPool[manaColor] || 0) + 1;
}
produced++;
});
if (produced > 0) {
addLogEntry('\u{1F916} ' + player.name + ' tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : '') + ' for mana');
aiAddThought(playerIdx, 'Tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : ''));
}
return produced;
}
/**
* AI equips equipment to its best unequipped creature.
* Looks for equipment on the battlefield and attaches to best candidate.
*/
function aiEquipCreatures(playerIdx) {
var player = gameState.players[playerIdx];
if (!player) return;
// Find unattached equipment the AI controls
var equipment = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('equipment') &&
!c.attachedTo;
});
if (equipment.length === 0) return;
// Find best creature to equip (prefer attackers, biggest creatures)
var creatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('creature') &&
!c.typeLine.toLowerCase().includes('land');
});
if (creatures.length === 0) return;
// Sort by power descending - equip the biggest threats
creatures.sort(function(a, b) {
return getCreaturePower(b) - getCreaturePower(a);
});
equipment.forEach(function(equip) {
// Find equip cost from oracle text
var equipCostMatch = equip.oracleText ? equip.oracleText.match(/[Ee]quip \{(\d+)\}/) : null;
var equipCost = equipCostMatch ? parseInt(equipCostMatch[1]) : 2;
// Special case: Equip {0}
if (equip.oracleText && /[Ee]quip \{0\}/.test(equip.oracleText)) equipCost = 0;
var available = aiCountUntappedLands(playerIdx);
if (available < equipCost) return;
// Pick the best creature that doesn't already have this equipment
var target = creatures.find(function(c) {
var attachments = c.attachments || [];
return !attachments.includes(equip.id);
});
if (!target) return;
// Pay equip cost by tapping lands
if (equipCost > 0) {
var lands = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx && c.typeLine && c.typeLine.toLowerCase().includes('land') && !c.tapped;
});
for (var li = 0; li < equipCost && li < lands.length; li++) {
lands[li].tapped = true;
var landEl = document.getElementById('bc-' + lands[li].id);
if (landEl) landEl.classList.add('tapped');
}
}
// Attach equipment
equip.attachedTo = target.id;
if (!target.attachments) target.attachments = [];
target.attachments.push(equip.id);
addLogEntry('\u{1F916} ' + player.name + ' equipped ' + equip.name + ' to ' + target.name + ' ');
aiAddThought(playerIdx, 'Equipped ' + equip.name + ' to ' + target.name);
renderBattlefield();
});
}
// ============================================================
// AI SPELL TIMING & SEQUENCING
// ============================================================
/**
* Sort and categorize the AI's hand into play sequencing buckets.
* Returns { ramp: [], removal: [], threats: [], utility: [], instants: [] }
* Each entry has { card, index, score, cmc }
*/
function aiCategorizeHand(playerIdx, difficulty) {
var player = gameState.players[playerIdx];
if (!player) return { ramp: [], removal: [], threats: [], utility: [], instants: [] };
var result = { ramp: [], removal: [], threats: [], utility: [], instants: [] };
player.zones.hand.forEach(function(c, i) {
if (aiIsLand(c)) return;
var entry = { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)), cmc: aiGetCmc(c) };
var tl = (c.typeLine || '').toLowerCase();
var isInstant = tl.includes('instant');
if (c.isRamp) {
result.ramp.push(entry);
} else if (c.isRemoval) {
if (isInstant) result.instants.push(entry);
else result.removal.push(entry);
} else if (c.isBoardWipe) {
result.removal.push(entry);
} else if (isInstant) {
result.instants.push(entry);
} else if (tl.includes('creature')) {
result.threats.push(entry);
} else {
result.utility.push(entry);
}
});
// Sort each bucket by score descending
var sortFn = function(a, b) { return b.score - a.score; };
result.ramp.sort(sortFn);
result.removal.sort(sortFn);
result.threats.sort(sortFn);
result.utility.sort(sortFn);
result.instants.sort(sortFn);
return result;
}
/**
* Build the AI's play sequence for Main Phase 1.
* Hard mode: ramp first, then threats. Hold removal + instants for post-combat.
* Normal mode: ramp first, then best cards by score.
* Easy mode: random order.
*/
function aiBuildPlaySequence(playerIdx, difficulty) {
var categories = aiCategorizeHand(playerIdx, difficulty);
var sequence = [];
if (difficulty === 'easy') {
// Easy: just play whatever, no sequencing intelligence
var all = [].concat(categories.ramp, categories.removal, categories.threats, categories.utility, categories.instants);
// Shuffle
for (var i = all.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
}
return { mainPhase1: all, holdForPostCombat: [] };
}
if (difficulty === 'normal') {
// Normal: ramp first, then everything else by score
sequence = [].concat(categories.ramp);
var rest = [].concat(categories.threats, categories.removal, categories.utility, categories.instants);
rest.sort(function(a, b) { return b.score - a.score; });
sequence = sequence.concat(rest);
return { mainPhase1: sequence, holdForPostCombat: [] };
}
// Hard mode: sophisticated sequencing
// Phase 1: Ramp spells first, then equipment/enchantments, then threats
var phase1 = [].concat(categories.ramp);
// Add utility (equipment, enchantments) early
phase1 = phase1.concat(categories.utility);
// Add threats
phase1 = phase1.concat(categories.threats);
// Hold back: sorcery-speed removal (play post-combat to see if combat solves the problem)
// Hold back: instants (save for opponent's turn if stack is enabled)
var held = [];
// In hard mode, hold instant-speed removal and counterspells for post-combat or responses
held = held.concat(categories.instants);
// Sorcery removal goes post-combat in hard mode
held = held.concat(categories.removal);
return { mainPhase1: phase1, holdForPostCombat: held };
}
// --- AI post-combat continuation (used after human blocker pause) ---
function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postActions = [];
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
if (difficulty !== 'easy') {
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
if (difficulty === 'hard' || difficulty === 'normal') {
var instantsInHand = player.zones.hand.filter(function(c) {
var tl = (c.typeLine || '').toLowerCase();
return tl.indexOf('instant') !== -1;
});
var flashCards = player.zones.hand.filter(function(c) {
var text = (c.oracleText || '').toLowerCase();
return text.indexOf('flash') !== -1;
});
var bluffCandidates = instantsInHand.concat(flashCards);
if (bluffCandidates.length > 0) {
var cheapestInstant = bluffCandidates.reduce(function(best, c) {
var cmc = aiGetCmc(c);
return cmc < aiGetCmc(best) ? c : best;
}, bluffCandidates[0]);
bluffReserve = aiGetCmc(cheapestInstant);
bluffCardName = cheapestInstant.name;
if (difficulty === 'normal' && Math.random() > 0.5) bluffReserve = 0;
}
if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) {
bluffReserve = 2;
bluffCardName = '';
}
}
if (heldCards && heldCards.length > 0) {
var heldPlayed = 0;
for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) {
var he = heldCards[hi];
if (bluffReserve > 0 && he.card.name === bluffCardName) continue;
var hci = player.zones.hand.indexOf(he.card);
if (hci === -1) continue;
var havailable = aiCountUntappedLands(playerIdx);
var hcmc = aiGetCmc(he.card);
if (havailable - hcmc < bluffReserve) continue;
if (havailable < hcmc) continue;
if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card);
playCardFromHand(playerIdx, hci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)');
heldPlayed++;
}
}
var remainingHand = player.zones.hand
.map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; })
.filter(function(e) { return !aiIsLand(e.card); })
.sort(function(a, b) { return b.score - a.score; });
for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) {
var re = remainingHand[ri];
if (bluffReserve > 0 && re.card.name === bluffCardName) continue;
var ci = player.zones.hand.indexOf(re.card);
if (ci === -1) continue;
var available = aiCountUntappedLands(playerIdx);
var cardCmc = aiGetCmc(re.card);
if (available - cardCmc < bluffReserve) continue;
if (available < cardCmc) continue;
if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card);
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)');
}
if (bluffReserve > 0) {
var untappedAfter = aiCountUntappedLands(playerIdx);
if (untappedAfter >= bluffReserve) {
if (bluffCardName) {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')');
} else {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)');
}
addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...');
}
}
}});
postDelay += 800;
}
// END TURN
postActions.push({ delay: postDelay, fn: function() {
addLogEntry('\u{1F916} ' + player.name + ' passes the turn');
aiTurnActive = false;
nextTurn();
}});
postActions.forEach(function(action) {
setTimeout(function() {
try { action.fn(); } catch(e) { console.error('[AI Post-Combat Error]', e); }
}, action.delay);
});
}
function runAiTurn() {
if (!gameState) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return;
// ========================================
// FORGE ENGINE HYBRID: If Forge is connected & enabled, delegate AI turn
// ========================================
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) {
// Forge handled the turn successfully
addLogEntry('\u{1F916} ' + player.name + ' passes the turn [Forge AI]');
aiTurnActive = false;
renderPlayerPanels();
renderHandArea();
nextTurn();
} else {
// Forge failed â fall back to local AI
addLogEntry('\u{1F916} [Forge] Falling back to local AI for ' + player.name);
aiTurnActive = false;
_runLocalAiTurn();
}
});
return;
}
// ========================================
// LOCAL AI: Standard heuristic-based AI turn
// ========================================
_runLocalAiTurn();
}
function _runLocalAiTurn() {
if (!gameState) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return;
aiTurnActive = true;
player.aiLandPlayedThisTurn = false;
var difficulty = gameState.aiDifficulty || 'easy';
addLogEntry('\u{1F916} ' + player.name + ' is thinking...');
aiAddThought(playerIdx, difficulty.charAt(0).toUpperCase() + difficulty.slice(1) + ' mode \u2014 evaluating options...');
// Log threat assessment for Normal/Hard
if (difficulty !== 'easy') {
var threatReport = aiEvaluatePlayerThreats(playerIdx);
if (threatReport.length > 0) {
var topThreat = threatReport[0];
var threatSummary = 'Threat assessment: ' + threatReport.map(function(t) {
return t.name + ' (' + Math.round(t.threatScore) + ')';
}).join(' > ');
aiAddThought(playerIdx, threatSummary);
}
// Forge-inspired board analysis
if (typeof AIBoardAnalyzer !== 'undefined') {
var boardScore = AIBoardAnalyzer.evaluateBoardState(playerIdx);
var archInfo = AIBoardAnalyzer.detectArchetype(playerIdx);
aiAddThought(playerIdx, 'Board eval: ' + boardScore + ' | Archetype: ' + archInfo.archetype + ' (' + archInfo.confidence + '% conf)');
// Log active synergies
var synergies = archInfo.synergies || [];
if (synergies.length > 0) {
aiAddThought(playerIdx, 'Synergies: ' + synergies.map(function(s) { return s.description; }).join(', '));
}
// Strategic advice for hard mode
if (difficulty === 'hard') {
var advice = AIBoardAnalyzer.getStrategicAdvice(playerIdx);
var highPriority = advice.filter(function(a) { return a.priority === 'high'; });
if (highPriority.length > 0) {
aiAddThought(playerIdx, 'Strategy: ' + highPriority[0].text);
}
}
}
}
var actions = []; // queue of { delay, fn }
var delay = 600;
// ========================================
// MAIN PHASE 1: Play a land via playCardFromHand()
// ========================================
var landInHand = player.zones.hand.findIndex(function(c) { return aiIsLand(c); });
if (landInHand !== -1) {
actions.push({ delay: delay, fn: function() {
// Ensure card has imageUrl for battlefield rendering
var landCard = player.zones.hand[landInHand];
if (landCard && !landCard.imageUrl) landCard.imageUrl = generateAiCardSVG(landCard);
// Use playCardFromHand to integrate with mana system, triggers, land-drop tracking
playCardFromHand(playerIdx, landInHand);
addLogEntry('\u{1F916} ' + player.name + ' played a land');
}});
delay += 800;
}
// ========================================
// MAIN PHASE 1: Try to cast commander from command zone
// ========================================
var cmdZone = player.zones.command || [];
if (cmdZone.length > 0) {
actions.push({ delay: delay, fn: function() {
var result = aiTryCastCommander(playerIdx);
if (result) {
var tax = getCommanderTax(playerIdx);
aiAddThought(playerIdx, 'Cast commander! (tax: ' + (tax > 0 ? '+' + (tax - 2) : '0') + ')');
} else {
var needed = (cmdZone[0] ? cmdZone[0].cmc || 0 : 0) + getCommanderTax(playerIdx);
var have = aiCountUntappedLands(playerIdx);
if (have < needed) {
aiAddThought(playerIdx, 'Need ' + needed + ' mana for commander, have ' + have);
}
}
}});
delay += 800;
}
// ========================================
// MAIN PHASE 1: Tap mana dorks for extra mana (before casting)
// ========================================
actions.push({ delay: delay, fn: function() {
aiTapManaDorks(playerIdx);
}});
delay += 400;
// ========================================
// MAIN PHASE 1: Cast spells from hand using playCardFromHand()
// Uses smart sequencing: ramp > threats > hold removal for post-combat
// ========================================
var playSequence = aiBuildPlaySequence(playerIdx, difficulty);
var phase1Cards = playSequence.mainPhase1;
var heldCards = playSequence.holdForPostCombat;
// Also check board threats for emergency overrides
var boardThreats = aiEvaluateBoardThreats(playerIdx);
var biggestThreat = boardThreats.length > 0 ? boardThreats[0] : null;
// If there's a massive threat, override and play removal in Main Phase 1
if (biggestThreat && biggestThreat.score >= 10 && difficulty === 'hard') {
// Move removal from held back to phase1
phase1Cards = phase1Cards.concat(heldCards.filter(function(e) { return e.card.isRemoval || e.card.isBoardWipe; }));
heldCards = heldCards.filter(function(e) { return !e.card.isRemoval && !e.card.isBoardWipe; });
aiAddThought(playerIdx, 'Critical threat detected - using removal now!');
}
// Boost board wipe scores if significantly behind
if (aiShouldBoardWipe(playerIdx)) {
phase1Cards.forEach(function(e) { if (e.card.isBoardWipe) e.score += 30; });
phase1Cards.sort(function(a, b) { return b.score - a.score; });
}
// Decide how many spells to play based on difficulty
var maxSpells = difficulty === 'easy' ? 1 : difficulty === 'normal' ? 3 : 5;
var spellsQueued = 0;
for (var si = 0; si < phase1Cards.length; si++) {
if (spellsQueued >= maxSpells) break;
var entry = phase1Cards[si];
var cardRef = entry.card;
var cmc = aiGetCmc(cardRef);
(function(theCardRef, theCmc) {
actions.push({ delay: delay, fn: function() {
// Re-find card in hand at action time (indices shift as cards are played)
var ci = player.zones.hand.indexOf(theCardRef);
if (ci === -1) return;
var available = aiCountUntappedLands(playerIdx);
if (available < theCmc) return;
// Ensure card has imageUrl and manaCost for rendering and mana payment
if (!theCardRef.imageUrl) theCardRef.imageUrl = generateAiCardSVG(theCardRef);
// Use playCardFromHand() which integrates with:
// - attemptManaPay() (auto-tap + mana spend)
// - Stack system (if useStack is enabled)
// - Triggers and battlefield placement
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + theCardRef.name + ' ');
aiAddThought(playerIdx, 'Casting ' + theCardRef.name + (theCardRef.isRemoval ? ' (removal!)' : theCardRef.isBoardWipe ? ' (board wipe!)' : ''));
}});
})(cardRef, cmc);
delay += 1000;
spellsQueued++;
}
// ========================================
// MAIN PHASE 1: Equip equipment to creatures
// ========================================
if (difficulty !== 'easy') {
actions.push({ delay: delay, fn: function() {
aiEquipCreatures(playerIdx);
}});
delay += 600;
}
// ========================================
// COMBAT PHASE: Smart attack decisions with multi-target
// ========================================
var shouldAttack = difficulty === 'easy' ? Math.random() > 0.3 : true;
if (shouldAttack) {
actions.push({ delay: delay, fn: function() {
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) {
aiAddThought(playerIdx, 'No creatures ready to attack');
return;
}
// Smart attacker selection based on difficulty
var attackerCards = [];
if (difficulty === 'easy') {
// Easy: random subset
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
// For evaluation, pick a primary target first
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
// Normal/Hard: evaluate each attack
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
// Hard mode: hold back 1-2 creatures as blockers
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
aiAddThought(playerIdx, 'Holding back ' + holdBack.name + ' to block');
}
}
}
if (attackerCards.length === 0) {
aiAddThought(playerIdx, 'No favorable attacks available');
return;
}
// Use multi-target attack allocation in multiplayer
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
// Set up combat state with potentially multiple targets
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
// Tap attackers (unless vigilance)
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
// Log attacks grouped by target
var targetGroups = {};
assignments.forEach(function(a) {
if (!targetGroups[a.targetIdx]) targetGroups[a.targetIdx] = [];
targetGroups[a.targetIdx].push(a.card.name);
});
var targetCount = Object.keys(targetGroups).length;
for (var tIdx in targetGroups) {
var targetName = gameState.players[tIdx] ? gameState.players[tIdx].name : '?';
var names = targetGroups[tIdx].join(', ');
addLogEntry('\u{1F916} \u2694 ' + player.name + ' attacks ' + targetName + ' with: ' + names);
}
if (targetCount > 1) {
aiAddThought(playerIdx, 'Splitting attacks across ' + targetCount + ' opponents');
} else {
aiAddThought(playerIdx, 'Attacking with ' + attackerCards.length + ' creature' + (attackerCards.length > 1 ? 's' : ''));
}
fireBattlefieldTriggers('attack');
}});
delay += 800;
// Let any AI defenders assign blockers, then check for human defenders
actions.push({ delay: delay, fn: function() {
if (gameState.combat.attackers.length === 0) return;
// AI defenders assign blockers first
aiAssignBlockers();
// Check if any human player is being attacked
var defenderSet = {};
gameState.combat.attackers.forEach(function(a) { defenderSet[a.targetPlayerId] = true; });
var humanDefended = false;
for (var dIdx in defenderSet) {
var def = gameState.players[dIdx];
if (def && !def.isAI && !def.eliminated) { humanDefended = true; break; }
}
if (humanDefended) {
// PAUSE: Show blocker UI for human player, store resume callback
gameState.combat.phase = 'declaring-blockers';
gameState.combat.pendingBlockerFor = null;
showCombatOverlay();
refreshCombatOverlay();
addLogEntry('\u{1F6E1} Declare your blockers!');
// Store the resume function â confirmBlockers will call it
_aiBlockerPauseState = {
playerIdx: playerIdx,
resumeFn: function() {
_aiBlockerPauseState = null;
// Continue with damage resolution
if (gameState.combat.attackers.length > 0) {
resolveCombatDamage();
setTimeout(function() {
endCombat();
renderPlayerPanels();
// Continue with Main Phase 2 and End Turn
_aiRunPostCombat(playerIdx, player, difficulty, heldCards);
}, 600);
} else {
_aiRunPostCombat(playerIdx, player, difficulty, heldCards);
}
}
};
// STOP the action queue here â remaining actions are handled by resumeFn
return '__PAUSE_AI_TURN__';
}
}});
delay += 600;
// Resolve combat damage (only runs if not paused for human blockers)
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
if (gameState.combat.attackers.length > 0) {
resolveCombatDamage();
setTimeout(function() {
endCombat();
renderPlayerPanels();
}, 600);
}
}});
delay += 1000;
}
// ========================================
// MAIN PHASE 2: Post-combat spell casting (normal & hard)
// Hard: play held-back removal + instants + any remaining affordable spells
// Normal: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
if (difficulty !== 'easy') {
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
var bluffReserve = 0;
var bluffCardName = '';
if (difficulty === 'hard' || difficulty === 'normal') {
// Check for instant-speed cards in hand we want to bluff with
var instantsInHand = player.zones.hand.filter(function(c) {
var tl = (c.typeLine || '').toLowerCase();
return tl.indexOf('instant') !== -1;
});
// Also check for cards with flash
var flashCards = player.zones.hand.filter(function(c) {
var text = (c.oracleText || '').toLowerCase();
return text.indexOf('flash') !== -1;
});
var bluffCandidates = instantsInHand.concat(flashCards);
if (bluffCandidates.length > 0) {
// Pick the cheapest instant to bluff with
var cheapestInstant = bluffCandidates.reduce(function(best, c) {
var cmc = aiGetCmc(c);
return cmc < aiGetCmc(best) ? c : best;
}, bluffCandidates[0]);
bluffReserve = aiGetCmc(cheapestInstant);
bluffCardName = cheapestInstant.name;
// Hard mode: always bluff if possible. Normal: 50% chance
if (difficulty === 'normal' && Math.random() > 0.5) {
bluffReserve = 0;
}
}
// Even without instants in hand, hard mode sometimes bluffs with 2 open mana
if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) {
bluffReserve = 2;
bluffCardName = ''; // Pure bluff, no actual card
}
}
// First: play held-back cards (hard mode sequencing)
if (heldCards && heldCards.length > 0) {
var heldPlayed = 0;
for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) {
var he = heldCards[hi];
// Don't play the card we're bluffing with
if (bluffReserve > 0 && he.card.name === bluffCardName) continue;
var hci = player.zones.hand.indexOf(he.card);
if (hci === -1) continue;
var havailable = aiCountUntappedLands(playerIdx);
var hcmc = aiGetCmc(he.card);
// Reserve mana for bluff
if (havailable - hcmc < bluffReserve) continue;
if (havailable < hcmc) continue;
if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card);
playCardFromHand(playerIdx, hci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)');
if (he.card.isRemoval) {
aiAddThought(playerIdx, 'Using held removal: ' + he.card.name);
}
heldPlayed++;
}
}
// Then: play any remaining affordable spells from hand (respecting bluff reserve)
var remainingHand = player.zones.hand
.map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; })
.filter(function(e) { return !aiIsLand(e.card); })
.sort(function(a, b) { return b.score - a.score; });
for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) {
var re = remainingHand[ri];
// Don't play bluff card
if (bluffReserve > 0 && re.card.name === bluffCardName) continue;
var ci = player.zones.hand.indexOf(re.card);
if (ci === -1) continue;
var available = aiCountUntappedLands(playerIdx);
var cardCmc = aiGetCmc(re.card);
// Reserve mana for bluff
if (available - cardCmc < bluffReserve) continue;
if (available < cardCmc) continue;
if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card);
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)');
}
// Log bluff status
if (bluffReserve > 0) {
var untappedAfter = aiCountUntappedLands(playerIdx);
if (untappedAfter >= bluffReserve) {
if (bluffCardName) {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')');
} else {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)');
}
addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...');
}
}
}});
delay += 800;
}
// ========================================
// END TURN
// ========================================
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
addLogEntry('\u{1F916} ' + player.name + ' passes the turn');
aiTurnActive = false;
nextTurn();
}});
// Execute queued actions with delays (wrapped in try/catch so aiTurnActive resets on error)
actions.forEach(function(action) {
setTimeout(function() {
try { action.fn(); } catch(e) { console.error('[AI Turn Error]', e); }
}, action.delay);
});
// Safety timeout: if aiTurnActive is still true after all actions should be done, force-reset it
var safetyDelay = delay + 5000;
setTimeout(function() {
if (aiTurnActive && !_aiBlockerPauseState) {
console.warn('[AI] Safety timeout - resetting aiTurnActive');
aiTurnActive = false;
}
}, safetyDelay);
}
// ============================================================
// ============================================================
// CARD ABILITIES & RULES TEXT PARSING ENGINE
// ============================================================
/**
* Parse all abilities from a card's oracle text.
* Returns a structured object with keyword, activated, triggered, static, and mana abilities.
*/
function parseCardAbilities(oracleText, typeLine, cardName) {
if (!oracleText && !typeLine) return { keywords: [], activated: [], triggered: [], static: [], mana: [], loyalty: [], replacement: [] };
const text = oracleText || '';
const type = (typeLine || '').toLowerCase();
const result = {
keywords: [],
activated: [],
triggered: [],
static: [],
mana: [],
loyalty: [],
replacement: [],
};
// --- 1. KEYWORD ABILITIES ---
const textLower = text.toLowerCase();
for (const [keyword, data] of Object.entries(KEYWORD_ABILITIES)) {
const kw = keyword.toLowerCase();
// Match keyword as standalone word or at start of a sentence
const re = new RegExp(`(?:^|[,;.\\n\\s])${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\b|\\s|,|\\.|$)`, 'i');
if (re.test(textLower) || re.test(',' + textLower)) {
result.keywords.push({
name: keyword,
icon: data.icon,
color: data.color,
description: data.desc,
});
}
}
// --- 2. ACTIVATED ABILITIES ---
// Pattern: "[cost]: [effect]" where cost contains mana symbols or {T}
const lines = text.split('\n');
for (const line of lines) {
// Activated abilities have a colon separating cost from effect
// Match patterns like "{2}{B}, {T}: Do something" or "{T}: Add {G}"
const activatedMatch = line.match(/^([^:"]+(?:\{[^}]+\})[^:]*?):\s*(.+)$/i);
if (activatedMatch) {
const costPart = activatedMatch[1].trim();
const effectPart = activatedMatch[2].trim();
// Verify it has a mana symbol or tap symbol in cost
if (/\{[WUBRGCTX0-9]+\}/.test(costPart) || /sacrifice|discard|pay|exile|remove|tap an untapped/i.test(costPart)) {
// Check if it's a mana ability (produces mana)
if (/add \{[WUBRGC]\}/i.test(effectPart) || /add one mana/i.test(effectPart)) {
result.mana.push({
cost: costPart,
effect: effectPart,
type: 'mana',
});
} else {
result.activated.push({
cost: costPart,
effect: effectPart,
type: 'activated',
});
}
}
}
}
// --- 3. TRIGGERED ABILITIES ---
const triggerPatterns = [
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+enters(?:\s+the\s+battlefield)?)/gi, sub: 'etb' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+leaves\s+the\s+battlefield)/gi, sub: 'ltb' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+dies)/gi, sub: 'dies' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+attacks)/gi, sub: 'attack' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+blocks)/gi, sub: 'block' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes?\s+(?:the\s+)?target)/gi, sub: 'targeted' },
{ re: /\b(when(?:ever)?\s+(?:you|a player)\s+(?:cast|draw|discard|gain|lose|pay))/gi, sub: 'action' },
{ re: /\b(at the beginning of (?:your|each|the)\s+(?:upkeep|end step|draw step|combat|main phase|untap step))/gi, sub: 'upkeep' },
{ re: /\b(when(?:ever)?\s+(?:a|another|one or more)\s+(?:creature|permanent|artifact|enchantment|land|nontoken))/gi, sub: 'permanent' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+deals\s+(?:combat\s+)?damage)/gi, sub: 'damage' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes\s+(?:tapped|untapped))/gi, sub: 'tap' },
];
for (const { re, sub } of triggerPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
// Grab the rest of the sentence for context
const startIdx = match.index;
let endIdx = text.indexOf('.', startIdx + match[0].length);
if (endIdx === -1) endIdx = text.indexOf('\n', startIdx + match[0].length);
if (endIdx === -1) endIdx = text.length;
const fullAbilityText = text.substring(startIdx, endIdx + 1).trim();
result.triggered.push({
trigger: match[1].trim(),
effect: fullAbilityText,
subType: sub,
type: 'triggered',
});
}
}
// --- 4. STATIC ABILITIES ---
const staticPatterns = [
{ re: /\b(other\s+(?:creatures?|permanents?)\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' },
{ re: /\b(creatures?\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' },
{ re: /\b((?:creatures?|spells?)\s+(?:you\s+cast\s+)?cost\s+\{?\d+\}?\s+(?:less|more))/gi, sub: 'cost-mod' },
{ re: /\b(you\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' },
{ re: /\b(players?\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' },
{ re: /\b((?:this|~|enchanted|equipped)\s+(?:creature|permanent)\s+(?:gets?|has|gains)\s+[^.]+)/gi, sub: 'buff' },
{ re: /\b((?:each|all)\s+(?:creatures?|opponents?|players?)\s+[^.]*(?:get|have|gain|lose|deal|take)[^.]+)/gi, sub: 'global' },
{ re: /\b((?:as long as|while)\s+[^,]+,\s+[^.]+)/gi, sub: 'conditional' },
];
for (const { re, sub } of staticPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
result.static.push({
text: match[1].trim(),
subType: sub,
type: 'static',
});
}
}
// --- 5. LOYALTY ABILITIES (Planeswalkers) ---
if (type.includes('planeswalker')) {
const loyaltyRe = /([+\-\u2212]\d+|0):\s*([^\n]+)/g;
let lm;
while ((lm = loyaltyRe.exec(text)) !== null) {
result.loyalty.push({
cost: lm[1].replace('\u2212', '-'),
effect: lm[2].trim(),
type: 'loyalty',
});
}
}
// --- 6. REPLACEMENT EFFECTS ---
const replacementPatterns = [
/\b(if .+ would .+, .+ instead[^.]*)/gi,
/\b(as .+ enters the battlefield[^.]*)/gi,
/\b(.+ enters the battlefield (?:tapped|with)[^.]*)/gi,
];
for (const re of replacementPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
result.replacement.push({
text: match[1].trim(),
type: 'replacement',
});
}
}
return result;
}
/**
* Get a compact badge set for battlefield card display.
* Returns max 4 most important keyword icons.
*/
function getKeywordBadges(cardData) {
if (!cardData || !cardData.oracleText) return [];
const abilities = parseCardAbilities(cardData.oracleText, cardData.typeLine, cardData.name);
// Priority order for display (combat-relevant first)
const priorityOrder = [
'flying', 'deathtouch', 'lifelink', 'first strike', 'double strike',
'trample', 'haste', 'vigilance', 'menace', 'hexproof', 'indestructible',
'reach', 'flash', 'infect', 'toxic', 'ward', 'defender', 'shroud',
'prowess', 'annihilator', 'cascade', 'undying', 'persist',
];
const badges = [];
for (const kw of priorityOrder) {
const found = abilities.keywords.find(k => k.name === kw);
if (found) badges.push(found);
if (badges.length >= 4) break;
}
// If less than 4, add remaining keywords not in priority list
if (badges.length < 4) {
for (const kw of abilities.keywords) {
if (!badges.find(b => b.name === kw.name)) {
badges.push(kw);
if (badges.length >= 4) break;
}
}
}
return badges;
}
/**
* Convert mana symbols in text to styled HTML spans.
*/
function formatManaSymbols(text) {
if (!text) return '';
return text.replace(/\{([^}]+)\}/g, (match) => {
const sym = MANA_DISPLAY_MAP[match];
if (sym) {
return `${sym.text} `;
}
return `${match.replace(/[{}]/g, '')} `;
});
}
/**
* Format oracle text with mana symbols, line breaks, and ability highlighting.
*/
function formatOracleText(text) {
if (!text) return 'Vanilla (no abilities) ';
const escaped = text.replace(//g, '>');
let formatted = formatManaSymbols(escaped);
// Add line breaks
formatted = formatted.replace(/\n/g, ' ');
// Highlight keyword abilities in bold
for (const kw of Object.keys(KEYWORD_ABILITIES)) {
const re = new RegExp(`\\b(${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi');
formatted = formatted.replace(re, '$1 ');
}
return formatted;
}
/**
* Build the full abilities detail panel HTML for a card.
*/
function buildAbilitiesPanel(cardData) {
const abilities = parseCardAbilities(cardData.oracleText || '', cardData.typeLine || '', cardData.name || '');
const sections = [];
// Card identity header
const isCreature = (cardData.typeLine || '').toLowerCase().includes('creature');
const pt = cardData.pt || (cardData.extraData && cardData.extraData.pt) || '';
let headerInfo = ``;
sections.push(headerInfo);
// Oracle text with formatted mana symbols
sections.push(`Rules Text
${formatOracleText(cardData.oracleText)}
`);
// Keywords
if (abilities.keywords.length > 0) {
let kwHtml = 'Keyword Abilities
';
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 = 'Activated Abilities
';
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 = 'Mana Abilities
';
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 = 'Triggered Abilities
';
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 = 'Static Abilities
';
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 = 'Loyalty Abilities
';
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 = 'Replacement Effects
';
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 = '';
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}
â
${gameState.players.map((p, i) => {
if (i === gameState.currentPlayerIndex || p.eliminated) return '';
return `${p.name} (${p.life}âĪ) `;
}).join('')}
${getOpponentPlaneswalkers().map(pw => {
const loyalty = pw.counters && pw.counters['loyalty'] != null ? pw.counters['loyalty'] : '?';
return `${escapeHtml(pw.name)} [âĶ${loyalty}] `;
}).join('')}
â
`;
}).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 += ``; // 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 += `\u2212 `;
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 += `
\u2764 Lifelink: ${ownerName} gains ${m.lifelinkGain} life
`;
}
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(' • ')
+ '\u2715 ';
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 += '
';
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 += '' + escapeHtml(ab.cost) + ' ';
html += '' + formatManaSymbols(ab.effect) + ' ';
if (!canAfford) {
html += '(not enough loyalty) ';
}
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 ? ' ' : '';
overlay.innerHTML =
'' +
'' +
'
' +
'
' +
imgHtml +
'
' +
'
' + escapeHtml(card.name) + ' ' + manaCostHtml + '
' +
'
' + escapeHtml(card.typeLine || '') + '
' +
oraclePreview +
targetHtml +
'
' +
'
' +
'
' +
'
' +
'\u2713 Allow (Resolve) ' +
'\u270b Respond ' +
'
' +
'
';
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: ' +
'\u2713 Let It Resolve ' +
'\u274c Counter (Fizzle) ';
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 Game
Load Game
`;
if (tab === 'save') {
// Save tab
html += `
`;
if (!hasGame) {
html += `
Start a game to save it.
`;
} else {
// Quick save with custom name
html += `
Save
${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 += `
`;
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 = `
âŧ Save
â `;
} else if (mode === 'load') {
actions = `
âķ Load
ðĪ
${!isAuto ? `â ` : ''}`;
}
return `
${isAuto ? 'âą ' : ''}${displayName}
${details}
`;
}
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) + '
';
}
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 ? '
' : '
ð·
')
+ '
'
+ ''
+ '
Set ' + escapeHtml(card.setName || card.set) + ' #' + escapeHtml(card.collectorNumber || '') + '
'
+ '
Type ' + escapeHtml(card.typeLine || '') + '
'
+ '
CMC ' + (card.cmc || 0) + '
'
+ '
Rarity ' + escapeHtml(card.rarity || '') + '
'
+ '
Condition ' + card.condition + '
'
+ '
Foil ' + (card.foil ? 'âĶ Yes' : 'No') + '
'
+ '
Quantity '
+ 'â '
+ ' ' + card.quantity + ' '
+ '+ '
+ '
'
+ '
Price $' + (priceNum > 0 ? priceNum.toFixed(2) : 'â') + '
'
+ '
Total Value $' + (priceNum > 0 ? totalVal : 'â') + '
'
+ '
Added ' + new Date(card.dateAdded).toLocaleDateString() + '
'
+ '
'
+ 'ð Remove '
+ 'Close '
+ '
'
+ '
';
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 ? '
' : '')
+ '
'
+ ''
+ '
Set ' + escapeHtml(card.setName || card.set || '') + '
'
+ '
Type ' + escapeHtml(card.typeLine || '') + '
'
+ '
Price $' + (price > 0 ? price.toFixed(2) : 'â') + '
'
+ '
Added ' + new Date(card.dateAdded).toLocaleDateString() + '
'
+ '
'
+ 'ðĶ Move to Collection '
+ 'ð Remove '
+ 'Close '
+ '
'
+ '
';
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 = 'Qty Name Set Condition Foil ';
cards.slice(0, 100).forEach(function(c) {
html += ''
+ '' + (c.quantity || 1) + ' '
+ '' + escapeHtml(c.name) + ' '
+ '' + escapeHtml((c.set || '').toUpperCase()) + ' '
+ '' + escapeHtml(c.condition || 'NM') + ' '
+ '' + (c.foil ? '\u2726' : '') + ' '
+ ' ';
});
if (cards.length > 100) {
html += '...and ' + (cards.length - 100) + ' more ';
}
html += '
';
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) + '
')
+ (inDeckQty > 0 ? '
Ã' + inDeckQty + '
' : '')
+ '
+ Add '
+ '
';
});
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.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 += ''
+ ''
+ '
';
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
'
+ '
'
+ 'Load '
+ 'Dupe '
+ 'Del '
+ '
'
+ '
';
});
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 Phase Space
' +
'
Next Turn N
' +
'
Pass Priority (when open) Space
' +
'
' +
'' +
'
Actions
' +
'
Untap All U
' +
'
Roll Dice / Flip Coin D
' +
'
Trigger Stack T
' +
'
Toggle Mana Pool M
' +
'
' +
'' +
'
Panels & Tools
' +
'
Save / Load Game S
' +
'
Keyword Reference K
' +
'
Keyboard Shortcuts ?
' +
'
Undo History H
' +
'
Close Any Panel Esc
' +
'
' +
'' +
'
Edit
' +
'
Undo Ctrl + Z
' +
'
Redo Ctrl + Y
' +
'
' +
'' +
'
Cards
' +
'
Tap / Untap Double-click card
' +
'
Context Menu Right-click card
' +
'
Move Card Click + 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 += '
';
}
// Controls
html += '';
if (!st.running) {
html += 'Train 50 gens ';
html += 'Train 200 gens ';
html += 'Train 500 gens ';
} else {
html += 'Stop Training ';
}
html += '
';
// Apply / Reset
if (st.bestWeights && !st.running) {
html += '';
html += 'Apply Trained Weights ';
html += 'Reset to Defaults ';
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 + '%)
' +
'
Cancel ' +
'
';
}
// --- 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 += '
' + wr + '% (' + m.wins + '-' + m.losses + ')
';
html += '
';
});
html += '
';
// --- Detailed Stats Table ---
html += '';
html += '
Performance Metrics (Averages per Game)
';
html += '
';
html += 'Matchup Turns Dmg Dealt Dmg Taken Spells Creatures Removal Ramp Cards Drawn Max Board ';
html += '';
r.matchups.forEach(function(m) {
html += '';
html += '' + escapeHtml(m.preconName) + ' ';
html += '' + m.avgTurns + ' ';
html += '' + m.avgDamageDealt + ' ';
html += '' + m.avgDamageReceived + ' ';
html += '' + m.avgSpellsCast + ' ';
html += '' + m.avgCreaturesPlayed + ' ';
html += '' + m.avgRemovalUsed + ' ';
html += '' + m.avgRampPlayed + ' ';
html += '' + m.avgCardsDrawn + ' ';
html += '' + m.avgBoardSize + ' ';
html += ' ';
});
html += '
';
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 += 'Re-run Test ';
html += 'Export Results ';
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 += 'Games per matchup: ';
html += ' ';
html += ' ';
html += '
';
// Precon selection
html += '';
// Run button
html += '';
html += 'Run Simulation ';
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) {}
}
}