';
// 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 += '
`;
// Right-click on life total to subtract more
const lifeEl = panel.querySelector(`#life-${player.id}`);
if (lifeEl) {
lifeEl.addEventListener('contextmenu', e => {
e.preventDefault();
showSetLifeDialog(player.id);
});
}
// Right-click on poison to remove
panel.querySelectorAll('.poison-counter').forEach(el => {
el.addEventListener('contextmenu', e => {
e.preventDefault();
adjustPoison(player.id, -1);
});
});
return panel;
}
// ============================================================
// LIFE TOTAL MANAGEMENT
// ============================================================
function adjustLife(playerId, amount) {
if (!gameState) return;
const player = gameState.players[playerId];
if (!player) return;
// Apply replacement effects to life gain/loss
if (amount > 0) {
var gainEvent = applyReplacements('gain_life', { playerIdx: playerId, amount: amount });
if (!gainEvent) return;
amount = gainEvent.amount;
}
setLife(playerId, player.life + amount);
}
function setLife(playerId, newLife) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[playerId];
if (!player) return;
const oldLife = player.life;
player.life = newLife;
// Animate the value
const lifeEl = document.getElementById(`life-${playerId}`);
if (lifeEl) {
lifeEl.textContent = newLife;
lifeEl.classList.remove('life-gain', 'life-loss');
lifeEl.offsetWidth; // Force reflow
if (newLife > oldLife) lifeEl.classList.add('life-gain');
else if (newLife < oldLife) lifeEl.classList.add('life-loss');
setTimeout(() => lifeEl.classList.remove('life-gain', 'life-loss'), 500);
}
// Panel flash
const panel = document.getElementById(`player-panel-${playerId}`);
if (panel) {
panel.classList.remove('life-gained', 'life-lost');
panel.offsetWidth;
if (newLife > oldLife) panel.classList.add('life-gained');
else if (newLife < oldLife) panel.classList.add('life-lost');
setTimeout(() => panel.classList.remove('life-gained', 'life-lost'), 700);
}
const diff = newLife - oldLife;
const sign = diff > 0 ? '+' : '';
addLogEntry(`${player.name} life: ${oldLife} â ${newLife} (${sign}${diff})`);
// Fire life gain/loss triggers
if (diff > 0) {
fireBattlefieldTriggers('life_gain');
} else if (diff < 0) {
fireBattlefieldTriggers('life_loss');
}
// Check for 0 or negative life
if (newLife <= 0 && !player.eliminated) {
eliminatePlayer(playerId, 'life total reached 0');
}
checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
// COMMANDER DAMAGE
// ============================================================
function adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[targetPlayerId];
if (!player) return;
const oldDmg = player.commanderDamage[sourcePlayerId] || 0;
const newDmg = Math.max(0, oldDmg + amount);
player.commanderDamage[sourcePlayerId] = newDmg;
const srcPlayer = gameState.players[sourcePlayerId];
const srcName = srcPlayer ? srcPlayer.name : 'Unknown';
if (amount > 0) {
addLogEntry(`${player.name} took ${amount} commander damage from ${srcName} (total: ${newDmg})`);
}
if (newDmg >= 21 && !player.eliminated) {
eliminatePlayer(targetPlayerId, `21 commander damage from ${srcName}`);
}
// Re-render the panel
const panelContainer = document.getElementById(`player-panel-${targetPlayerId}`);
if (panelContainer) {
const newPanel = createPlayerPanel(player, gameState.currentPlayerIndex === targetPlayerId);
panelContainer.replaceWith(newPanel);
}
checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
// POISON COUNTERS
// ============================================================
function adjustPoison(playerId, amount) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[playerId];
if (!player) return;
player.poison = Math.max(0, player.poison + amount);
const poisonEl = document.getElementById(`poison-${playerId}`);
if (poisonEl) poisonEl.textContent = player.poison;
if (amount !== 0) {
addLogEntry(`${player.name} has ${player.poison} poison counter${player.poison !== 1 ? 's' : ''}`);
}
if (player.poison >= 10 && !player.eliminated) {
eliminatePlayer(playerId, '10 poison counters');
}
// Update the counter styling
const panelContainer = document.getElementById(`player-panel-${playerId}`);
if (panelContainer) {
const newPanel = createPlayerPanel(player, gameState.currentPlayerIndex === playerId);
panelContainer.replaceWith(newPanel);
}
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
// SET LIFE DIALOG (replaces prompt())
// ============================================================
function showSetLifeDialog(playerId) {
const player = gameState?.players[playerId];
if (!player) return;
// Remove any existing
const existing = document.getElementById('set-life-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'set-life-dialog';
dialog.style.cssText = `
position: fixed; inset: 0; z-index: 800;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);
`;
dialog.innerHTML = `
Set Life Total
${player.name}
`;
document.body.appendChild(dialog);
const input = document.getElementById('set-life-input');
input.select();
input.focus();
const confirm = () => {
const val = parseInt(input.value);
if (!isNaN(val)) setLife(playerId, val);
dialog.remove();
};
document.getElementById('set-life-confirm').addEventListener('click', confirm);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') confirm();
if (e.key === 'Escape') dialog.remove();
});
dialog.addEventListener('click', e => {
if (e.target === dialog) dialog.remove();
});
}
function eliminatePlayer(playerId, reason) {
pushUndoState();
const player = gameState.players[playerId];
if (!player || player.eliminated) return;
player.eliminated = true;
addLogEntry(`â ïļ ${player.name} has been eliminated! (${reason})`);
renderPlayerPanels();
// Fire player_eliminated triggers
fireBattlefieldTriggers('player_eliminated');
// Check for winner
const activePlayers = gameState.players.filter(p => !p.eliminated);
if (activePlayers.length === 1) {
showWinner(activePlayers[0]);
}
}
function showWinner(player) {
addLogEntry(`ð ${player.name} wins the game!`);
// Show a nice in-app winner notification
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed; inset: 0; z-index: 2000;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.75); backdrop-filter: blur(8px);
animation: fadeIn 0.5s ease;
`;
banner.innerHTML = `
ð
${player.name}
Wins the Game!
`;
document.body.appendChild(banner);
}
// ============================================================
// FEATURE 9: COMMANDER DAMAGE MATRIX
// ============================================================
function openCmdDmgMatrix() {
if (!gameState) return;
var existing = document.getElementById('cmd-dmg-matrix-modal');
if (existing) { existing.classList.add('visible'); renderCmdDmgMatrix(); return; }
renderCmdDmgMatrix();
var modal = document.getElementById('cmd-dmg-matrix-modal');
if (modal) modal.classList.add('visible');
}
function closeCmdDmgMatrix() {
var modal = document.getElementById('cmd-dmg-matrix-modal');
if (modal) modal.classList.remove('visible');
}
function renderCmdDmgMatrix() {
var modal = document.getElementById('cmd-dmg-matrix-modal');
if (!modal || !gameState) return;
var players = gameState.players;
var html = '
ð Commander Damage Matrix
' +
'
';
html += '
Row = damage from row commander. Column = player taking damage. Click cell +1, right-click \u22121.
';
html += '
';
// Header row
html += '
From \ To
';
players.forEach(function(p) {
html += '
' + p.name + '
';
});
html += '
';
// Data rows
players.forEach(function(rowP) {
html += '
';
html += '
' + rowP.name + '
';
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 =
'
' +
'
Set Partner Commander
' +
'
' +
'
' +
'
Enter the name of ' + player.name + '’s partner commander:
' +
'' +
'
' +
'' +
'' +
'
';
document.body.appendChild(modal);
var inp = document.getElementById('partner-name-input');
if (inp) inp.focus();
}
function confirmSetPartner(playerId) {
var inp = document.getElementById('partner-name-input');
if (!inp) return;
var name = inp.value.trim();
if (!name) return;
pushUndoState('Set partner ' + name);
gameState.players[playerId].commanderCardName2 = name;
gameState.players[playerId].commanderCastCount2 = 0;
addLogEntry('ð ' + gameState.players[playerId].name + ' set partner commander: ' + name + '');
var modal = document.getElementById('set-partner-modal');
if (modal) modal.remove();
renderPlayerPanels();
}
function clearPartnerCommander(playerId) {
if (!gameState) return;
pushUndoState('Clear partner commander');
gameState.players[playerId].commanderCardName2 = '';
gameState.players[playerId].commanderCastCount2 = 0;
addLogEntry('ð ' + gameState.players[playerId].name + ' cleared partner commander');
renderPlayerPanels();
}
function checkStateBasedActions() {
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.sbaChecks) return;
var alerts = [];
var autoEliminate = gameState.rulesEngine && gameState.rulesEngine.autoEliminateOnSBA;
gameState.players.forEach(function(p, idx) {
if (p.eliminated) return;
// Life <= 0
if (p.life <= 0) {
if (autoEliminate) {
eliminatePlayer(idx, 'life total reached ' + p.life);
} else {
alerts.push({ type: 'life', player: p.name, playerIdx: idx, msg: p.name + ' has ' + p.life + ' life â should be eliminated' });
}
}
// Poison >= 10
if (p.poison >= 10) {
if (autoEliminate) {
eliminatePlayer(idx, '10 poison counters');
} else {
alerts.push({ type: 'poison', player: p.name, playerIdx: idx, msg: p.name + ' has ' + p.poison + ' poison counters â should be eliminated' });
}
}
// Commander damage >= 21 from any single opponent
for (var oppId in p.commanderDamage) {
if (p.commanderDamage[oppId] >= 21) {
var oppName = (gameState.players[oppId] || {}).name || 'Unknown';
if (autoEliminate) {
eliminatePlayer(idx, '21 commander damage from ' + oppName);
} else {
alerts.push({ type: 'cmdr_dmg', player: p.name, playerIdx: idx, msg: p.name + ' has taken 21+ commander damage from ' + oppName });
}
}
}
});
// Legendary Rule SBA
gameState.players.forEach(function(p, idx) {
if (p.eliminated) return;
var legendaries = gameState.battlefieldCards.filter(function(c) {
return c.ownerIndex === idx && c.typeLine && /legendary/i.test(c.typeLine);
});
var byName = {};
legendaries.forEach(function(c) {
if (!byName[c.name]) byName[c.name] = [];
byName[c.name].push(c);
});
Object.keys(byName).forEach(function(name) {
if (byName[name].length > 1) {
alerts.push({
type: 'legendary',
player: p.name,
playerIdx: idx,
cardName: name,
cardIds: byName[name].map(function(c) { return c.id; }),
msg: p.name + ' controls ' + byName[name].length + ' legendary permanents named "' + name + '" â must keep only one',
});
}
});
});
// SBA: Planeswalker with 0 loyalty dies
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/planeswalker/i.test(card.typeLine)) return;
if (!card.counters || card.counters['loyalty'] === undefined) return;
if (card.counters['loyalty'] <= 0) {
alerts.push({
type: 'pw_loyalty',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' has 0 loyalty \u2014 should be moved to graveyard',
});
}
});
// NEW SBA: Toughness <= 0 (creatures that should die from -1/-1 counters or debuffs)
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/creature/i.test(card.typeLine)) return;
// Only check creatures that have actual P/T data (skip if toughness data is missing)
var hasPTData = (card.toughness && card.toughness !== '') || (card.pt && card.pt !== '');
if (!hasPTData) return;
var toughness = getCreatureToughness(card);
if (toughness <= 0) {
// Double-check: only alert if the card actually had modifiers that reduced toughness
var ept = getEffectivePT(card);
if (ept && ept.bonusTough < 0) {
alerts.push({
type: 'toughness',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' has toughness ' + toughness + ' \u2014 should be moved to graveyard',
});
}
}
});
// NEW SBA: Token in non-battlefield zone â tokens outside battlefield cease to exist
gameState.players.forEach(function(p, idx) {
['graveyard', 'exile', 'hand'].forEach(function(zone) {
if (!p.zones[zone]) return;
var tokensInZone = p.zones[zone].filter(function(c) { return c.isToken; });
if (tokensInZone.length > 0) {
// Auto-remove tokens from non-battlefield zones
p.zones[zone] = p.zones[zone].filter(function(c) { return !c.isToken; });
addLogEntry('\u26a0 SBA: ' + tokensInZone.length + ' token(s) removed from ' + p.name + '\'s ' + zone + ' (tokens cease to exist)');
}
});
});
// NEW SBA: +1/+1 and -1/-1 counter cancellation
gameState.battlefieldCards.forEach(function(card) {
if (!card.counters) return;
var plus = card.counters['+1/+1'] || 0;
var minus = card.counters['-1/-1'] || 0;
if (plus > 0 && minus > 0) {
var cancel = Math.min(plus, minus);
card.counters['+1/+1'] -= cancel;
card.counters['-1/-1'] -= cancel;
addLogEntry('â SBA: ' + cancel + ' pair(s) of +1/+1 and -1/-1 counters cancelled on ' + card.name + '');
}
});
// NEW SBA: Aura without attached permanent
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/enchantment.*aura/i.test(card.typeLine)) return;
if (!card.attachedTo) {
alerts.push({
type: 'aura',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' (Aura) has no attached permanent â should be moved to graveyard',
});
}
});
// SBA: Lethal damage â use damage marking system (Forge-style)
// (replaces old damageThisTurn checks with unified damageMarked system)
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/creature/i.test(card.typeLine)) return;
var totalDmg = (card.damageMarked || 0) + (card.damageThisTurn || 0);
if (totalDmg <= 0) return;
if (hasKeyword(card, 'indestructible')) return;
var toughness = getCreatureToughness(card);
if (toughness <= 0) return; // handled by toughness <= 0 SBA above
if (totalDmg >= toughness) {
alerts.push({
type: 'lethal_damage',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' has ' + totalDmg + ' damage (toughness ' + toughness + ') \u2014 should be destroyed',
});
}
});
if (alerts.length > 0) {
showSbaAlert(alerts);
}
}
function showSbaAlert(alerts) {
var existing = document.getElementById('sba-alert-panel');
if (existing) existing.remove();
var panel = document.createElement('div');
panel.id = 'sba-alert-panel';
panel.className = 'sba-alert-panel';
var html = '
â State-Based Actions
';
html += '
';
alerts.forEach(function(a) {
html += '
';
html += '' + a.msg + '';
if (a.type === 'toughness' && a.cardId) {
html += '';
html += '';
} else if (a.type === 'pw_loyalty' && a.cardId) {
html += '';
html += '';
} else if (a.type === 'aura' && a.cardId) {
html += '';
html += '';
} else if (a.type === 'lethal_damage' && a.cardId) {
html += '';
html += '';
} else if (a.type === 'lethal_damage_marked' && a.cardId) {
html += '';
html += '';
} else if (a.type === 'legendary' && a.cardIds && a.cardIds.length >= 2) {
// Show keep/sacrifice buttons for each copy
var cIds = JSON.stringify(a.cardIds);
html += '
';
for (var li = 0; li < a.cardIds.length; li++) {
var cid = a.cardIds[li];
var card = gameState.battlefieldCards.find(function(c) { return c.id === cid; });
var cardLabel = card ? (card.name + ' #' + (li+1)) : 'Copy ' + (li+1);
html += '';
}
html += '
';
html += '';
} else {
html += '';
html += '';
}
html += '
';
});
html += '
';
panel.innerHTML = html;
document.body.appendChild(panel);
}
function closeSbaAlert() {
var panel = document.getElementById('sba-alert-panel');
if (panel) panel.remove();
}
// Feature 5: Keep one legendary, sacrifice the rest
function keepLegendaryCard(keepId, allIds) {
if (!gameState) return;
pushUndoState();
for (var i = 0; i < allIds.length; i++) {
if (allIds[i] !== keepId) {
var card = gameState.battlefieldCards.find(function(c) { return c.id === allIds[i]; });
if (card) {
addLogEntry('â Legendary rule: ' + card.name + ' was sacrificed');
moveCardToZone(allIds[i], 'graveyard');
}
}
}
}
// ============================================================
// FEATURE 6: PROTECTION / HEXPROOF / SHROUD TARGETING
// ============================================================
function canTarget(sourceOwnerId, targetCardId) {
if (!gameState) return { allowed: true, reason: '' };
var card = gameState.battlefieldCards.find(function(c) { return c.id === targetCardId; });
if (!card) return { allowed: true, reason: '' };
var oracle = (card.oracleText || '').toLowerCase();
var typeLine = (card.typeLine || '').toLowerCase();
// Shroud â can't be targeted by anything
if (/shroud/.test(oracle)) {
return { allowed: false, reason: card.name + ' has shroud and cannot be targeted by anything' };
}
// Hexproof â can't be targeted by opponents
if (/hexproof/.test(oracle)) {
if (sourceOwnerId !== card.ownerIndex && sourceOwnerId !== card.ownerId) {
return { allowed: false, reason: card.name + ' has hexproof and cannot be targeted by your spells or abilities' };
}
}
// Protection from [color/quality] â prevents targeting
var protQualities = parseProtectionQualities(card);
if (protQualities.length > 0 && sourceOwnerId !== card.ownerIndex && sourceOwnerId !== card.ownerId) {
// For targeting, protection blocks spells/abilities from matching colors
// We can't know the source's color here, so we flag it as protected and let the caller decide
return { allowed: true, reason: '', protections: protQualities };
}
return { allowed: true, reason: '' };
}
/**
* Parse all protection qualities from a card's oracle text.
* Returns an array of protection qualities: ['white','blue','red', 'everything', etc.]
*/
function parseProtectionQualities(card) {
if (!card) return [];
var oracle = (card.oracleText || '').toLowerCase();
var qualities = [];
// Match: "protection from X", "protection from X and from Y", "protection from X, Y, and Z"
var matches = oracle.match(/protection from [a-z, ]+/g) || [];
matches.forEach(function(m) {
var raw = m.replace('protection from ', '').trim();
// Split on 'and from', 'and', or commas
var parts = raw.split(/(?:\s+and\s+from\s+|\s+and\s+|,\s*)/);
parts.forEach(function(p) {
p = p.trim();
if (p) qualities.push(p);
});
});
return qualities;
}
/**
* Check if a source card's colors match any of the protection qualities.
* Colors: white, blue, black, red, green
* Also handles: 'everything', 'all colors', 'each color'
* @returns {boolean} true if protection blocks this source
*/
function protectionBlocksSource(protQualities, sourceCard) {
if (!protQualities || protQualities.length === 0) return false;
if (!sourceCard) return false;
var colorMap = { W: 'white', U: 'blue', B: 'black', R: 'red', G: 'green' };
var sourceColors = [];
if (Array.isArray(sourceCard.colors)) {
sourceCard.colors.forEach(function(c) {
if (colorMap[c]) sourceColors.push(colorMap[c]);
});
}
// Fallback: parse mana cost
if (sourceColors.length === 0 && sourceCard.manaCost) {
var mc = sourceCard.manaCost.toUpperCase();
Object.keys(colorMap).forEach(function(code) {
if (mc.indexOf(code) !== -1) sourceColors.push(colorMap[code]);
});
}
for (var i = 0; i < protQualities.length; i++) {
var q = protQualities[i];
if (q === 'everything' || q === 'all colors' || q === 'each color') return true;
if (q === 'colorless' && sourceColors.length === 0) return true;
if (q === 'creatures' && sourceCard.typeLine && /creature/i.test(sourceCard.typeLine)) return true;
if (q === 'instants' && sourceCard.typeLine && /instant/i.test(sourceCard.typeLine)) return true;
if (q === 'sorceries' && sourceCard.typeLine && /sorcery/i.test(sourceCard.typeLine)) return true;
if (q === 'enchantments' && sourceCard.typeLine && /enchantment/i.test(sourceCard.typeLine)) return true;
if (q === 'artifacts' && sourceCard.typeLine && /artifact/i.test(sourceCard.typeLine)) return true;
// Color match
if (sourceColors.indexOf(q) !== -1) return true;
}
return false;
}
/**
* Check if protection prevents a creature from being blocked by a specific blocker.
* Protection means the protected creature can't be blocked by creatures matching the quality.
* @returns {boolean} true if blocker is prevented from blocking
*/
function protectionPreventsBlocking(attacker, blocker) {
var atkProt = parseProtectionQualities(attacker);
return protectionBlocksSource(atkProt, blocker);
}
function getTargetingWarning(sourceOwnerId, targetCardId) {
var result = canTarget(sourceOwnerId, targetCardId);
return result.allowed ? '' : result.reason;
}
function getCardProtectionKeywords(card) {
if (!card) return [];
var oracle = (card.oracleText || '').toLowerCase();
var keywords = [];
if (/shroud/.test(oracle)) keywords.push('shroud');
if (/hexproof/.test(oracle)) keywords.push('hexproof');
var protMatches = oracle.match(/protection from [a-z]+/g) || [];
protMatches.forEach(function(m) { keywords.push(m); });
return keywords;
}
function showTargetingWarningBadge(targetCardId, sourceOwnerId) {
var card = gameState.battlefieldCards.find(function(c) { return c.id === targetCardId; });
if (!card) return;
var result = canTarget(sourceOwnerId, targetCardId);
if (!result.allowed) {
addLogEntry('ðŦ Targeting warning: ' + result.reason);
// Show a brief floating warning
var el = document.getElementById('bc-' + targetCardId);
if (el) {
var warn = document.createElement('div');
warn.className = 'targeting-warning-overlay';
warn.textContent = 'ðŦ';
warn.title = result.reason;
el.appendChild(warn);
setTimeout(function() { if (warn.parentNode) warn.parentNode.removeChild(warn); }, 2500);
}
}
}
function isCommander(cardData) {
if (!gameState) return false;
var owner = gameState.players[cardData.ownerIndex];
if (!owner) return false;
if (owner.commanderCardName && owner.commanderCardName === cardData.name) return true;
if (owner.commanderCardName2 && owner.commanderCardName2 === cardData.name) return true;
return false;
}
// Feature 8: Color Identity
function getCommanderColorIdentity(playerId) {
if (!gameState) return ['W','U','B','R','G','C'];
var player = gameState.players[playerId];
if (!player) return ['W','U','B','R','G','C'];
var colors = [];
var COLOR_SYMBOLS = ['W','U','B','R','G'];
function extractFromCost(cost) {
if (!cost) return;
COLOR_SYMBOLS.forEach(function(c) {
if (cost.indexOf(c) !== -1 && colors.indexOf(c) === -1) colors.push(c);
});
}
// Get from battlefield cards that are this player's commander
gameState.battlefieldCards.forEach(function(card) {
if (card.ownerIndex === playerId && isCommander(card)) {
extractFromCost(card.manaCost || '');
extractFromCost(card.oracleText || '');
}
});
// Also check command zone
var cmdZone = player.zones && player.zones.command || [];
cmdZone.forEach(function(card) {
extractFromCost(card.manaCost || '');
extractFromCost(card.oracle_text || card.oracleText || '');
});
// If commander name is set but we couldn't find colors, allow all (unknown)
if (!player.commanderCardName && !player.commanderCardName2) return ['W','U','B','R','G','C'];
if (colors.length === 0) return ['W','U','B','R','G','C'];
colors.push('C'); // colorless always allowed
return colors;
}
function checkColorIdentityViolation(cardData, ownerIndex) {
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceColorIdentity) return false;
var player = gameState.players[ownerIndex];
if (!player) return false;
if (!player.commanderCardName && !player.commanderCardName2) return false; // no commander set
var identity = getCommanderColorIdentity(ownerIndex);
var cardCost = cardData.manaCost || cardData.mana_cost || '';
if (!cardCost) return false; // can't check without mana cost
var COLOR_SYMBOLS = ['W','U','B','R','G'];
var violations = [];
COLOR_SYMBOLS.forEach(function(c) {
if (cardCost.indexOf(c) !== -1 && identity.indexOf(c) === -1) {
violations.push(c);
}
});
return violations.length > 0 ? violations : false;
}
function showColorIdentityWarning(cardName, violations) {
var existing = document.getElementById('color-identity-toast');
if (existing) existing.remove();
var toast = document.createElement('div');
toast.id = 'color-identity-toast';
toast.className = 'color-identity-toast';
var colorNames = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green' };
var vNames = violations.map(function(v) { return colorNames[v] || v; }).join(', ');
toast.innerHTML = '⚠ ' + cardName + ' contains ' + vNames + ' \u2014 outside commander color identity';
document.body.appendChild(toast);
setTimeout(function() { toast.classList.add('visible'); }, 10);
setTimeout(function() {
toast.classList.remove('visible');
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 400);
}, 4000);
}
function showCommanderZonePrompt(cardId, cardData, intendedZone) {
var existing = document.getElementById('commander-zone-prompt');
if (existing) existing.remove();
var panel = document.createElement('div');
panel.id = 'commander-zone-prompt';
panel.className = 'commander-zone-prompt';
panel.innerHTML = '
' +
'
Commander Zone Replacement
' +
'
' + cardData.name + ' would go to ' + intendedZone + '. Send to command zone instead?
' +
'
' +
'' +
'' +
'
' +
'
';
document.body.appendChild(panel);
}
function resolveCommanderZoneChoice(cardId, chosenZone, originalZone) {
var prompt = document.getElementById('commander-zone-prompt');
if (prompt) prompt.remove();
// Temporarily disable commander zone replacement to avoid recursion
var prev = gameState.rulesEngine.commanderZoneReplacement;
gameState.rulesEngine.commanderZoneReplacement = false;
moveCardToZone(cardId, chosenZone);
gameState.rulesEngine.commanderZoneReplacement = prev;
if (chosenZone === 'command') {
addLogEntry('ð Commander returned to command zone');
}
}
// Feature 11: Command Zone Casting Flow
function castCommanderFromZone(playerId, isPartner) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
var cmdName = isPartner ? (player.commanderCardName2 || '') : (player.commanderCardName || '');
if (!cmdName) {
addLogEntry('â No commander name set for ' + player.name);
return;
}
// Find commander in command zone
var cmdZone = player.zones.command;
var cmdIdx = -1;
for (var i = 0; i < cmdZone.length; i++) {
if (cmdZone[i].name === cmdName) { cmdIdx = i; break; }
}
var tax = isPartner ? getCommanderTax2(playerId) : getCommanderTax(playerId);
// Show cast confirmation
var existing = document.getElementById('cast-commander-modal');
if (existing) existing.remove();
var baseCMC = '?';
var cardInZone = cmdIdx >= 0 ? cmdZone[cmdIdx] : null;
if (cardInZone && cardInZone.cmc !== undefined) baseCMC = cardInZone.cmc;
var modal = document.createElement('div');
modal.id = 'cast-commander-modal';
modal.className = 'cast-commander-modal';
var costDisplay = tax > 0 ? (baseCMC + ' + ' + tax + ' (tax) = ' + (baseCMC !== '?' ? (parseInt(baseCMC)||0) + tax : '?' + '+' + tax)) : baseCMC;
modal.innerHTML =
'
' +
'
ð Cast Commander
' +
'
' +
'
' +
'
' + cmdName + '
' +
'
Cost: ' + costDisplay + '
' +
'
' +
'' +
'' +
'
';
document.body.appendChild(modal);
}
function confirmCastCommander(playerId, isPartner) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
var cmdName = isPartner ? (player.commanderCardName2 || '') : (player.commanderCardName || '');
if (!cmdName) return;
pushUndoState('Cast commander ' + cmdName);
// Increment cast count
if (isPartner) {
player.commanderCastCount2 = (player.commanderCastCount2 || 0) + 1;
} else {
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
}
// Find in command zone and move to battlefield
var cmdZone = player.zones.command;
var cmdIdx = -1;
for (var i = 0; i < cmdZone.length; i++) {
if (cmdZone[i].name === cmdName) { cmdIdx = i; break; }
}
var tax = isPartner ? getCommanderTax2(playerId) : getCommanderTax(playerId);
addLogEntry('ð ' + player.name + ' cast ' + cmdName + ' from command zone (tax: +' + tax + ')');
if (cmdIdx >= 0) {
var cardData = cmdZone.splice(cmdIdx, 1)[0];
addCardToBattlefield(cardData.name, cardData.imageUrl || cardData.image_uris && cardData.image_uris.normal || '',
cardData.typeLine || cardData.type_line || '', playerId, undefined, undefined,
cardData.oracleText || cardData.oracle_text || '',
{ manaCost: cardData.manaCost || cardData.mana_cost || '', power: cardData.power, toughness: cardData.toughness });
} else {
// No physical card in zone, just add to battlefield by name
addCardToBattlefield(cmdName, '', 'Legendary Creature', playerId, undefined, undefined, '', {});
}
var modal = document.getElementById('cast-commander-modal');
if (modal) modal.remove();
updatePlayerZones();
renderPlayerPanels();
// Fire commander_cast trigger for all battlefield permanents watching
fireBattlefieldTriggers('commander_cast');
// Also fire generic cast triggers
fireBattlefieldTriggers('cast');
}
function checkHandSize() {
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceHandSize) return;
var p = gameState.players[gameState.currentPlayerIndex];
if (!p || p.eliminated) return;
var maxHand = 7;
if (p.zones.hand.length > maxHand) {
var excess = p.zones.hand.length - maxHand;
showDiscardPrompt(gameState.currentPlayerIndex, excess);
}
}
function showDiscardPrompt(playerId, count) {
var existing = document.getElementById('discard-prompt-panel');
if (existing) existing.remove();
var player = gameState.players[playerId];
if (!player) return;
var panel = document.createElement('div');
panel.id = 'discard-prompt-panel';
panel.className = 'discard-prompt-panel';
var html = '
';
html += 'â Discard to Hand Size';
html += '';
html += '
';
player.zones.hand.forEach(function(card, idx) {
html += '
';
html += '' + (card.name || 'Unknown') + '';
html += '
';
});
html += '
';
html += '';
panel.innerHTML = html;
document.body.appendChild(panel);
}
function discardFromPrompt(playerId, handIndex) {
if (!gameState) return;
pushUndoState();
var player = gameState.players[playerId];
if (!player || handIndex < 0 || handIndex >= player.zones.hand.length) return;
var card = player.zones.hand.splice(handIndex, 1)[0];
player.zones.graveyard.push(card);
addLogEntry('ð ' + player.name + ' discarded ' + (card.name || 'a card') + '');
updatePlayerZones();
// Fire discard triggers
fireBattlefieldTriggers('discard');
// Check if still over hand size
var maxHand = 7;
if (player.zones.hand.length > maxHand) {
var excess = player.zones.hand.length - maxHand;
showDiscardPrompt(playerId, excess);
} else {
closeDiscardPrompt();
}
}
function closeDiscardPrompt() {
var panel = document.getElementById('discard-prompt-panel');
if (panel) panel.remove();
}
function discardFromHand(playerId, handIndex) {
if (!gameState) return;
pushUndoState();
var player = gameState.players[playerId];
if (!player || handIndex < 0 || handIndex >= player.zones.hand.length) return;
var card = player.zones.hand.splice(handIndex, 1)[0];
player.zones.graveyard.push(card);
addLogEntry('ð ' + player.name + ' discarded ' + (card.name || 'a card') + '');
fireBattlefieldTriggers('discard');
updatePlayerZones();
if (typeof renderZoneModal === 'function' && zoneModalPlayerId === playerId) renderZoneModal();
}
function sacrificeCard(cardId) {
if (!gameState) return;
hideContextMenu();
pushUndoState();
var idx = gameState.battlefieldCards.findIndex(function(c) { return c.id === cardId; });
if (idx === -1) return;
var cardData = gameState.battlefieldCards[idx];
var owner = gameState.players[cardData.ownerIndex];
// Fire sacrifice triggers BEFORE removing
fireBattlefieldTriggers('sacrifice');
// Clean up stack membership
cleanupCardFromStack(cardId);
// Move to graveyard (tokens cease to exist)
if (cardData.isToken) {
gameState.battlefieldCards.splice(idx, 1);
var cardEl = document.getElementById('bc-' + cardId);
if (cardEl) {
cardEl.style.transition = 'opacity 0.3s, transform 0.3s';
cardEl.style.opacity = '0';
cardEl.style.transform = 'scale(0.8)';
setTimeout(function() { cardEl.remove(); }, 300);
}
addLogEntry('ð ' + cardData.name + ' was sacrificed (token ceased to exist)');
} else {
if (owner) owner.zones.graveyard.push({ name: cardData.name, imageUrl: cardData.imageUrl, typeLine: cardData.typeLine, oracleText: cardData.oracleText || '', manaCost: cardData.manaCost || '' });
gameState.battlefieldCards.splice(idx, 1);
var cardEl2 = document.getElementById('bc-' + cardId);
if (cardEl2) {
cardEl2.style.transition = 'opacity 0.3s, transform 0.3s';
cardEl2.style.opacity = '0';
cardEl2.style.transform = 'scale(0.8)';
setTimeout(function() { cardEl2.remove(); }, 300);
}
addLogEntry('ð ' + cardData.name + ' was sacrificed');
}
// Fire dies triggers (sacrifice = goes to graveyard = dies)
fireBattlefieldTriggers('dies');
updatePlayerZones();
if (!gameState.battlefieldCards.length) {
var label = document.querySelector('.battlefield-label');
if (label) label.style.opacity = '';
}
updateBattlefieldSectionCounts();
updateTokenSummaryBar();
}
var activeSettingsTab = 'rules';
function toggleRulesPanel() {
var panel = document.getElementById('rules-settings-panel');
if (!panel) return;
panel.classList.toggle('visible');
if (panel.classList.contains('visible')) renderSettingsPanel();
}
function closeRulesPanel() {
var panel = document.getElementById('rules-settings-panel');
if (panel) panel.classList.remove('visible');
}
function switchSettingsTab(tabId) {
activeSettingsTab = tabId;
renderSettingsPanel();
}
function renderSettingsPanel() {
if (!gameState) return;
var tabsEl = document.getElementById('settings-hub-tabs');
var bodyEl = document.getElementById('settings-hub-body');
if (!tabsEl || !bodyEl) return;
var tabs = [
{ id: 'rules', label: 'Rules' },
{ id: 'display', label: 'Display' },
{ id: 'gameplay', label: 'Gameplay' },
{ id: 'ai', label: 'AI' },
];
// Render tabs
var tabHtml = '';
tabs.forEach(function(t) {
var cls = t.id === activeSettingsTab ? ' active' : '';
tabHtml += '';
});
tabsEl.innerHTML = tabHtml;
// Render body based on active tab
switch (activeSettingsTab) {
case 'rules': bodyEl.innerHTML = renderSettingsRulesTab(); break;
case 'display': bodyEl.innerHTML = renderSettingsDisplayTab(); break;
case 'gameplay': bodyEl.innerHTML = renderSettingsGameplayTab(); break;
case 'ai': bodyEl.innerHTML = renderSettingsAITab(); break;
default: bodyEl.innerHTML = renderSettingsRulesTab();
}
}
// ---- RULES TAB ----
function renderSettingsRulesTab() {
var r = gameState.rulesEngine;
if (!r) return '';
var toggles = [
{ key: 'autoUntap', label: 'Auto-untap on new turn', desc: 'Automatically untap all your permanents at the start of your turn' },
{ key: 'autoDraw', label: 'Auto-draw on new turn', desc: 'Automatically draw a card at the start of your turn' },
{ key: 'sbaChecks', label: 'State-based action checks', desc: 'Check for lethal damage, 0 toughness, legend rule, etc.' },
{ key: 'enforceLandLimit', label: 'Land drop limit (1/turn)', desc: 'Enforce the one land per turn limit' },
{ key: 'enforceHandSize', label: 'Hand size enforcement (7)', desc: 'Discard to max hand size at end of turn' },
{ key: 'commanderTaxEnabled', label: 'Commander tax tracking', desc: 'Track and apply +2 tax for each cast from command zone' },
{ key: 'commanderZoneReplacement',label: 'Commander zone replacement', desc: 'Option to return commander to command zone instead of graveyard/exile' },
{ key: 'useStack', label: 'Spell stack & priority', desc: 'Spells go on the stack with priority passing' },
{ key: 'enforceTurnStructure', label: 'Turn structure & priority', desc: 'Enforce phases, sorcery-speed timing, and priority passes (auto-enables stack)' },
{ key: 'enforceColorIdentity', label: 'Color identity enforcement', desc: 'Prevent casting spells outside your commander\'s color identity' },
{ key: 'autoResolveTriggers', label: 'Auto-resolve simple triggers', desc: 'Automatically resolve draw, gain life, and other simple triggers' },
{ key: 'autoEliminateOnSBA', label: 'Auto-eliminate on SBA', desc: 'Automatically eliminate players at 0 life or 21 commander damage' },
{ key: 'autoTapLands', label: 'Auto-tap lands when casting', desc: 'Automatically tap lands to pay for spells' },
{ key: 'autoSpendMana', label: 'Auto-spend mana from pool', desc: 'Automatically spend mana from your mana pool' },
];
var html = '';
toggles.forEach(function(s) {
var checked = r[s.key] ? ' checked' : '';
html += '';
if (s.desc) html += '
' + s.desc + '
';
});
// Timer section
html += '
Timer
';
var timerChecked = r.turnTimerEnabled ? ' checked' : '';
html += '';
if (r.turnTimerEnabled) {
html += '
';
html += 'Minutes per player';
html += '';
html += '
';
}
return html;
}
// ---- DISPLAY TAB ----
function renderSettingsDisplayTab() {
// Initialize display settings if not present
if (!gameState.displaySettings) {
gameState.displaySettings = {
cardScale: 100,
animationSpeed: 1,
showCardNames: true,
showPowerToughness: true,
compactPlayerPanels: false,
showFloatingLog: true,
logFontSize: 12,
};
}
var ds = gameState.displaySettings;
var html = '';
// Theme
html += '
Theme
';
html += '
';
html += '';
html += '';
html += '
';
// Card Scale
html += '
Cards
';
html += '
';
html += 'Card Size';
html += '';
html += '' + ds.cardScale + '%';
html += '
';
// Animation Speed
html += '
';
html += 'Animation Speed';
html += '';
html += '' + (ds.animationSpeed === 0 ? 'Off' : ds.animationSpeed + 'x') + '';
html += '
';
// Toggles
html += '
Visibility
';
var displayToggles = [
{ key: 'showCardNames', label: 'Show card name labels on battlefield' },
{ key: 'showPowerToughness', label: 'Show power/toughness badges' },
{ key: 'compactPlayerPanels', label: 'Compact player panels' },
{ key: 'showFloatingLog', label: 'Show floating game log' },
];
displayToggles.forEach(function(t) {
var checked = ds[t.key] ? ' checked' : '';
html += '';
});
// Log font size
html += '
';
html += 'Log Font Size';
html += '';
html += '' + ds.logFontSize + 'px';
html += '
';
return html;
}
// ---- GAMEPLAY TAB ----
function renderSettingsGameplayTab() {
if (!gameState) return '';
var html = '';
// Starting life
html += '
Life
';
html += '
';
html += 'Starting Life';
html += '' + (gameState.startingLife || 40) + '';
html += '
';
html += '
Starting life is set during game setup and cannot be changed mid-game.
';
// Max lands per turn
html += '
Lands
';
html += '
';
html += 'Max Lands Per Turn';
html += '';
html += '
';
html += '
Increase for effects like Exploration or Azusa.
';
// Storm count display
html += '
Counters
';
html += '
';
html += 'Storm Count (this turn)';
html += '';
html += '
';
// Undo limit
html += '
History
';
html += '
';
html += 'Undo Stack Size';
html += '' + UNDO_MAX + '';
html += '
';
html += '
Maximum undo steps stored. Currently fixed at ' + UNDO_MAX + '.
';
// Turn number
html += '
';
html += 'Current Turn';
html += '' + (gameState.currentTurn || 1) + '';
html += '
';
return html;
}
// ---- AI TAB ----
function renderSettingsAITab() {
if (!gameState) return '';
var diff = gameState.aiDifficulty || 'easy';
var hasAI = gameState.players.some(function(p) { return p.isAI; });
var html = '';
if (!hasAI) {
html += '
No AI players in this game. Add AI players during game setup to use these settings.
';
return html;
}
// Difficulty
html += '
Difficulty
';
html += '
';
var difficulties = ['easy', 'normal', 'hard'];
difficulties.forEach(function(d) {
var cls = d === diff ? ' active' : '';
var label = d.charAt(0).toUpperCase() + d.slice(1);
html += '';
});
html += '
';
// Difficulty descriptions
var diffDescs = {
easy: 'Random play order, limited spells per turn, no bluffing, attacks weakest player.',
normal: 'Smart sequencing (ramp first), threat assessment, some bluffing, attacks the leader.',
hard: 'Optimal sequencing, full threat assessment, holds removal, bluffs with open mana, goes for kills.'
};
html += '
' + diffDescs[diff] + '
';
// AI Behavior toggles
if (!gameState.aiSettings) {
gameState.aiSettings = {
showThoughts: true,
turnDelay: 600,
};
}
var ais = gameState.aiSettings;
html += '
Behavior
';
// Show AI thoughts toggle
var thoughtsChecked = ais.showThoughts ? ' checked' : '';
html += '';
html += '
Display AI decision reasoning in the game log.
';
// Turn delay
html += '
';
html += 'AI Turn Speed';
html += '';
html += '' + (ais.turnDelay / 1000).toFixed(1) + 's';
html += '
';
html += '
Delay between AI actions. Lower = faster turns.
';
// AI player list
html += '
AI Players
';
gameState.players.forEach(function(p, i) {
if (!p.isAI) return;
var color = p.color || '#999';
html += '
';
html += '';
html += '';
html += p.name;
html += '';
html += '' + diff + '';
html += '
';
});
// Training section
html += '
Self-Play Training
';
html += '
Train the AI via headless self-play games. Hill-climbing optimizer mutates weight vectors and keeps improvements.
';
html += '';
// After rendering, populate the training panel
setTimeout(function() { renderTrainingUI(); }, 0);
return html;
}
// ---- Settings action handlers ----
function setSettingsTheme(theme) {
currentTheme = theme;
document.documentElement.setAttribute('data-theme', theme);
var toggle = document.querySelector('[data-theme-toggle]');
if (toggle) {
toggle.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
toggle.innerHTML = theme === 'dark'
? ''
: '';
}
renderSettingsPanel();
}
function updateDisplaySetting(key, value) {
if (!gameState) return;
if (!gameState.displaySettings) gameState.displaySettings = {};
gameState.displaySettings[key] = value;
// Apply display changes immediately
if (key === 'cardScale') {
var scale = value / 100;
document.documentElement.style.setProperty('--card-scale', scale);
// Update battlefield card sizes
var cards = document.querySelectorAll('.battlefield-card');
cards.forEach(function(el) {
el.style.width = Math.round(100 * scale) + 'px';
el.style.height = Math.round(140 * scale) + 'px';
});
}
if (key === 'animationSpeed') {
var speed = value;
document.documentElement.style.setProperty('--anim-speed', speed === 0 ? '0s' : (0.3 / speed) + 's');
}
if (key === 'showFloatingLog') {
var logSidebar = document.getElementById('game-log-sidebar');
if (logSidebar) {
if (value) logSidebar.classList.remove('collapsed');
else logSidebar.classList.add('collapsed');
}
}
if (key === 'logFontSize') {
var logSidebar = document.getElementById('game-log-sidebar');
if (logSidebar) logSidebar.style.fontSize = value + 'px';
}
if (key === 'compactPlayerPanels') {
document.body.classList.toggle('compact-panels', !!value);
}
}
function updateGameplaySetting(key, value) {
if (!gameState) return;
if (key === 'maxLandsPerTurn') {
gameState.maxLandsPerTurn = Math.max(1, Math.min(10, value));
addLogEntry('Lands per turn set to ' + gameState.maxLandsPerTurn + '');
}
}
function updateAIDifficulty(diff) {
if (!gameState) return;
gameState.aiDifficulty = diff;
addLogEntry('AI difficulty set to ' + diff + '');
renderSettingsPanel();
}
function updateAISetting(key, value) {
if (!gameState) return;
if (!gameState.aiSettings) gameState.aiSettings = {};
gameState.aiSettings[key] = value;
}
function setTimerMinutes(mins) {
if (!gameState || !gameState.rulesEngine) return;
gameState.rulesEngine.turnTimerMinutes = mins;
// Re-init timer banks
if (gameState.turnTimer) {
gameState.players.forEach(function(p) {
gameState.turnTimer.timePerPlayer[p.id] = mins * 60;
});
renderAllTimerDisplays();
}
addLogEntry('âą Timer set to ' + mins + ' minutes per player');
}
function toggleRule(key, value) {
if (!gameState || !gameState.rulesEngine) return;
gameState.rulesEngine[key] = value;
addLogEntry('â Rule ' + key + ' ' + (value ? 'enabled' : 'disabled'));
if (key === 'useStack') renderStackPanel();
if (key === 'enforceTurnStructure') {
renderLegalActionsBar();
// Enforce turn structure implies stack & priority
if (value && !gameState.rulesEngine.useStack) {
gameState.rulesEngine.useStack = true;
addLogEntry('\u2699 Spell stack auto-enabled with turn structure enforcement');
renderStackPanel();
renderSettingsPanel();
}
// Close any active priority window when disabling
if (!value && gameState.priority && gameState.priority.active) {
closePriorityWindow();
}
}
if (key === 'turnTimerEnabled') {
if (!value) { stopTurnTimer(); renderAllTimerDisplays(); }
else { initTurnTimers(); if (gameState) startTurnTimer(gameState.currentPlayerIndex); }
}
}
// ============================================================
// FEATURE 3: SPELL STACK & PRIORITY SYSTEM
// ============================================================
function renderStackPanel() {
var panel = document.getElementById('spell-stack-panel');
if (!panel) return;
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.useStack) {
panel.style.display = 'none';
updateUnifiedStack();
return;
}
var stack = gameState.theStack || [];
panel.style.display = stack.length > 0 ? 'flex' : 'none';
if (stack.length === 0) {
updateUnifiedStack();
return;
}
var activePlayer = gameState.players[gameState.currentPlayerIndex];
var activePlayerName = activePlayer ? activePlayer.name : 'Player';
var html = '
ð The Stack (' + stack.length + ')';
html += 'Priority: ' + activePlayerName + '
';
html += '
';
for (var i = stack.length - 1; i >= 0; i--) {
var entry = stack[i];
var isTop = (i === stack.length - 1);
var casterPlayer = gameState.players[entry.ownerIndex];
var casterColor = casterPlayer ? casterPlayer.color : '#888';
var casterName = casterPlayer ? casterPlayer.name : 'Unknown';
html += '
';
if (entry.imageUrl) {
html += '';
}
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 += '';
}
html += '
';
}
html += '
';
html += '
';
html += '';
html += '';
html += '';
html += '
';
panel.innerHTML = html;
updateUnifiedStack();
}
/**
* Resolve the entire theStack in LIFO order with 500ms delays.
*/
function resolveAllOfStack() {
if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return;
function resolveNext() {
if (!gameState.theStack || gameState.theStack.length === 0) return;
resolveTopOfStack();
if (gameState.theStack.length > 0) setTimeout(resolveNext, 500);
}
resolveNext();
}
/**
* Toggle the unified stack panel collapsed/expanded.
*/
function toggleUnifiedStack() {
var panel = document.getElementById('unified-stack-panel');
if (!panel) return;
panel.classList.toggle('collapsed');
}
/**
* Update the unified stack panel â merges theStack and triggerStack visually.
*/
function updateUnifiedStack() {
var panel = document.getElementById('unified-stack-panel');
if (!panel) return;
var spellStack = (gameState && gameState.theStack) || [];
var trigStack = (gameState && gameState.triggerStack)
? gameState.triggerStack.filter(function(t) { return !t.resolved; })
: [];
var totalCount = spellStack.length + trigStack.length;
var countEl = document.getElementById('usp-count');
if (countEl) countEl.textContent = totalCount;
if (totalCount === 0) {
panel.style.display = 'none';
return;
}
panel.style.display = 'flex';
var body = document.getElementById('usp-body');
if (!body) return;
if (panel.classList.contains('collapsed')) return;
var html = '';
// Spell stack entries (LIFO â top resolves first)
for (var i = spellStack.length - 1; i >= 0; i--) {
var entry = spellStack[i];
var isTop = (i === spellStack.length - 1);
var cPl = gameState.players[entry.ownerIndex];
var cColor = cPl ? cPl.color : '#888';
var cName = cPl ? cPl.name : 'Unknown';
html += '
';
if (entry.imageUrl) {
html += '';
} 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 += '
';
if (spellStack.length > 1) {
html += '';
}
if (trigStack.length > 1) {
html += '';
}
if (spellStack.length > 0) {
html += '';
html += '';
}
html += '
';
body.innerHTML = html;
}
function resolveTopOfStack() {
if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return;
pushUndoState();
var entry = gameState.theStack.pop();
var typeLine = (entry.typeLine || '').toLowerCase();
var isInstantOrSorcery = /instant|sorcery/.test(typeLine);
if (isInstantOrSorcery) {
// Instants and sorceries resolve their effect, then go to the graveyard (not battlefield)
addLogEntry('\u2713 ' + escapeHtml(entry.name) + ' resolves:');
if (entry.oracleText) {
addLogEntry('' + escapeHtml(entry.oracleText).substring(0, 200) + '');
}
// Send to owner's graveyard
var owner = gameState.players[entry.ownerIndex];
if (owner) {
owner.zones.graveyard.push({
name: entry.name,
imageUrl: entry.imageUrl,
typeLine: entry.typeLine || '',
oracleText: entry.oracleText || '',
manaCost: entry.manaCost || '',
cmc: entry.cmc || 0,
power: entry.power || '',
toughness: entry.toughness || '',
pt: entry.pt || '',
});
addLogEntry('\u2192 ' + escapeHtml(entry.name) + ' goes to ' + escapeHtml(owner.name) + "'s graveyard");
}
// Fire 'cast' triggers on existing battlefield permanents (spell resolved)
fireBattlefieldTriggers('cast');
updatePlayerZones();
} else {
// Permanents (creatures, enchantments, artifacts, planeswalkers, lands) go to battlefield
addCardToBattlefield(entry.name, entry.imageUrl, entry.typeLine, entry.ownerIndex, undefined, undefined, entry.oracleText,
{ manaCost: entry.manaCost || '', power: entry.power || '', toughness: entry.toughness || '', pt: entry.pt || '', cmc: entry.cmc || 0, loyalty: entry.loyalty || '', colorIdentity: entry.colorIdentity, keywords: entry.keywords });
addLogEntry('\u2713 ' + escapeHtml(entry.name) + ' enters the battlefield');
}
renderStackPanel();
renderHandArea();
// After a spell resolves, open priority if there are more items on stack
if (isPriorityEnabled() && gameState.theStack.length > 0) {
openPriorityWindow('spell_resolve');
}
}
function passStackPriority() {
if (!gameState || !gameState.theStack) return;
var stack = gameState.theStack;
if (stack.length === 0) return;
var top = stack[stack.length - 1];
top.passCount = (top.passCount || 0) + 1;
var playerCount = gameState.players.filter(function(p) { return !p.eliminated; }).length;
if (top.passCount >= playerCount) {
resolveTopOfStack();
addLogEntry('ð All players passed priority â spell resolves');
} else {
// Cycle priority to next player
var nextIdx = (gameState.currentPlayerIndex + 1) % gameState.players.length;
while (gameState.players[nextIdx] && gameState.players[nextIdx].eliminated) {
nextIdx = (nextIdx + 1) % gameState.players.length;
}
var nextName = gameState.players[nextIdx] ? gameState.players[nextIdx].name : 'Next Player';
addLogEntry('â Priority passed to ' + nextName + '');
renderStackPanel();
}
}
function holdStackPriority() {
if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return;
var top = gameState.theStack[gameState.theStack.length - 1];
top.passCount = 0; // Reset pass count â active player is holding
var activePlayer = gameState.players[gameState.currentPlayerIndex];
addLogEntry('âļ ' + (activePlayer ? activePlayer.name : 'Active player') + ' is holding priority');
renderStackPanel();
}
function clearEntireStack() {
if (!gameState) return;
pushUndoState();
gameState.theStack = [];
renderStackPanel();
addLogEntry('\ud83d\udcda Stack cleared');
}
// ============================================================
// PRIORITY SYSTEM
// ============================================================
/**
* Check if the priority system is enabled.
* Priority is active when enforceTurnStructure is on.
*/
function isPriorityEnabled() {
return gameState && gameState.rulesEngine && gameState.rulesEngine.enforceTurnStructure;
}
/**
* Open a priority window. Each player (starting with active player)
* gets a chance to act (cast instants, activate abilities) or pass.
* When ALL players have passed in succession, the priority window closes
* and the game proceeds (phase advances, spell resolves, etc.).
*
* @param {string} reason - why priority opened: 'phase', 'spell_cast', 'spell_resolve', 'trigger', 'combat_damage'
* @param {function} [onAllPass] - callback when all players pass priority in succession
*/
var _priorityOnAllPass = null;
function openPriorityWindow(reason, onAllPass) {
if (!gameState || !isPriorityEnabled()) {
// If priority not enabled, just run the callback immediately
if (typeof onAllPass === 'function') onAllPass();
return;
}
// If we're in the very beginning phase (auto-resolved), skip priority
var phase = PHASES[gameState.currentPhaseIndex];
var phaseId = phase ? phase.id : '';
gameState.priority.active = true;
gameState.priority.playerIndex = gameState.currentPlayerIndex;
gameState.priority.passCount = 0;
gameState.priority.reason = reason;
gameState.priority.sourcePhase = phaseId;
_priorityOnAllPass = onAllPass || null;
renderPriorityBanner();
}
/**
* Current player passes priority to the next non-eliminated player.
* If all players have passed in succession, close the priority window.
*/
function passPriority() {
if (!gameState || !gameState.priority.active) return;
var currentPriorityPlayer = gameState.players[gameState.priority.playerIndex];
var activePlayers = gameState.players.filter(function(p) { return !p.eliminated; }).length;
gameState.priority.passCount++;
// Log the pass
if (currentPriorityPlayer) {
addLogEntry('\u27a1 ' + currentPriorityPlayer.name + ' passes priority');
}
// Check if all active players have passed
if (gameState.priority.passCount >= activePlayers) {
closePriorityWindow();
return;
}
// Advance priority to next non-eliminated player
var next = (gameState.priority.playerIndex + 1) % gameState.players.length;
var safety = 0;
while (gameState.players[next].eliminated && safety < gameState.players.length) {
next = (next + 1) % gameState.players.length;
safety++;
}
gameState.priority.playerIndex = next;
// If next player is AI, auto-pass after brief delay
if (gameState.players[next].isAI) {
renderPriorityBanner();
setTimeout(function() {
if (gameState && gameState.priority.active && gameState.priority.playerIndex === next) {
passPriority();
}
}, 600);
return;
}
renderPriorityBanner();
}
/**
* Player wants to hold priority to cast an instant or activate an ability.
* This resets the pass count since a new action is being taken.
*/
function holdPriorityForAction() {
if (!gameState || !gameState.priority.active) return;
var currentPriorityPlayer = gameState.players[gameState.priority.playerIndex];
if (currentPriorityPlayer) {
addLogEntry('\u23f8 ' + currentPriorityPlayer.name + ' is acting (casting / activating)');
}
// Close the banner â player can play instants/flash cards or activate abilities.
// After they finish, they can click "Done Acting" which re-opens priority round.
gameState.priority.passCount = 0; // Reset passes since someone is acting
showPriorityActionMode();
}
/**
* Player is done acting. Reopen priority round starting with the active player
* (per MTG rules, after a spell/ability is added, the active player gets priority).
*/
function doneActing() {
if (!gameState || !gameState.priority.active) return;
// If something was put on the stack, start a new round of priority
gameState.priority.passCount = 0;
gameState.priority.playerIndex = gameState.currentPlayerIndex;
renderPriorityBanner();
}
/**
* Close the priority window and fire the callback.
*/
function closePriorityWindow() {
if (!gameState) return;
gameState.priority.active = false;
gameState.priority.passCount = 0;
gameState.priority.reason = '';
hidePriorityBanner();
var callback = _priorityOnAllPass;
_priorityOnAllPass = null;
if (typeof callback === 'function') callback();
}
/**
* Render the priority banner UI.
*/
function renderPriorityBanner() {
var banner = document.getElementById('priority-banner');
if (!banner) return;
if (!gameState || !gameState.priority.active) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
var player = gameState.players[gameState.priority.playerIndex];
var playerName = player ? player.name : 'Unknown';
var playerColor = player ? (player.color || '#C8A843') : '#C8A843';
var isHuman = player && !player.isAI;
var isPlayer1 = gameState.priority.playerIndex === 0;
var reasonText = '';
var reason = gameState.priority.reason;
if (reason === 'phase') {
var phase = PHASES[gameState.currentPhaseIndex];
reasonText = phase ? phase.label + ' â any responses?' : 'Phase change';
} else if (reason === 'spell_cast') {
reasonText = 'Spell cast â any responses?';
} else if (reason === 'spell_resolve') {
reasonText = 'Spell resolved â any responses?';
} else if (reason === 'trigger') {
reasonText = 'Trigger fired â any responses?';
} else if (reason === 'combat_damage') {
reasonText = 'Combat damage resolved â any responses?';
}
var labelEl = document.getElementById('priority-banner-label');
var reasonEl = document.getElementById('priority-banner-reason');
var actionsEl = document.getElementById('priority-banner-actions');
if (labelEl) {
labelEl.innerHTML = 'Priority: ' + escapeHtml(playerName) + '';
if (gameState.priority.passCount > 0) {
var activePlayers = gameState.players.filter(function(p) { return !p.eliminated; }).length;
labelEl.innerHTML += ' (' + gameState.priority.passCount + '/' + activePlayers + ' passed)';
}
}
if (reasonEl) reasonEl.textContent = reasonText;
if (actionsEl) {
if (isPlayer1) {
actionsEl.innerHTML = '' +
'';
} else if (isHuman) {
actionsEl.innerHTML = '' +
'';
} else {
actionsEl.innerHTML = 'AI thinking...';
}
}
// Color-code the banner by player
banner.style.borderColor = playerColor;
}
/**
* Show "Done Acting" mode when a player is holding priority.
*/
function showPriorityActionMode() {
var actionsEl = document.getElementById('priority-banner-actions');
var reasonEl = document.getElementById('priority-banner-reason');
if (reasonEl) reasonEl.textContent = 'Play instants, flash cards, or activate abilities. Click Done when finished.';
if (actionsEl) {
actionsEl.innerHTML = '';
}
}
function hidePriorityBanner() {
var banner = document.getElementById('priority-banner');
if (banner) banner.style.display = 'none';
}
/**
* Check if priority should block phase advancement.
* Returns true if priority is active (meaning we're waiting for players to pass).
*/
function isPriorityBlocking() {
return gameState && gameState.priority && gameState.priority.active;
}
// ============================================================
// FEATURE 4: TURN STRUCTURE ENFORCEMENT â LEGAL ACTIONS HINTS
// ============================================================
var PHASE_LEGAL_ACTIONS = {
'beginning': 'Untap, upkeep triggers, draw â no spells or land plays allowed',
'main1': 'Play a land (if not yet this turn), cast spells and activate abilities',
'combat': 'Declare attackers, declare blockers, resolve combat damage (instants only)',
'main2': 'Cast spells, activate abilities, play a land (if not yet this turn)',
'end': 'Resolve end step triggers, discard to hand size (instants only)',
};
function renderLegalActionsBar() {
var bar = document.getElementById('legal-actions-bar');
if (!bar) return;
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.enforceTurnStructure) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
var phaseId;
if (gameState.inCombat && gameState.currentPhaseIndex === 2) {
phaseId = 'combat';
} else {
var phase = (typeof PHASES !== 'undefined' && PHASES[gameState.currentPhaseIndex]) ? PHASES[gameState.currentPhaseIndex] : null;
phaseId = phase ? phase.id : '';
}
var hint = PHASE_LEGAL_ACTIONS[phaseId] || 'No specific restrictions this phase';
var warnings = [];
var activePlayer = gameState.players[gameState.currentPlayerIndex];
if (activePlayer) {
if (phaseId === 'beginning' || phaseId === 'end') {
warnings.push('Casting sorcery-speed spells not allowed');
}
if (phaseId === 'main1' || phaseId === 'main2') {
if (gameState.landsPlayedThisTurn >= gameState.maxLandsPerTurn) {
warnings.push('Land drop used (' + gameState.landsPlayedThisTurn + '/' + gameState.maxLandsPerTurn + ')');
} else {
warnings.push('Land drop available (' + gameState.landsPlayedThisTurn + '/' + gameState.maxLandsPerTurn + ')');
}
}
}
var html = '
âđ' + hint + '
';
if (warnings.length) {
html += '
';
warnings.forEach(function(w) {
html += '' + w + '';
});
html += '
';
}
bar.innerHTML = html;
}
// ============================================================
// TURN & PHASE MANAGEMENT
// ============================================================
// Check if any triggers or spells are pending on the stack
function hasUnresolvedStack() {
if (!gameState) return false;
var spellsPending = gameState.theStack && gameState.theStack.length > 0;
var triggersPending = gameState.triggerStack && gameState.triggerStack.filter(function(t) { return !t.resolved; }).length > 0;
return spellsPending || triggersPending;
}
function showStackBlockedWarning() {
addLogEntry('\u26a0\ufe0f Resolve all triggers/spells on the stack before advancing.', 'rules');
// Flash the unified stack panel to draw attention
var panel = document.getElementById('unified-stack-panel');
if (panel) {
panel.classList.add('stack-flash');
setTimeout(function() { panel.classList.remove('stack-flash'); }, 800);
}
}
function nextPhase() {
if (!gameState) return;
// Block manual phase advances while AI is playing
if (aiTurnActive) return;
// Block phase advance while priority window is open
if (isPriorityBlocking()) {
addLogEntry('\u26a0\ufe0f Must resolve priority before advancing phases.');
return;
}
// Block phase advance while stack has unresolved triggers or spells
if (hasUnresolvedStack()) {
showStackBlockedWarning();
return;
}
pushUndoState();
// If we're in combat, cycle through combat sub-phases
if (gameState.currentPhaseIndex === 2 && gameState.inCombat) {
gameState.combatPhaseIndex++;
if (gameState.combatPhaseIndex >= COMBAT_PHASES.length) {
endCombat();
gameState.inCombat = false;
gameState.combatPhaseIndex = 0;
gameState.currentPhaseIndex = 3; // Move to Main 2
} else {
const combatPhase = COMBAT_PHASES[gameState.combatPhaseIndex];
if (combatPhase.id === 'begin-combat') {
fireBattlefieldTriggers('combat_begin');
} else if (combatPhase.id === 'attackers') {
// Pause and show attacker declaration UI
renderPhaseTracker();
addLogEntry(`Phase: ${getCurrentPhaseName()}`);
startAttackerDeclaration();
return; // Don't advance further â wait for Confirm
} else if (combatPhase.id === 'blockers') {
// Pause and show blocker declaration UI
renderPhaseTracker();
addLogEntry(`Phase: ${getCurrentPhaseName()}`);
startBlockerDeclaration();
return; // Don't advance further â wait for Confirm
} else if (combatPhase.id === 'damage') {
renderPhaseTracker();
addLogEntry(`Phase: ${getCurrentPhaseName()}`);
resolveCombatDamage();
// Priority after combat damage
openPriorityWindow('combat_damage', function() {
setTimeout(function() { nextPhase(); }, 400);
});
return;
} else if (combatPhase.id === 'end-combat') {
endCombat();
}
}
} else {
// If entering combat, start combat sub-phases
if (gameState.currentPhaseIndex === 1) { // After Main 1 â Combat
gameState.currentPhaseIndex = 2;
gameState.inCombat = true;
gameState.combatPhaseIndex = 0;
// begin-combat is the first combat sub-phase (index 0)
fireBattlefieldTriggers('combat_begin');
} else if (gameState.currentPhaseIndex < PHASES.length - 1) {
gameState.currentPhaseIndex++;
} else {
// End of turn â advance to next player
nextTurn();
return;
}
}
// Phase-specific trigger firing
const phase = PHASES[gameState.currentPhaseIndex];
if (phase) {
if (phase.id === 'end') {
fireBattlefieldTriggers('end_step');
// Monarch: draw extra card at end step
if (gameState.monarchPlayerIndex !== undefined && gameState.monarchPlayerIndex !== null) {
var monarchP = gameState.players[gameState.monarchPlayerIndex];
if (monarchP && !monarchP.eliminated && monarchP.zones.library.length > 0) {
var monarchCard = monarchP.zones.library.shift();
monarchP.zones.hand.push(monarchCard);
addLogEntry('\ud83d\udc51 ' + monarchP.name + ' draws a card as the Monarch.');
renderHandArea();
}
}
checkHandSize();
// Cleanup step: clear damage counters and 'until end of turn' effects
performCleanupStep();
}
}
// Mana pools empty between phases (MTG rules)
emptyAllManaPools();
renderPhaseTracker();
renderHandArea(); // Re-render hand to update phase-locked visual state
addLogEntry(`Phase: ${getCurrentPhaseName()}`);
showShortcutHint();
// Open priority window for the new phase (skipped during Beginning phase since it auto-resolves)
if (isPriorityEnabled()) {
var newPhase = PHASES[gameState.currentPhaseIndex];
var newPhaseId = newPhase ? newPhase.id : '';
// Skip priority during beginning phase (it auto-resolves untap/upkeep/draw in nextTurn)
if (newPhaseId !== 'beginning') {
openPriorityWindow('phase');
}
}
}
// Feature 12: Turn Timer / Chess Clock
var _turnTimerInterval = null;
function initTurnTimers() {
if (!gameState || !gameState.turnTimer) return;
gameState.players.forEach(function(p) {
if (gameState.turnTimer.timePerPlayer[p.id] === undefined) {
var mins = (gameState.rulesEngine && gameState.rulesEngine.turnTimerMinutes) || 30;
gameState.turnTimer.timePerPlayer[p.id] = mins * 60;
}
});
}
function startTurnTimer(playerId) {
if (!gameState || !gameState.rulesEngine || !gameState.rulesEngine.turnTimerEnabled) return;
stopTurnTimer();
initTurnTimers();
gameState.turnTimer.activePlayerId = playerId;
_turnTimerInterval = setInterval(function() {
if (!gameState || !gameState.turnTimer) return;
var pid = gameState.turnTimer.activePlayerId;
if (pid === null || pid === undefined) return;
var remaining = gameState.turnTimer.timePerPlayer[pid];
if (remaining === undefined) return;
remaining = Math.max(0, remaining - 1);
gameState.turnTimer.timePerPlayer[pid] = remaining;
updateTimerDisplay(pid);
if (remaining === 0) {
addLogEntry('âą â ' + gameState.players[pid].name + ' time is up!');
flashTimerWarning(pid);
}
}, 1000);
gameState.turnTimer.intervalId = _turnTimerInterval;
}
function stopTurnTimer() {
if (_turnTimerInterval) { clearInterval(_turnTimerInterval); _turnTimerInterval = null; }
if (gameState && gameState.turnTimer) { gameState.turnTimer.activePlayerId = null; gameState.turnTimer.intervalId = null; }
}
function formatTimerTime(seconds) {
var m = Math.floor(seconds / 60);
var s = seconds % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
function updateTimerDisplay(playerId) {
var el = document.getElementById('timer-display-' + playerId);
if (!el || !gameState || !gameState.turnTimer) return;
var remaining = gameState.turnTimer.timePerPlayer[playerId];
if (remaining === undefined) { el.style.display = 'none'; return; }
el.style.display = 'flex';
el.textContent = formatTimerTime(remaining);
el.className = 'player-timer-display';
if (remaining <= 60) el.classList.add('timer-critical');
else if (remaining <= 180) el.classList.add('timer-warning');
}
function flashTimerWarning(playerId) {
var panel = document.getElementById('player-panel-' + playerId);
if (!panel) return;
panel.classList.add('timer-expired');
setTimeout(function() { panel.classList.remove('timer-expired'); }, 2000);
}
function renderAllTimerDisplays() {
if (!gameState) return;
gameState.players.forEach(function(p) { updateTimerDisplay(p.id); });
}
function nextTurn() {
if (!gameState) return;
// Block manual turn advances while AI is playing
if (aiTurnActive) return;
// Block turn advance while priority window is open
if (isPriorityBlocking()) {
addLogEntry('\u26a0\ufe0f Must resolve priority before advancing turns.');
return;
}
// Block turn advance while stack has unresolved triggers or spells
if (hasUnresolvedStack()) {
showStackBlockedWarning();
return;
}
pushUndoState();
// Perform cleanup step for the ending turn
performCleanupStep();
// Find next active player
let nextIdx = (gameState.currentPlayerIndex + 1) % gameState.players.length;
let attempts = 0;
while (gameState.players[nextIdx].eliminated && attempts < gameState.players.length) {
nextIdx = (nextIdx + 1) % gameState.players.length;
attempts++;
}
// If all are eliminated, don't advance
if (attempts >= gameState.players.length) return;
// Increment turn number when we wrap around to a lower index (completing a round)
if (nextIdx <= gameState.currentPlayerIndex) {
gameState.turnNumber++;
}
gameState.currentPlayerIndex = nextIdx;
gameState.currentPhaseIndex = 0;
gameState.inCombat = false;
gameState.combatPhaseIndex = 0;
gameState.stormCount = 0;
gameState.landsPlayedThisTurn = 0;
// Reset combat state for new turn
gameState.combat = {
attackers: [],
blockers: [],
damageAssignments: [],
phase: null,
pendingBlockerFor: null,
damagePreview: null,
};
hideCombatOverlay();
const manaSelect = document.getElementById('mana-pool-player-select');
if (manaSelect) manaSelect.value = nextIdx;
renderManaPool();
const activePlayer = gameState.players[nextIdx];
// Auto-untap active player's permanents (Beginning Phase â Untap Step)
let untapCount = 0;
gameState.battlefieldCards.forEach(card => {
if (card.ownerId === nextIdx && card.tapped) {
card.tapped = false;
untapCount++;
const cardEl = document.getElementById(`bc-${card.id}`);
if (cardEl) cardEl.classList.remove('tapped');
}
});
// Feature 3: Clear damage-this-turn counters at start of each new turn
clearAllDamageCounters();
// Reset planeswalker loyalty ability usage for the new turn
resetPlaneswalkerLoyaltyUsage();
// Forge CardScript: Clear temporary keywords and reset SpellAbility once-per-turn flags
gameState.battlefieldCards.forEach(function(card) {
if (card.tempKeywords) card.tempKeywords = [];
if (card.powerMod !== undefined && card._tempPowerMod) { card.powerMod -= card._tempPowerMod; card._tempPowerMod = 0; }
if (card.toughnessMod !== undefined && card._tempToughMod) { card.toughnessMod -= card._tempToughMod; card._tempToughMod = 0; }
});
if (typeof CardScriptManager !== 'undefined') { CardScriptManager.clearCache(); }
if (untapCount > 0) {
addLogEntry(`âĻ ${activePlayer.name} untapped ${untapCount} permanent${untapCount > 1 ? 's' : ''}`);
}
// Clear summoning sickness visuals (turn changed, so previous creatures are no longer sick)
document.querySelectorAll('.battlefield-card.summoning-sick').forEach(el => el.classList.remove('summoning-sick'));
// Re-apply to any creatures that still have it this turn
gameState.battlefieldCards.forEach(card => {
if (hasSummoningSickness(card)) {
const cardEl = document.getElementById(`bc-${card.id}`);
if (cardEl) cardEl.classList.add('summoning-sick');
}
});
// Fire upkeep triggers (Beginning Phase â Upkeep Step)
addLogEntry('Phase: Upkeep Step -- checking battlefield triggers');
fireBattlefieldTriggers('upkeep');
// Priority window during Upkeep (before draw) â if priority is enabled
if (isPriorityEnabled()) {
addLogEntry('Phase: Upkeep \u2014 priority open before draw step');
renderPhaseTracker();
openPriorityWindow('upkeep', function() {
// After upkeep priority resolves, proceed to draw step
_proceedToDrawStep(nextIdx, activePlayer);
});
return; // Wait for priority to resolve
}
// No priority â draw immediately
_proceedToDrawStep(nextIdx, activePlayer);
}
/**
* Internal: Proceed from upkeep to draw step and then to Main 1.
* Called after upkeep priority resolves (or immediately if priority is off).
*/
function _proceedToDrawStep(nextIdx, activePlayer) {
if (!gameState) return;
// Auto-draw a card (Beginning Phase â Draw Step)
if (activePlayer.zones.library.length > 0) {
var drawnCard = activePlayer.zones.library.shift();
activePlayer.zones.hand.push(drawnCard);
addLogEntry('\ud83d\udce4 ' + activePlayer.name + ' drew a card for turn');
} else {
addLogEntry('\u26a0\ufe0f ' + activePlayer.name + ' cannot draw \u2014 library is empty!');
}
// Auto-advance past Beginning phase to Main 1
gameState.currentPhaseIndex = 1;
updateStormCounter();
// Track turn history
gameState.turnHistory.push({
turnNumber: gameState.turnNumber,
playerIndex: nextIdx,
playerName: activePlayer.name,
playerColor: activePlayer.color || PLAYER_COLORS[nextIdx] || '#666',
});
// Keep max 20 entries
if (gameState.turnHistory.length > 20) gameState.turnHistory.shift();
renderPhaseTracker();
renderPlayerPanels();
renderHandArea(); // Re-render hand to update phase-locked visual state
addLogEntry(`Turn ${gameState.turnNumber}: ${activePlayer.name}`);
updateTurnDisplay();
showShortcutHint();
// Feature 12: Turn timer
if (gameState.rulesEngine && gameState.rulesEngine.turnTimerEnabled) {
startTurnTimer(nextIdx);
}
// If the new active player is AI, auto-play their turn
if (activePlayer.isAI && !aiTurnActive) {
setTimeout(() => runAiTurn(), 800);
} else if (isPriorityEnabled()) {
// Open priority for Main Phase 1 start â gives all players a chance to respond
openPriorityWindow('phase');
}
}
function getCurrentPhaseName() {
if (gameState.inCombat) {
return `Combat â ${COMBAT_PHASES[gameState.combatPhaseIndex]?.label || ''}`;
}
return PHASES[gameState.currentPhaseIndex]?.label || '';
}
function renderPhaseTracker() {
renderLegalActionsBar();
const track = document.getElementById('phase-track');
if (!track || !gameState) return;
track.innerHTML = '';
// If in combat, show expanded combat sub-phases instead of the flat 5
if (gameState.inCombat && gameState.currentPhaseIndex === 2) {
// Show a condensed pre-combat indicator
const preCombat = document.createElement('div');
preCombat.className = 'phase-step phase-step-mini done';
preCombat.textContent = 'MAIN 1';
preCombat.title = 'Main Phase 1 (completed)';
preCombat.onclick = function() { jumpToPhase(1); };
track.appendChild(preCombat);
// Connector
const conn = document.createElement('div');
conn.className = 'phase-connector';
track.appendChild(conn);
// Combat sub-phases expanded
COMBAT_PHASES.forEach((cp, ci) => {
const step = document.createElement('div');
step.className = 'phase-step phase-step-combat';
const isActive = ci === gameState.combatPhaseIndex;
const isDone = ci < gameState.combatPhaseIndex;
if (isActive) step.classList.add('active');
if (isDone) step.classList.add('done');
step.innerHTML = `${cp.icon}${cp.short}`;
step.title = cp.label;
if (isActive) {
step.style.setProperty('--phase-accent', cp.color);
step.style.background = hexToRgba(cp.color, 0.18);
step.style.boxShadow = '0 0 10px ' + hexToRgba(cp.color, 0.25) + ', inset 0 0 0 1px ' + hexToRgba(cp.color, 0.25);
}
track.appendChild(step);
// Connector between sub-phases
if (ci < COMBAT_PHASES.length - 1) {
const subConn = document.createElement('div');
subConn.className = 'phase-connector' + (isDone ? ' done' : '');
track.appendChild(subConn);
}
});
// Post-combat connector + indicator
const postConn = document.createElement('div');
postConn.className = 'phase-connector';
track.appendChild(postConn);
const postCombat = document.createElement('div');
postCombat.className = 'phase-step phase-step-mini';
postCombat.textContent = 'MAIN 2';
postCombat.title = 'Main Phase 2';
postCombat.onclick = function() { jumpToPhase(3); };
track.appendChild(postCombat);
} else {
// Normal 5-phase display
PHASES.forEach((phase, i) => {
const step = document.createElement('div');
step.className = 'phase-step';
const isActive = i === gameState.currentPhaseIndex;
const isDone = i < gameState.currentPhaseIndex;
if (isActive) step.classList.add('active');
if (isDone) step.classList.add('done');
step.innerHTML = `${phase.icon}${phase.short}`;
step.title = phase.tip;
step.onclick = function() { jumpToPhase(i); };
if (isActive) {
step.style.setProperty('--phase-accent', phase.color);
step.style.background = hexToRgba(phase.color, 0.18);
step.style.boxShadow = '0 0 10px ' + hexToRgba(phase.color, 0.25) + ', inset 0 0 0 1px ' + hexToRgba(phase.color, 0.25);
}
track.appendChild(step);
// Connector between phases
if (i < PHASES.length - 1) {
const conn = document.createElement('div');
conn.className = 'phase-connector' + (isDone ? ' done' : '');
track.appendChild(conn);
}
});
}
// Land drop indicator
if (gameState && gameState.landsPlayedThisTurn !== undefined) {
var landIndicator = document.createElement('div');
landIndicator.className = 'land-drop-indicator';
var remaining = Math.max(0, gameState.maxLandsPerTurn - gameState.landsPlayedThisTurn);
landIndicator.innerHTML = 'ð ' + remaining + '/' + gameState.maxLandsPerTurn;
landIndicator.title = remaining > 0 ? 'Land drops remaining this turn' : 'No land drops remaining';
if (remaining === 0) landIndicator.classList.add('exhausted');
track.appendChild(landIndicator);
}
// Render turn history ribbon
renderTurnHistory();
}
/**
* Jump to a specific main phase index. Only allows forward movement
* within the current turn (no going back to prevent rule violations).
* Fires appropriate triggers for each skipped/entered phase.
*/
function jumpToPhase(targetIdx) {
if (!gameState) return;
// Don't allow jumping backwards (would break turn order rules)
if (targetIdx <= gameState.currentPhaseIndex) return;
// Don't allow jumping during active combat
if (gameState.inCombat) {
// Allow jumping past combat to main2 or end
if (targetIdx > 2) {
endCombat();
gameState.inCombat = false;
gameState.combatPhaseIndex = 0;
} else {
return;
}
}
pushUndoState();
// Skip from current to target, firing triggers for each intermediate phase
for (let i = gameState.currentPhaseIndex + 1; i <= targetIdx; i++) {
gameState.currentPhaseIndex = i;
const phase = PHASES[i];
if (phase) {
if (phase.id === 'combat') {
// Don't enter combat â skip it
continue;
} else if (phase.id === 'beginning') {
fireBattlefieldTriggers('upkeep');
} else if (phase.id === 'end') {
fireBattlefieldTriggers('end_step');
}
}
emptyAllManaPools();
}
renderPhaseTracker();
addLogEntry(`Phase: ${getCurrentPhaseName()} (jumped)`);
showShortcutHint();
}
/**
* Render the turn history ribbon below the phase track.
*/
function renderTurnHistory() {
const ribbon = document.getElementById('turn-history-ribbon');
if (!ribbon || !gameState || !gameState.turnHistory) return;
const history = gameState.turnHistory;
if (history.length <= 1) {
ribbon.style.display = 'none';
return;
}
ribbon.style.display = 'flex';
ribbon.innerHTML = '';
// Show recent entries (last 8)
const visible = history.slice(-8);
visible.forEach((entry, i) => {
const isCurrent = (i === visible.length - 1);
const chip = document.createElement('div');
chip.className = 'turn-history-chip' + (isCurrent ? ' current' : '');
chip.style.setProperty('--chip-color', entry.playerColor);
if (isCurrent) {
chip.style.background = hexToRgba(entry.playerColor, 0.15);
chip.style.border = '1px solid ' + hexToRgba(entry.playerColor, 0.25);
}
chip.innerHTML = `T${entry.turnNumber}`;
chip.title = `Turn ${entry.turnNumber}: ${entry.playerName}`;
ribbon.appendChild(chip);
});
}
/**
* Flash the active phase step with a trigger-fired animation.
*/
function flashActivePhaseStep() {
const activeStep = document.querySelector('.phase-step.active');
if (!activeStep) return;
activeStep.classList.remove('trigger-flash');
activeStep.offsetWidth; // force reflow
activeStep.classList.add('trigger-flash');
setTimeout(() => activeStep.classList.remove('trigger-flash'), 800);
}
function updateTurnDisplay() {
if (!gameState) return;
const turnEl = document.getElementById('turn-number');
if (turnEl) turnEl.textContent = gameState.turnNumber;
const activePlayerEl = document.getElementById('active-player-name');
if (activePlayerEl) {
const player = gameState.players[gameState.currentPlayerIndex];
activePlayerEl.textContent = player?.name || '';
}
const activeDot = document.getElementById('active-player-dot');
if (activeDot) {
const player = gameState.players[gameState.currentPlayerIndex];
activeDot.style.background = player?.color || '#666';
}
}
// ============================================================
// STORM COUNTER
// ============================================================
function updateStormCounter() {
const badge = document.getElementById('storm-counter');
if (!badge) return;
if (gameState.stormCount > 0) {
badge.textContent = `Storm: ${gameState.stormCount}`;
badge.classList.add('visible');
} else {
badge.classList.remove('visible');
}
}
// ============================================================
// ZONE MODAL
// ============================================================
// zoneModalPlayerId declared in ui.js
// activeZoneTab declared in ui.js
// _zoneCardCtx declared in ui.js
function openZoneModal(playerId) {
zoneModalPlayerId = playerId;
activeZoneTab = 'graveyard';
renderZoneModal();
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.add('visible');
}
function closeZoneModal() {
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.remove('visible');
zoneModalPlayerId = null;
hideZoneCardCtx();
}
function hideZoneCardCtx() {
if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible');
}
function renderZoneModal() {
if (zoneModalPlayerId === null) return;
const player = gameState.players[zoneModalPlayerId];
if (!player) return;
const titleEl = document.getElementById('zone-modal-title');
if (titleEl) titleEl.textContent = player.name + "'s Zones";
// Player color indicator
var playerBar = document.getElementById('zone-modal-player-bar');
if (!playerBar) {
playerBar = document.createElement('div');
playerBar.id = 'zone-modal-player-bar';
playerBar.className = 'zone-modal-player-bar';
var header = document.querySelector('.zone-modal-header');
if (header) header.parentNode.insertBefore(playerBar, header.nextSibling);
}
playerBar.innerHTML =
'' +
'' + player.name + (player.isAI ? ' (AI)' : '') + '';
// Update tabs with counts and zone-specific styling
const tabs = document.querySelectorAll('.zone-tab');
tabs.forEach(function(tab) {
var zone = tab.dataset.zone;
tab.classList.toggle('active', zone === activeZoneTab);
var count = (player.zones[zone] || []).length;
var label = zone.charAt(0).toUpperCase() + zone.slice(1);
var icons = { graveyard: '\u2620', exile: '\u2b21', command: '\u{1F451}', hand: '\u270B', library: '\uD83D\uDCDA' };
tab.innerHTML = (icons[zone] || '') + ' ' + label + ' (' + count + ')';
});
// Render zone content
const body = document.getElementById('zone-modal-body');
if (!body) return;
const cards = player.zones[activeZoneTab] || [];
if (!cards.length) {
body.innerHTML = '
';
}
grid.appendChild(panel);
}
// Fetch user-created decks from the API and populate My Decks tabs
_loadUserDecksForSetup();
}
function _loadUserDecksForSetup() {
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
fetch(apiBase + '/api/lab/decks')
.then(function(res) { return res.json(); })
.then(function(data) {
var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; });
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
var listEl = document.getElementById('setup-custom-deck-list-' + i);
if (!listEl) continue;
if (decks.length === 0) {
listEl.innerHTML = '
No saved decks. Build one in Deck Builder first.
';
continue;
}
var html = '';
for (var di = 0; di < decks.length; di++) {
var d = decks[di];
var cardCount = d.card_count || d.total_cards || 0;
var cmdr = d.commander || '';
html += '';
}
listEl.innerHTML = html;
}
})
.catch(function(e) {
console.warn('[Setup] Could not load user decks:', e);
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
var listEl = document.getElementById('setup-custom-deck-list-' + i);
if (listEl) listEl.innerHTML = '
Could not load decks. Is the server running?
';
}
});
}
function selectSetupUserDeck(playerIdx, deckId, deckName) {
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
// Highlight selected button
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
if (listEl) {
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
}
// Find clicked button and highlight it
var allBtns = listEl ? listEl.querySelectorAll('.setup-custom-deck-btn') : [];
allBtns.forEach(function(b) {
if (b.textContent.indexOf(deckName) !== -1) b.classList.add('selected');
});
// Export the deck to a .dck file, then store it as a precon-style choice
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(result) {
if (result.success) {
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
} else {
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
}
})
.catch(function(e) {
console.error('[Setup] Export failed:', e);
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
});
}
function switchSetupDeckTab(playerIdx, tab) {
var tabs = ['precon', 'mydecks'];
tabs.forEach(function(t) {
var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx);
var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx);
if (tabBtn) tabBtn.classList.remove('active');
if (content) content.classList.remove('active');
});
var activeTab = document.getElementById('setup-deck-tab-' + tab + '-' + playerIdx);
var activeContent = document.getElementById('setup-deck-content-' + tab + '-' + playerIdx);
if (activeTab) activeTab.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
function selectSetupPrecon(playerIdx) {
var sel = document.getElementById('setup-deck-precon-select-' + playerIdx);
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
if (!sel) return;
var preconId = sel.value;
if (!preconId) {
delete setupDeckChoices[playerIdx];
if (statusEl) { statusEl.textContent = 'No deck selected'; statusEl.className = 'deck-select-status'; }
return;
}
var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA.find(function(p) { return p.id === preconId; }) : null;
var preconName = precon ? precon.name : preconId;
setupDeckChoices[playerIdx] = { type: 'precon', preconId: preconId };
if (statusEl) { statusEl.textContent = 'Precon: ' + preconName; statusEl.className = 'deck-select-status loaded'; }
}
async function loadCustomDeckForPlayer(playerIdx, deckId) {
var decks = (typeof getDecks === 'function') ? getDecks() : [];
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
var player = gameState.players[playerIdx];
if (!player) return;
// Convert deck builder format to a text decklist, then use importDeckForPlayer
var lines = [];
if (deck.commander) {
lines.push('// Commander');
lines.push('1 ' + deck.commander.name);
lines.push('');
lines.push('// Deck');
}
if (deck.cards && deck.cards.length) {
deck.cards.forEach(function(c) {
lines.push((c.quantity || 1) + ' ' + c.name);
});
}
var deckText = lines.join('\n');
await importDeckForPlayer(playerIdx, deckText);
addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded custom deck "' + deck.name + '"');
}
async function loadPreconForPlayer(playerIdx, preconId) {
if (typeof PRECON_DATA === 'undefined') return;
var precon = PRECON_DATA.find(function(p) { return p.id === preconId; });
if (!precon) return;
var player = gameState.players[playerIdx];
if (!player) return;
// Try to get decklist text: embedded first, then fetch .dck from server
var decklistText = precon.decklist || '';
if (!decklistText && precon.fileName) {
// 1. Try the API endpoint (returns JSON with decklist field)
try {
var apiResp = await fetch('/api/lab/precons/deck?fileName=' + encodeURIComponent(precon.fileName));
if (apiResp.ok) {
var deckData = await apiResp.json();
if (deckData && deckData.decklist) decklistText = deckData.decklist;
}
} catch (e) {
console.warn('[loadPreconForPlayer] API deck fetch failed:', e);
}
// 2. Fallback: fetch .dck file directly from static mount
if (!decklistText) {
try {
var staticResp = await fetch('/precon-decks/' + encodeURIComponent(precon.fileName));
if (staticResp.ok) decklistText = await staticResp.text();
} catch (e2) {
console.warn('[loadPreconForPlayer] Static .dck fetch failed:', precon.fileName);
}
}
}
var parsedCards = parseDeckList(decklistText);
if (!parsedCards.length) return;
var allCardNames = [];
for (var ei = 0; ei < parsedCards.length; ei++) {
for (var q = 0; q < parsedCards[ei].qty; q++) {
allCardNames.push(parsedCards[ei].name);
}
}
var cardDataMap = await batchFetchCards(allCardNames, null);
var libraryCards = [];
var commandCards = [];
for (var ci = 0; ci < parsedCards.length; ci++) {
var entry = parsedCards[ci];
var scryfallCard = cardDataMap.get(entry.name.toLowerCase());
if (!scryfallCard) continue;
var cardObj = scryfallToZoneCard(scryfallCard);
for (var cq = 0; cq < entry.qty; cq++) {
if (entry.section === 'commander') {
commandCards.push(Object.assign({}, cardObj));
} else {
libraryCards.push(Object.assign({}, cardObj));
}
}
}
player.zones.library = [];
player.zones.hand = [];
player.zones.command = [];
commandCards.forEach(function(card) { player.zones.command.push(card); });
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
if (precon.fileName) {
player.deckFileName = precon.fileName;
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp;
}
libraryCards.forEach(function(card) { player.zones.library.push(card); });
// Draw opening hand of 7
if (player.zones.library.length >= 7) {
for (var di = 0; di < 7; di++) {
player.zones.hand.push(player.zones.library.shift());
}
}
addLogEntry('\uD83D\uDCCB ' + player.name + ' loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)');
}
async function loadUserDeckForPlayer(playerIdx, deckName) {
var player = gameState.players[playerIdx];
if (!player || !deckName) return;
// Fetch the .dck file content from user-decks/ via static mount or API
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
var deckText = '';
try {
var resp = await fetch(apiBase + '/user-decks/' + encodeURIComponent(deckName) + '.dck');
if (resp.ok) deckText = await resp.text();
} catch (e) {
console.warn('[loadUserDeckForPlayer] Could not fetch .dck file:', e);
}
if (!deckText) {
addLogEntry('\u26A0 Could not load user deck "' + deckName + '"');
return;
}
await importDeckForPlayer(playerIdx, deckText);
player.deckFileName = deckName + '.dck';
}
async function importDeckForPlayer(playerIdx, deckText) {
var player = gameState.players[playerIdx];
if (!player || !deckText.trim()) return;
var parsedCards = parseDeckList(deckText);
if (!parsedCards.length) return;
var allCardNames = [];
for (var ei = 0; ei < parsedCards.length; ei++) {
for (var q = 0; q < parsedCards[ei].qty; q++) {
allCardNames.push(parsedCards[ei].name);
}
}
var cardDataMap = await batchFetchCards(allCardNames, null);
var libraryCards = [];
var commandCards = [];
for (var ci = 0; ci < parsedCards.length; ci++) {
var entry = parsedCards[ci];
var scryfallCard = cardDataMap.get(entry.name.toLowerCase());
if (!scryfallCard) continue;
var cardObj = scryfallToZoneCard(scryfallCard);
for (var cq = 0; cq < entry.qty; cq++) {
if (entry.section === 'commander') {
commandCards.push(Object.assign({}, cardObj));
} else {
libraryCards.push(Object.assign({}, cardObj));
}
}
}
player.zones.library = [];
player.zones.hand = [];
player.zones.command = [];
commandCards.forEach(function(card) { player.zones.command.push(card); });
if (commandCards.length > 0 && !player.commanderCardName) {
player.commanderCardName = commandCards[0].name || '';
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = libraryCards[si]; libraryCards[si] = libraryCards[sj]; libraryCards[sj] = tmp;
}
libraryCards.forEach(function(card) { player.zones.library.push(card); });
// Draw opening hand of 7
if (player.zones.library.length >= 7) {
for (var di = 0; di < 7; di++) {
player.zones.hand.push(player.zones.library.shift());
}
}
addLogEntry('\uD83D\uDCCB ' + player.name + ' imported a deck (' + (libraryCards.length + commandCards.length) + ' cards)');
}
async function startGameWithDecks() {
// 1. Call existing startGame() â creates gameState, sets up AI, shows board
startGame();
// 2. Collect human players with deck choices
var humanDecks = [];
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
if (setupDeckChoices[i]) humanDecks.push({ idx: i, choice: setupDeckChoices[i] });
}
if (humanDecks.length === 0) {
// No decks to load â if player 0 has cards already, enter mulligan
if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) {
enterMulliganPhase(0);
}
setupDeckChoices = {};
return;
}
// 3. Show loading overlay
var overlay = document.createElement('div');
overlay.id = 'deck-loading-overlay';
overlay.className = 'deck-loading-overlay';
overlay.innerHTML =
'
Loading Decks...
' +
'
Fetching cards from Scryfall
';
document.body.appendChild(overlay);
try {
// 4. Process each human deck choice sequentially
for (var d = 0; d < humanDecks.length; d++) {
var hd = humanDecks[d];
var sub = document.getElementById('deck-loading-subtext');
var pName = gameState.players[hd.idx] ? gameState.players[hd.idx].name : ('Player ' + (hd.idx + 1));
if (sub) sub.textContent = 'Loading deck for ' + pName + '...';
if (hd.choice.type === 'precon') {
await loadPreconForPlayer(hd.idx, hd.choice.preconId);
} else if (hd.choice.type === 'custom') {
await loadCustomDeckForPlayer(hd.idx, hd.choice.deckId);
} else if (hd.choice.type === 'user-deck') {
await loadUserDeckForPlayer(hd.idx, hd.choice.deckName);
}
}
} catch (err) {
console.error('[startGameWithDecks] Error loading decks:', err);
}
// 5. Remove overlay
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
// 6. Clear choices, re-render, enter mulligan
setupDeckChoices = {};
if (typeof updatePlayerZones === 'function') updatePlayerZones();
renderPlayerPanels();
renderHandArea();
if (gameState.players[0] && gameState.players[0].zones.hand.length > 0) {
enterMulliganPhase(0);
}
}
function startGame() {
const names = [];
for (let i = 0; i < setupPlayerCount; i++) {
const input = document.getElementById(`player-name-${i}`);
names.push(input?.value.trim() || (setupAiPlayers.has(i) ? `AI Bot ${i + 1}` : `Player ${i + 1}`));
}
gameState = createInitialState(setupPlayerCount, names, setupStartingLife);
// Clear undo/redo stacks for new game
undoStack = [];
redoStack = [];
updateUndoRedoButtons();
// Clear token history for new game
tokenHistory = [];
// Clear AI thought history for new game
aiThoughts = [];
// Hide token summary bar on new game
var tsb = document.getElementById('token-summary-bar');
if (tsb) tsb.style.display = 'none';
// Initialize turn history with first turn
const firstPlayer = gameState.players[0];
gameState.turnHistory = [{
turnNumber: 1,
playerIndex: 0,
playerName: firstPlayer?.name || 'Player 1',
playerColor: firstPlayer?.color || PLAYER_COLORS[0] || '#666',
}];
// Mark AI players
setupAiPlayers.forEach(idx => {
if (gameState.players[idx]) {
gameState.players[idx].isAI = true;
}
});
gameState.aiDifficulty = setupAiDifficulty;
// ========================================
// Commander-AI-Lab: Initialize backend session
// ========================================
if (typeof AIBridge !== 'undefined' && AIBridge.connector.connected) {
AIBridge.gameState.initSession(gameState).then(function(session) {
if (session) {
addLogEntry('\u{1F9E0} Connected to Commander-AI-Lab (session: ' +
session.game_id.substring(0, 8) + '...)');
}
}).catch(function(err) {
console.warn('[startGame] AI Bridge session init failed:', err);
});
}
// Forge Engine: If enabled, start a Forge game session
if (typeof ForgeBridge !== 'undefined' && ForgeBridge.enabled && ForgeBridge.connected) {
gameState.forgeEnabled = true;
forgeStartGame({
players: gameState.players,
startingLife: setupStartingLife,
aiDifficulty: setupAiDifficulty,
});
// Show eval button
var evalBtn = document.getElementById('btn-forge-eval');
if (evalBtn) evalBtn.style.display = '';
} else {
gameState.forgeEnabled = false;
var evalBtn2 = document.getElementById('btn-forge-eval');
if (evalBtn2) evalBtn2.style.display = 'none';
}
// Setup AI decks (generates library, draws opening hand)
setupAiDecks();
// Fetch real Scryfall images for AI cards in background (non-blocking)
fetchAiCardImages();
// Hide setup, show board
document.getElementById('setup-screen').classList.add('hidden');
const board = document.getElementById('game-board');
board.classList.add('visible');
// Show game log sidebar
var logSidebar = document.getElementById('game-log-sidebar');
if (logSidebar) logSidebar.classList.remove('collapsed');
// Show zone panel
var zp = document.getElementById('zone-panel');
if (zp) zp.classList.remove('hidden');
initGameBoard();
// showManaPool() removed â mana pool display removed
// Set commanderCardName for each player from their command zone
gameState.players.forEach(function(p) {
if (p.zones.command && p.zones.command.length > 0) {
p.commanderCardName = p.zones.command[0].name || '';
}
});
addLogEntry(`ðŪ Game started with ${setupPlayerCount} players, ${setupStartingLife} starting life`);
renderPlayerPanels();
renderHandArea();
renderPhaseTracker();
updateTurnDisplay();
// 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 = ``;
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
}
// ============================================================
// AI STRATEGY & DECISION-MAKING HELPERS
// ============================================================
// ---- AI Helper: Evaluate board threat level (per-card) ----
function aiEvaluateBoardThreats(playerIdx, w) {
w = w || aiWeights;
var threats = [];
if (!gameState) return threats;
gameState.battlefieldCards.forEach(function(c) {
if (c.ownerId === playerIdx) return;
if (!aiIsCreature(c)) return;
var pow = getCreaturePower(c);
var tou = getCreatureToughness(c);
var score = pow + tou;
if (hasKeyword(c, 'flying')) score += w.bt_flying;
if (hasKeyword(c, 'deathtouch')) score += w.bt_deathtouch;
if (hasKeyword(c, 'lifelink')) score += w.bt_lifelink;
if (hasKeyword(c, 'double strike')) score += pow;
if (hasKeyword(c, 'first strike')) score += w.bt_firstStrike;
if (hasKeyword(c, 'trample')) score += w.bt_trample;
if (hasKeyword(c, 'hexproof')) score += w.bt_hexproof;
if (hasKeyword(c, 'indestructible')) score += w.bt_indestructible;
if (c.isCommander) score += w.bt_commander;
threats.push({ card: c, score: score });
});
threats.sort(function(a, b) { return b.score - a.score; });
return threats;
}
// ---- AI Helper: Evaluate per-PLAYER threat level (political awareness) ----
// Returns sorted array: [{ idx, name, threatScore, breakdown }] highest threat first
function aiEvaluatePlayerThreats(playerIdx, w) {
w = w || aiWeights;
if (!gameState) return [];
var playerScores = [];
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
var breakdown = {
boardPresence: 0,
creaturePower: 0,
cardAdvantage: 0,
lifeDelta: 0,
commanderThreat: 0,
momentum: 0
};
// --- Board presence: count permanents, weight creatures more ---
var theirCards = gameState.battlefieldCards.filter(function(c) { return c.ownerId === i; });
var theirCreatures = theirCards.filter(function(c) { return aiIsCreature(c); });
var theirNonCreatures = theirCards.length - theirCreatures.length;
breakdown.boardPresence = theirCreatures.length * w.pt_creatureWeight + theirNonCreatures * w.pt_nonCreatureWeight;
// --- Creature power: sum total power on board ---
var totalPower = 0;
theirCreatures.forEach(function(c) {
var pow = getCreaturePower(c);
totalPower += pow;
// Extra danger for evasive creatures
if (hasKeyword(c, 'flying')) totalPower += w.pt_flyingBonus;
if (hasKeyword(c, 'trample')) totalPower += w.pt_trampleBonus;
if (hasKeyword(c, 'double strike')) totalPower += pow;
if (hasKeyword(c, 'indestructible')) totalPower += w.pt_indestructBonus;
if (hasKeyword(c, 'hexproof')) totalPower += w.pt_hexproofBonus;
});
breakdown.creaturePower = totalPower;
// --- Card advantage: hand size relative to average ---
var handSize = p.zones.hand ? p.zones.hand.length : 0;
var avgHand = 0;
var activeCount = 0;
gameState.players.forEach(function(pp, pi) {
if (pi === playerIdx || pp.eliminated) return;
avgHand += pp.zones.hand ? pp.zones.hand.length : 0;
activeCount++;
});
avgHand = activeCount > 0 ? avgHand / activeCount : 3;
breakdown.cardAdvantage = (handSize - avgHand) * w.pt_cardAdvMultiplier;
// --- Life delta: higher life = more threat (they can afford risky plays) ---
// Also factor in how close to killing opponents they are
var startLife = gameState.startingLife || 40;
var lifeRatio = p.life / startLife;
breakdown.lifeDelta = (lifeRatio - 0.5) * w.pt_lifeDeltaScale; // 0 at half life, +4 at full, -4 at 0
// --- Commander threat: commander on battlefield is dangerous ---
var cmdOnField = theirCards.some(function(c) { return c.isCommander; });
var cmdDamage = 0;
// Check if this player has dealt commander damage to us
if (p.commanderDamageDealt) {
cmdDamage = p.commanderDamageDealt[playerIdx] || 0;
}
breakdown.commanderThreat = (cmdOnField ? w.pt_cmdOnField : 0) + (cmdDamage > 10 ? w.pt_cmdDmgHigh : cmdDamage > 5 ? w.pt_cmdDmgMid : 0);
// --- Momentum: recently-cast spells suggest active turn ---
// Use graveyard size as a proxy for spells cast
var gySize = p.zones.graveyard ? p.zones.graveyard.length : 0;
breakdown.momentum = Math.min(gySize, w.pt_momentumCap); // Cap at 5
var totalThreat = breakdown.boardPresence + breakdown.creaturePower +
breakdown.cardAdvantage + breakdown.lifeDelta +
breakdown.commanderThreat + breakdown.momentum;
playerScores.push({
idx: i,
name: p.name,
threatScore: totalThreat,
breakdown: breakdown
});
});
// Sort highest threat first
playerScores.sort(function(a, b) { return b.threatScore - a.threatScore; });
return playerScores;
}
// ---- AI Helper: Check if AI can afford a card (CMC vs untapped lands) ----
function aiCanAfford(card, playerIdx) {
var cmc = aiGetCmc(card);
var untapped = aiCountUntappedLands(playerIdx);
return untapped >= cmc;
}
// ---- AI Helper: Pick best attack target (political â attack the leader) ----
function aiPickAttackTarget(playerIdx) {
var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy';
// Easy mode: pick lowest life (old behavior)
if (difficulty === 'easy') {
var bestIdx = -1;
var lowestLife = Infinity;
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
if (p.life < lowestLife) {
lowestLife = p.life;
bestIdx = i;
}
});
return bestIdx;
}
// Normal/Hard: use threat assessment to attack the leader
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length === 0) return -1;
var leader = playerThreats[0]; // Highest threat player
// Hard mode: consider if a low-life player is close to dying (finish them off)
if (difficulty === 'hard') {
var nearDeath = playerThreats.find(function(pt) {
var p = gameState.players[pt.idx];
if (!p) return false;
// Can we lethal them this turn?
var ourPower = 0;
gameState.battlefieldCards.forEach(function(c) {
if (c.ownerId === playerIdx && aiIsCreature(c) && !c.tapped && !hasSummoningSickness(c)) {
ourPower += getCreaturePower(c);
}
});
return p.life <= ourPower && p.life <= 15;
});
if (nearDeath) {
aiAddThought(playerIdx, 'Going for the kill on ' + nearDeath.name + ' (' + gameState.players[nearDeath.idx].life + ' life)');
return nearDeath.idx;
}
}
// Log the political decision
if (leader.threatScore > 0) {
var bd = leader.breakdown;
var reasons = [];
if (bd.creaturePower >= 8) reasons.push('big board');
if (bd.commanderThreat >= 6) reasons.push('commander out');
if (bd.cardAdvantage >= 3) reasons.push('card advantage');
if (bd.lifeDelta >= 3) reasons.push('high life');
if (reasons.length > 0) {
aiAddThought(playerIdx, 'Targeting ' + leader.name + ' (leader: ' + reasons.join(', ') + ')');
}
}
return leader.idx;
}
// ---- AI Helper: Evaluate if an attack is favorable ----
function aiEvaluateAttack(attacker, defenderIdx) {
var pow = getCreaturePower(attacker);
var tou = getCreatureToughness(attacker);
// Count potential blockers
var blockers = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === defenderIdx && aiIsCreature(c) && !c.tapped;
});
// If no blockers, always good to attack
if (blockers.length === 0) return { shouldAttack: true, risk: 'none' };
// If attacker has flying and no defender has flying/reach, safe
if (hasKeyword(attacker, 'flying')) {
var canBlockFlying = blockers.some(function(b) { return hasKeyword(b, 'flying') || hasKeyword(b, 'reach'); });
if (!canBlockFlying) return { shouldAttack: true, risk: 'none' };
}
// If attacker has menace and <2 untapped creatures, safe
if (hasKeyword(attacker, 'menace') && blockers.length < 2) {
return { shouldAttack: true, risk: 'none' };
}
// Check if any blocker can kill us
var canDie = blockers.some(function(b) {
var bPow = getCreaturePower(b);
return (bPow >= tou) || hasKeyword(b, 'deathtouch');
});
// Check if we can kill any blocker
var canKill = blockers.some(function(b) {
var bTou = getCreatureToughness(b);
return pow >= bTou || hasKeyword(attacker, 'deathtouch');
});
if (!canDie) return { shouldAttack: true, risk: 'low' };
if (canDie && canKill) return { shouldAttack: true, risk: 'trade' };
if (canDie && !canKill) return { shouldAttack: false, risk: 'bad-trade' };
return { shouldAttack: true, risk: 'medium' };
}
// ---- AI Helper: Find best removal target (threat-aware, prioritize leader's cards) ----
function aiFindRemovalTarget(playerIdx, w) {
w = w || aiWeights;
var difficulty = gameState ? (gameState.aiDifficulty || 'easy') : 'easy';
var threats = aiEvaluateBoardThreats(playerIdx);
if (threats.length === 0) return null;
// Easy mode: just pick highest-threat card regardless of owner
if (difficulty === 'easy') {
for (var i = 0; i < threats.length; i++) {
var t = threats[i];
if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) continue;
return t.card;
}
return null;
}
// Normal/Hard: weight removal toward the biggest threat PLAYER's cards
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length === 0) {
// Fallback: just pick highest individual threat
for (var j = 0; j < threats.length; j++) {
if (hasKeyword(threats[j].card, 'hexproof') || hasKeyword(threats[j].card, 'shroud')) continue;
return threats[j].card;
}
return null;
}
var leaderIdx = playerThreats[0].idx;
// Score each threat card, boosting cards owned by the table leader
var scoredThreats = threats.map(function(t) {
var adjustedScore = t.score;
// Boost cards from the leading player
if (t.card.ownerId === leaderIdx) adjustedScore += w.rem_leaderBoost;
// Extra boost for commanders (removing commander is huge tempo swing)
if (t.card.isCommander) adjustedScore += w.rem_commanderBoost;
// Penalty for hexproof/shroud (untargetable)
if (hasKeyword(t.card, 'hexproof') || hasKeyword(t.card, 'shroud')) adjustedScore = -100;
// Hard mode: also consider if removing this creature prevents lethal
if (difficulty === 'hard') {
var ownerPow = getCreaturePower(t.card);
var myLife = gameState.players[playerIdx] ? gameState.players[playerIdx].life : 40;
if (ownerPow >= myLife * w.rem_lifeThreatFactor) adjustedScore += w.rem_lifeThreatBonus; // Threatens significant life
}
return { card: t.card, score: adjustedScore };
});
scoredThreats.sort(function(a, b) { return b.score - a.score; });
for (var k = 0; k < scoredThreats.length; k++) {
if (scoredThreats[k].score > 0) {
var target = scoredThreats[k].card;
aiAddThought(playerIdx, 'Removal target: ' + target.name + ' (' + (gameState.players[target.ownerId] ? gameState.players[target.ownerId].name : '?') + ')');
return target;
}
}
return null;
}
// ---- AI Helper: Should the AI use a board wipe? ----
function aiShouldBoardWipe(playerIdx, w) {
w = w || aiWeights;
var myCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx && aiIsCreature(c);
});
var enemyCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId !== playerIdx && aiIsCreature(c);
});
// Wipe if enemies have significantly more creatures than us
if (enemyCreatures.length >= myCreatures.length + w.bw_creatureAdvantage) return true;
// Or if any single enemy creature is very threatening
var bigThreat = enemyCreatures.some(function(c) {
return getCreaturePower(c) >= w.bw_bigThreatPower || hasKeyword(c, 'indestructible');
});
if (bigThreat && enemyCreatures.length > myCreatures.length) return true;
return false;
}
// ---- AI Helper: Cast commander from command zone ----
function aiTryCastCommander(playerIdx) {
if (!gameState) return false;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return false;
var cmdZone = player.zones.command;
if (!cmdZone || cmdZone.length === 0) return false;
var cmdName = player.commanderCardName || '';
if (!cmdName) return false;
var cmdIdx = -1;
for (var i = 0; i < cmdZone.length; i++) {
if (cmdZone[i].name === cmdName) { cmdIdx = i; break; }
}
if (cmdIdx === -1) return false;
var cardData = cmdZone[cmdIdx];
var baseCmc = cardData.cmc || 0;
var tax = getCommanderTax(playerIdx);
var totalCost = baseCmc + tax;
var available = aiCountUntappedLands(playerIdx);
if (available < totalCost) return false;
// Generate image if needed
if (!cardData.imageUrl) cardData.imageUrl = generateAiCardSVG(cardData);
// Use confirmCastCommander logic but adapted for AI
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
var castCard = cmdZone.splice(cmdIdx, 1)[0];
// Tap lands to pay total cost (base + tax)
var manaToPay = totalCost;
var lands = aiCountLands(playerIdx).filter(function(c) { return !c.tapped; });
for (var li = 0; li < lands.length; li++) {
if (manaToPay <= 0) break;
toggleTap(lands[li].id);
manaToPay--;
}
addCardToBattlefield(castCard.name, castCard.imageUrl || '',
castCard.typeLine || '', playerIdx, undefined, undefined,
castCard.oracleText || '',
{ manaCost: castCard.manaCost || '', pt: castCard.pt, cmc: castCard.cmc, isCommander: true });
addLogEntry('\u{1F916} \u{1F451} ' + player.name + ' cast ' + cmdName + ' from command zone (tax: +' + tax + ')');
fireBattlefieldTriggers('commander_cast');
fireBattlefieldTriggers('cast');
updatePlayerZones();
renderPlayerPanels();
return true;
}
// ---- AI Helper: Pick multiple attack targets for multiplayer ----
function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
if (!gameState) return [];
var opponents = [];
gameState.players.forEach(function(p, i) {
if (i === playerIdx || p.eliminated) return;
var theirCreatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === i && aiIsCreature(c) && !c.tapped;
});
opponents.push({
idx: i,
life: p.life,
blockerCount: theirCreatures.length,
hasFlyers: theirCreatures.some(function(c) { return hasKeyword(c, 'flying') || hasKeyword(c, 'reach'); }),
name: p.name
});
});
if (opponents.length === 0) return [];
// Use threat assessment for Normal/Hard to determine primary target
var primaryTarget;
if (difficulty === 'easy') {
// Easy: lowest life
opponents.sort(function(a, b) { return a.life - b.life; });
primaryTarget = opponents[0].idx;
} else {
// Normal/Hard: highest-threat player from political assessment
var playerThreats = aiEvaluatePlayerThreats(playerIdx);
if (playerThreats.length > 0) {
primaryTarget = playerThreats[0].idx;
} else {
opponents.sort(function(a, b) { return a.life - b.life; });
primaryTarget = opponents[0].idx;
}
}
var assignments = []; // { card, targetIdx }
if (difficulty === 'easy' || opponents.length === 1) {
// Easy/single opponent: everything at one target
creatures.forEach(function(c) {
assignments.push({ card: c, targetIdx: primaryTarget });
});
return assignments;
}
// Normal/Hard: smart multi-target allocation
// Sort opponents by threat for secondary targeting
var opponentsByThreat = opponents.slice();
var ptMap = {};
var pt2 = aiEvaluatePlayerThreats(playerIdx);
pt2.forEach(function(p) { ptMap[p.idx] = p.threatScore; });
opponentsByThreat.sort(function(a, b) { return (ptMap[b.idx] || 0) - (ptMap[a.idx] || 0); });
creatures.forEach(function(c) {
var assigned = false;
// Flying creatures -> target player with no flying/reach blockers
if (hasKeyword(c, 'flying')) {
var noFlyerDefender = opponentsByThreat.find(function(op) { return !op.hasFlyers && op.blockerCount > 0; });
if (noFlyerDefender) {
assignments.push({ card: c, targetIdx: noFlyerDefender.idx });
assigned = true;
}
}
if (!assigned) {
// Hard: if primary target is well-defended, spread to unblocked opponents
if (difficulty === 'hard' && opponents.length >= 3) {
var unblocked = opponentsByThreat.find(function(op) {
return op.idx !== primaryTarget && op.blockerCount === 0;
});
if (unblocked) {
assignments.push({ card: c, targetIdx: unblocked.idx });
assigned = true;
}
}
if (!assigned) {
assignments.push({ card: c, targetIdx: primaryTarget });
}
}
});
return assignments;
}
// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
function aiDeclareAttacks(playerIdx) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player || player.eliminated) return;
var difficulty = gameState.aiDifficulty || 'easy';
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) return;
var attackerCards = [];
if (difficulty === 'easy') {
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
}
}
}
if (attackerCards.length === 0) return;
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
fireBattlefieldTriggers('attack');
}
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return null;
for (var i = 0; i < player.zones.hand.length; i++) {
var card = player.zones.hand[i];
var text = (card.oracleText || '').toLowerCase();
if (text.indexOf('counter target') !== -1) {
// Check if can afford it
var cmc = aiGetCmc(card);
var available = aiCountUntappedLands(playerIdx);
if (available >= cmc) {
return { card: card, handIndex: i };
}
}
}
return null;
}
// ---- AI Stack Response: AI reacts to spells on the stack ----
function aiRespondToStack() {
if (!gameState || !gameState.theStack || gameState.theStack.length === 0) return;
if (!gameState.rulesEngine || !gameState.rulesEngine.useStack) return;
var topSpell = gameState.theStack[gameState.theStack.length - 1];
if (!topSpell) return;
// Check each AI player for responses
gameState.players.forEach(function(player, idx) {
if (!player.isAI || player.eliminated) return;
if (idx === topSpell.ownerIndex) return; // Don't counter own spells
var difficulty = gameState.aiDifficulty || 'easy';
// Easy AI doesn't respond to stack
if (difficulty === 'easy') return;
var counterInfo = aiCheckForCounterspell(idx);
if (!counterInfo) return;
// Evaluate if the spell is worth countering
var spellText = (topSpell.oracleText || '').toLowerCase();
var spellType = (topSpell.typeLine || '').toLowerCase();
var shouldCounter = false;
// Always counter board wipes
if (spellText.indexOf('destroy all') !== -1 || spellText.indexOf('each creature') !== -1) {
shouldCounter = true;
}
// Counter big threats (CMC >= 5)
if (topSpell.cmc >= 5 || (spellType.indexOf('creature') !== -1 && topSpell.cmc >= 4)) {
shouldCounter = true;
}
// Counter removal targeting AI's stuff (heuristic)
if (spellText.indexOf('destroy target') !== -1 || spellText.indexOf('exile target') !== -1) {
if (difficulty === 'hard') shouldCounter = true;
else shouldCounter = Math.random() > 0.4;
}
// Threat-aware: more likely to counter spells from the leading player
var playerThreats = aiEvaluatePlayerThreats(idx);
var casterIsLeader = playerThreats.length > 0 && playerThreats[0].idx === topSpell.ownerIndex;
if (casterIsLeader && topSpell.cmc >= 2) {
shouldCounter = true; // Counter leader's spells more aggressively
}
// Hard mode: counter anything that costs 3+ from any player
if (difficulty === 'hard' && topSpell.cmc >= 3) shouldCounter = true;
if (!shouldCounter) return;
// Cast the counterspell
var card = counterInfo.card;
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
// Tap lands to pay
var manaToPay = aiGetCmc(card);
var lands = aiCountLands(idx).filter(function(c) { return !c.tapped; });
for (var li = 0; li < lands.length; li++) {
if (manaToPay <= 0) break;
toggleTap(lands[li].id);
manaToPay--;
}
// Remove from hand
player.zones.hand.splice(counterInfo.handIndex, 1);
// Counter the spell (remove from stack)
var countered = gameState.theStack.pop();
// Put countered spell in owner's graveyard
var spellOwner = gameState.players[countered.ownerIndex];
if (spellOwner) {
spellOwner.zones.graveyard.push({
name: countered.name,
imageUrl: countered.imageUrl || '',
typeLine: countered.typeLine || '',
oracleText: countered.oracleText || '',
manaCost: countered.manaCost || ''
});
}
// Put counterspell in AI's graveyard
player.zones.graveyard.push(card);
addLogEntry('\u{1F916} \u{1F6AB} ' + player.name + ' counters ' + countered.name + ' with ' + card.name + '!');
aiAddThought(idx, 'Countered ' + countered.name + ' (threat level: high)');
renderStackPanel();
renderHandArea();
renderPlayerPanels();
});
}
// ---- AI Thinking Log: visible decision tracking ----
var aiThoughts = []; // Array of { playerIdx, playerName, thought, timestamp }
function aiAddThought(playerIdx, thought) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player) return;
aiThoughts.push({
playerIdx: playerIdx,
playerName: player.name,
thought: thought,
timestamp: Date.now()
});
// Keep max 50
if (aiThoughts.length > 50) aiThoughts.shift();
renderAiThoughtBubble(playerIdx, thought);
}
function renderAiThoughtBubble(playerIdx, thought) {
// Show a floating thought bubble near the player's panel
var panelEl = document.getElementById('player-panel-' + playerIdx);
if (!panelEl) return;
var existing = panelEl.querySelector('.ai-thought-bubble');
if (existing) existing.remove();
var bubble = document.createElement('div');
bubble.className = 'ai-thought-bubble';
bubble.innerHTML = '\u{1F4AD} ' + thought;
panelEl.appendChild(bubble);
// Auto-remove after 3 seconds
setTimeout(function() {
if (bubble.parentNode) bubble.remove();
}, 3500);
}
// ============================================================
// AI BATTLEFIELD ABILITIES - Mana dorks, equipment usage
// ============================================================
/**
* AI taps mana-producing creatures (mana dorks) to generate extra mana.
* Called at the start of Main Phase 1 before casting spells.
* Returns the number of extra mana produced.
*/
function aiTapManaDorks(playerIdx) {
var player = gameState.players[playerIdx];
if (!player) return 0;
var produced = 0;
var dorks = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('creature') &&
!c.tapped &&
!hasSummoningSickness(c) &&
(c.isManaProducer || (c.oracleText && /\{T\}:.*[Aa]dd \{[WUBRGC]\}/i.test(c.oracleText)));
});
if (dorks.length === 0) return 0;
dorks.forEach(function(dork) {
// Determine what color of mana to produce
var manaColor = dork.manaAbility || 'G';
// Try to detect from oracle text
if (!dork.manaAbility && dork.oracleText) {
var manaMatch = dork.oracleText.match(/[Aa]dd \{([WUBRGC])\}/);
if (manaMatch) manaColor = manaMatch[1];
}
// Tap the dork
dork.tapped = true;
var el = document.getElementById('bc-' + dork.id);
if (el) el.classList.add('tapped');
// Add mana to pool directly
var p = gameState.players[playerIdx];
if (p && p.manaPool) {
p.manaPool[manaColor] = (p.manaPool[manaColor] || 0) + 1;
}
produced++;
});
if (produced > 0) {
addLogEntry('\u{1F916} ' + player.name + ' tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : '') + ' for mana');
aiAddThought(playerIdx, 'Tapped ' + produced + ' mana dork' + (produced > 1 ? 's' : ''));
}
return produced;
}
/**
* AI equips equipment to its best unequipped creature.
* Looks for equipment on the battlefield and attaches to best candidate.
*/
function aiEquipCreatures(playerIdx) {
var player = gameState.players[playerIdx];
if (!player) return;
// Find unattached equipment the AI controls
var equipment = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('equipment') &&
!c.attachedTo;
});
if (equipment.length === 0) return;
// Find best creature to equip (prefer attackers, biggest creatures)
var creatures = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx &&
c.typeLine && c.typeLine.toLowerCase().includes('creature') &&
!c.typeLine.toLowerCase().includes('land');
});
if (creatures.length === 0) return;
// Sort by power descending - equip the biggest threats
creatures.sort(function(a, b) {
return getCreaturePower(b) - getCreaturePower(a);
});
equipment.forEach(function(equip) {
// Find equip cost from oracle text
var equipCostMatch = equip.oracleText ? equip.oracleText.match(/[Ee]quip \{(\d+)\}/) : null;
var equipCost = equipCostMatch ? parseInt(equipCostMatch[1]) : 2;
// Special case: Equip {0}
if (equip.oracleText && /[Ee]quip \{0\}/.test(equip.oracleText)) equipCost = 0;
var available = aiCountUntappedLands(playerIdx);
if (available < equipCost) return;
// Pick the best creature that doesn't already have this equipment
var target = creatures.find(function(c) {
var attachments = c.attachments || [];
return !attachments.includes(equip.id);
});
if (!target) return;
// Pay equip cost by tapping lands
if (equipCost > 0) {
var lands = gameState.battlefieldCards.filter(function(c) {
return c.ownerId === playerIdx && c.typeLine && c.typeLine.toLowerCase().includes('land') && !c.tapped;
});
for (var li = 0; li < equipCost && li < lands.length; li++) {
lands[li].tapped = true;
var landEl = document.getElementById('bc-' + lands[li].id);
if (landEl) landEl.classList.add('tapped');
}
}
// Attach equipment
equip.attachedTo = target.id;
if (!target.attachments) target.attachments = [];
target.attachments.push(equip.id);
addLogEntry('\u{1F916} ' + player.name + ' equipped ' + equip.name + ' to ' + target.name + '');
aiAddThought(playerIdx, 'Equipped ' + equip.name + ' to ' + target.name);
renderBattlefield();
});
}
// ============================================================
// AI SPELL TIMING & SEQUENCING
// ============================================================
/**
* Sort and categorize the AI's hand into play sequencing buckets.
* Returns { ramp: [], removal: [], threats: [], utility: [], instants: [] }
* Each entry has { card, index, score, cmc }
*/
function aiCategorizeHand(playerIdx, difficulty) {
var player = gameState.players[playerIdx];
if (!player) return { ramp: [], removal: [], threats: [], utility: [], instants: [] };
var result = { ramp: [], removal: [], threats: [], utility: [], instants: [] };
player.zones.hand.forEach(function(c, i) {
if (aiIsLand(c)) return;
var entry = { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)), cmc: aiGetCmc(c) };
var tl = (c.typeLine || '').toLowerCase();
var isInstant = tl.includes('instant');
if (c.isRamp) {
result.ramp.push(entry);
} else if (c.isRemoval) {
if (isInstant) result.instants.push(entry);
else result.removal.push(entry);
} else if (c.isBoardWipe) {
result.removal.push(entry);
} else if (isInstant) {
result.instants.push(entry);
} else if (tl.includes('creature')) {
result.threats.push(entry);
} else {
result.utility.push(entry);
}
});
// Sort each bucket by score descending
var sortFn = function(a, b) { return b.score - a.score; };
result.ramp.sort(sortFn);
result.removal.sort(sortFn);
result.threats.sort(sortFn);
result.utility.sort(sortFn);
result.instants.sort(sortFn);
return result;
}
/**
* Build the AI's play sequence for Main Phase 1.
* Hard mode: ramp first, then threats. Hold removal + instants for post-combat.
* Normal mode: ramp first, then best cards by score.
* Easy mode: random order.
*/
function aiBuildPlaySequence(playerIdx, difficulty) {
var categories = aiCategorizeHand(playerIdx, difficulty);
var sequence = [];
if (difficulty === 'easy') {
// Easy: just play whatever, no sequencing intelligence
var all = [].concat(categories.ramp, categories.removal, categories.threats, categories.utility, categories.instants);
// Shuffle
for (var i = all.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
}
return { mainPhase1: all, holdForPostCombat: [] };
}
if (difficulty === 'normal') {
// Normal: ramp first, then everything else by score
sequence = [].concat(categories.ramp);
var rest = [].concat(categories.threats, categories.removal, categories.utility, categories.instants);
rest.sort(function(a, b) { return b.score - a.score; });
sequence = sequence.concat(rest);
return { mainPhase1: sequence, holdForPostCombat: [] };
}
// Hard mode: sophisticated sequencing
// Phase 1: Ramp spells first, then equipment/enchantments, then threats
var phase1 = [].concat(categories.ramp);
// Add utility (equipment, enchantments) early
phase1 = phase1.concat(categories.utility);
// Add threats
phase1 = phase1.concat(categories.threats);
// Hold back: sorcery-speed removal (play post-combat to see if combat solves the problem)
// Hold back: instants (save for opponent's turn if stack is enabled)
var held = [];
// In hard mode, hold instant-speed removal and counterspells for post-combat or responses
held = held.concat(categories.instants);
// Sorcery removal goes post-combat in hard mode
held = held.concat(categories.removal);
return { mainPhase1: phase1, holdForPostCombat: held };
}
// --- AI post-combat continuation (used after human blocker pause) ---
function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postActions = [];
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
{
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
if (difficulty === 'hard' || difficulty === 'normal') {
var instantsInHand = player.zones.hand.filter(function(c) {
var tl = (c.typeLine || '').toLowerCase();
return tl.indexOf('instant') !== -1;
});
var flashCards = player.zones.hand.filter(function(c) {
var text = (c.oracleText || '').toLowerCase();
return text.indexOf('flash') !== -1;
});
var bluffCandidates = instantsInHand.concat(flashCards);
if (bluffCandidates.length > 0) {
var cheapestInstant = bluffCandidates.reduce(function(best, c) {
var cmc = aiGetCmc(c);
return cmc < aiGetCmc(best) ? c : best;
}, bluffCandidates[0]);
bluffReserve = aiGetCmc(cheapestInstant);
bluffCardName = cheapestInstant.name;
if (difficulty === 'normal' && Math.random() > 0.5) bluffReserve = 0;
}
if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) {
bluffReserve = 2;
bluffCardName = '';
}
}
if (heldCards && heldCards.length > 0) {
var heldPlayed = 0;
for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) {
var he = heldCards[hi];
if (bluffReserve > 0 && he.card.name === bluffCardName) continue;
var hci = player.zones.hand.indexOf(he.card);
if (hci === -1) continue;
var havailable = aiCountUntappedLands(playerIdx);
var hcmc = aiGetCmc(he.card);
if (havailable - hcmc < bluffReserve) continue;
if (havailable < hcmc) continue;
if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card);
playCardFromHand(playerIdx, hci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)');
heldPlayed++;
}
}
var remainingHand = player.zones.hand
.map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; })
.filter(function(e) { return !aiIsLand(e.card); })
.sort(function(a, b) { return b.score - a.score; });
for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) {
var re = remainingHand[ri];
if (bluffReserve > 0 && re.card.name === bluffCardName) continue;
var ci = player.zones.hand.indexOf(re.card);
if (ci === -1) continue;
var available = aiCountUntappedLands(playerIdx);
var cardCmc = aiGetCmc(re.card);
if (available - cardCmc < bluffReserve) continue;
if (available < cardCmc) continue;
if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card);
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)');
}
if (bluffReserve > 0) {
var untappedAfter = aiCountUntappedLands(playerIdx);
if (untappedAfter >= bluffReserve) {
if (bluffCardName) {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')');
} else {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)');
}
addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...');
}
}
}});
postDelay += 800;
}
// END TURN
postActions.push({ delay: postDelay, fn: function() {
addLogEntry('\u{1F916} ' + player.name + ' passes the turn');
aiTurnActive = false;
nextTurn();
}});
postActions.forEach(function(action) {
setTimeout(function() {
try { action.fn(); } catch(e) { console.error('[AI Post-Combat Error]', e); }
}, action.delay);
});
}
function runAiTurn() {
if (!gameState) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return;
// ========================================
// PRIORITY 1: Commander-AI-Lab ML Pipeline
// ========================================
if (typeof AIBridge !== 'undefined' &&
AIBridge.connector.connected &&
AIBridge.connector.modelLoaded) {
aiTurnActive = true;
player.aiLandPlayedThisTurn = false;
addLogEntry('\u{1F9E0} ' + player.name + ' is thinking... [ML Policy]');
aiAddThought(playerIdx, 'ML pipeline mode \u2014 consulting neural network...');
_runMlAiTurn(playerIdx).then(function(success) {
if (!success) {
addLogEntry('\u{1F916} [ML] Falling back to local AI for ' + player.name);
aiTurnActive = false;
_runLocalAiTurn();
}
}).catch(function(err) {
console.error('[runAiTurn] ML pipeline error:', err);
aiTurnActive = false;
_runLocalAiTurn();
});
return;
}
// ========================================
// PRIORITY 2: Forge Engine Hybrid
// ========================================
if (typeof ForgeBridge !== 'undefined' && ForgeBridge.enabled &&
ForgeBridge.connected && ForgeBridge.gameActive) {
aiTurnActive = true;
player.aiLandPlayedThisTurn = false;
addLogEntry('\u{1F916} ' + player.name + ' is thinking... [Forge AI]');
aiAddThought(playerIdx, 'Forge engine mode \u2014 consulting advanced AI...');
forgeRunAiTurn(playerIdx, function(success) {
if (success) {
addLogEntry('\u{1F916} ' + player.name + ' passes the turn [Forge AI]');
aiTurnActive = false;
renderPlayerPanels();
renderHandArea();
nextTurn();
} else {
addLogEntry('\u{1F916} [Forge] Falling back to local AI for ' + player.name);
aiTurnActive = false;
_runLocalAiTurn();
}
});
return;
}
// ========================================
// PRIORITY 3: Local Heuristic AI
// ========================================
_runLocalAiTurn();
}
/**
* Run an AI turn using the Commander-AI-Lab ML policy pipeline.
* Makes multiple decision requests per turn (one per phase).
* Returns true if the ML pipeline completed the turn successfully.
*/
async function _runMlAiTurn(playerIdx) {
if (!gameState) return false;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return false;
aiTurnActive = true;
player.aiLandPlayedThisTurn = false;
var difficulty = gameState.aiDifficulty || 'easy';
var turnDelay = gameState.aiSettings?.turnDelay || 800;
try {
// ââ PHASE 1: Play a land (local, immediate) ââââââââââ
var landInHand = player.zones.hand.findIndex(function(c) {
return typeof aiIsLand === 'function' && aiIsLand(c);
});
if (landInHand !== -1) {
var landCard = player.zones.hand[landInHand];
if (landCard && !landCard.imageUrl && typeof generateAiCardSVG === 'function') {
landCard.imageUrl = generateAiCardSVG(landCard);
}
playCardFromHand(playerIdx, landInHand);
player.aiLandPlayedThisTurn = true;
addLogEntry('\u{1F9E0} ' + player.name + ' played a land [ML]');
await _mlDelay(turnDelay * 0.5);
}
// ââ PHASE 2: Main Phase 1 decisions ââââââââââââââââââ
var maxActions = difficulty === 'easy' ? 1 : difficulty === 'normal' ? 3 : 5;
var actionsPlayed = 0;
for (var i = 0; i < maxActions; i++) {
var decision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx);
if (!decision) break;
aiAddThought(playerIdx,
'ML decision: ' + decision.action +
' (' + Math.round(decision.confidence * 100) + '% conf, ' +
decision.inference_ms.toFixed(1) + 'ms)');
// Display probability distribution
if (decision.probabilities) {
var topActions = Object.entries(decision.probabilities)
.sort(function(a, b) { return b[1] - a[1]; })
.slice(0, 3)
.map(function(e) { return e[0] + ':' + Math.round(e[1] * 100) + '%'; })
.join(', ');
aiAddThought(playerIdx, 'Top actions: ' + topActions);
}
// Skip if action is pass/hold
if (decision.action === 'pass' || decision.action === 'hold_mana') {
aiAddThought(playerIdx, 'ML chose to ' +
(decision.action === 'pass' ? 'pass' : 'hold mana open'));
break;
}
// Skip combat action in main phase â save for combat
if (decision.action === 'attack_opponent') {
aiAddThought(playerIdx, 'ML wants to attack \u2014 saving for combat phase');
break;
}
var executed = AIBridge.MacroActionMapper.execute(
decision.action, playerIdx, gameState, decision
);
if (executed) {
actionsPlayed++;
// Push state update to backend
AIBridge.gameState.pushState(gameState);
AIBridge.gameState.pushDecision(decision);
await _mlDelay(turnDelay);
} else {
// Action couldn't execute â likely no valid targets
aiAddThought(playerIdx,
'Could not execute ' + decision.action + ' \u2014 moving on');
break;
}
}
// ââ PHASE 3: Combat ââââââââââââââââââââââââââââââââââ
var combatDecision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx);
if (combatDecision && combatDecision.action === 'attack_opponent') {
AIBridge.MacroActionMapper.execute(
'attack_opponent', playerIdx, gameState, combatDecision
);
await _mlDelay(turnDelay);
}
// ââ PHASE 4: Post-combat main phase 2 ââââââââââââââââ
var postDecision = await AIBridge.gameState.requestAiDecision(gameState, playerIdx);
if (postDecision &&
postDecision.action !== 'pass' &&
postDecision.action !== 'hold_mana' &&
postDecision.action !== 'attack_opponent') {
AIBridge.MacroActionMapper.execute(
postDecision.action, playerIdx, gameState, postDecision
);
await _mlDelay(turnDelay * 0.5);
}
// ââ PHASE 5: End turn ââââââââââââââââââââââââââââââââ
addLogEntry('\u{1F9E0} ' + player.name + ' ends their turn [ML]');
aiTurnActive = false;
renderPlayerPanels();
renderHandArea();
nextTurn();
return true;
} catch (err) {
console.error('[_runMlAiTurn] Error:', err);
aiTurnActive = false;
return false;
}
}
function _mlDelay(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
function _runLocalAiTurn() {
if (!gameState) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (!player || !player.isAI) return;
aiTurnActive = true;
player.aiLandPlayedThisTurn = false;
var difficulty = gameState.aiDifficulty || 'easy';
addLogEntry('\u{1F916} ' + player.name + ' is thinking...');
aiAddThought(playerIdx, difficulty.charAt(0).toUpperCase() + difficulty.slice(1) + ' mode \u2014 evaluating options...');
// Log threat assessment for Normal/Hard
if (difficulty !== 'easy') {
var threatReport = aiEvaluatePlayerThreats(playerIdx);
if (threatReport.length > 0) {
var topThreat = threatReport[0];
var threatSummary = 'Threat assessment: ' + threatReport.map(function(t) {
return t.name + ' (' + Math.round(t.threatScore) + ')';
}).join(' > ');
aiAddThought(playerIdx, threatSummary);
}
// Forge-inspired board analysis
if (typeof AIBoardAnalyzer !== 'undefined') {
var boardScore = AIBoardAnalyzer.evaluateBoardState(playerIdx);
var archInfo = AIBoardAnalyzer.detectArchetype(playerIdx);
aiAddThought(playerIdx, 'Board eval: ' + boardScore + ' | Archetype: ' + archInfo.archetype + ' (' + archInfo.confidence + '% conf)');
// Log active synergies
var synergies = archInfo.synergies || [];
if (synergies.length > 0) {
aiAddThought(playerIdx, 'Synergies: ' + synergies.map(function(s) { return s.description; }).join(', '));
}
// Strategic advice for hard mode
if (difficulty === 'hard') {
var advice = AIBoardAnalyzer.getStrategicAdvice(playerIdx);
var highPriority = advice.filter(function(a) { return a.priority === 'high'; });
if (highPriority.length > 0) {
aiAddThought(playerIdx, 'Strategy: ' + highPriority[0].text);
}
}
}
}
var actions = []; // queue of { delay, fn }
var delay = 600;
// ========================================
// MAIN PHASE 1: Play a land via playCardFromHand()
// ========================================
var landInHand = player.zones.hand.findIndex(function(c) { return aiIsLand(c); });
if (landInHand !== -1) {
actions.push({ delay: delay, fn: function() {
// Ensure card has imageUrl for battlefield rendering
var landCard = player.zones.hand[landInHand];
if (landCard && !landCard.imageUrl) landCard.imageUrl = generateAiCardSVG(landCard);
// Use playCardFromHand to integrate with mana system, triggers, land-drop tracking
playCardFromHand(playerIdx, landInHand);
addLogEntry('\u{1F916} ' + player.name + ' played a land');
}});
delay += 800;
}
// ========================================
// MAIN PHASE 1: Try to cast commander from command zone
// ========================================
var cmdZone = player.zones.command || [];
if (cmdZone.length > 0) {
actions.push({ delay: delay, fn: function() {
var result = aiTryCastCommander(playerIdx);
if (result) {
var tax = getCommanderTax(playerIdx);
aiAddThought(playerIdx, 'Cast commander! (tax: ' + (tax > 0 ? '+' + (tax - 2) : '0') + ')');
} else {
var needed = (cmdZone[0] ? cmdZone[0].cmc || 0 : 0) + getCommanderTax(playerIdx);
var have = aiCountUntappedLands(playerIdx);
if (have < needed) {
aiAddThought(playerIdx, 'Need ' + needed + ' mana for commander, have ' + have);
}
}
}});
delay += 800;
}
// ========================================
// MAIN PHASE 1: Tap mana dorks for extra mana (before casting)
// ========================================
actions.push({ delay: delay, fn: function() {
aiTapManaDorks(playerIdx);
}});
delay += 400;
// ========================================
// MAIN PHASE 1: Cast spells from hand using playCardFromHand()
// Uses smart sequencing: ramp > threats > hold removal for post-combat
// ========================================
var playSequence = aiBuildPlaySequence(playerIdx, difficulty);
var phase1Cards = playSequence.mainPhase1;
var heldCards = playSequence.holdForPostCombat;
// Also check board threats for emergency overrides
var boardThreats = aiEvaluateBoardThreats(playerIdx);
var biggestThreat = boardThreats.length > 0 ? boardThreats[0] : null;
// If there's a massive threat, override and play removal in Main Phase 1
if (biggestThreat && biggestThreat.score >= 10 && difficulty === 'hard') {
// Move removal from held back to phase1
phase1Cards = phase1Cards.concat(heldCards.filter(function(e) { return e.card.isRemoval || e.card.isBoardWipe; }));
heldCards = heldCards.filter(function(e) { return !e.card.isRemoval && !e.card.isBoardWipe; });
aiAddThought(playerIdx, 'Critical threat detected - using removal now!');
}
// Boost board wipe scores if significantly behind
if (aiShouldBoardWipe(playerIdx)) {
phase1Cards.forEach(function(e) { if (e.card.isBoardWipe) e.score += 30; });
phase1Cards.sort(function(a, b) { return b.score - a.score; });
}
// Decide how many spells to play based on difficulty
var maxSpells = difficulty === 'easy' ? 1 : difficulty === 'normal' ? 3 : 5;
var spellsQueued = 0;
for (var si = 0; si < phase1Cards.length; si++) {
if (spellsQueued >= maxSpells) break;
var entry = phase1Cards[si];
var cardRef = entry.card;
var cmc = aiGetCmc(cardRef);
(function(theCardRef, theCmc) {
actions.push({ delay: delay, fn: function() {
// Re-find card in hand at action time (indices shift as cards are played)
var ci = player.zones.hand.indexOf(theCardRef);
if (ci === -1) return;
var available = aiCountUntappedLands(playerIdx);
if (available < theCmc) return;
// Ensure card has imageUrl and manaCost for rendering and mana payment
if (!theCardRef.imageUrl) theCardRef.imageUrl = generateAiCardSVG(theCardRef);
// Use playCardFromHand() which integrates with:
// - attemptManaPay() (auto-tap + mana spend)
// - Stack system (if useStack is enabled)
// - Triggers and battlefield placement
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + theCardRef.name + '');
aiAddThought(playerIdx, 'Casting ' + theCardRef.name + (theCardRef.isRemoval ? ' (removal!)' : theCardRef.isBoardWipe ? ' (board wipe!)' : ''));
}});
})(cardRef, cmc);
delay += 1000;
spellsQueued++;
}
// ========================================
// MAIN PHASE 1: Equip equipment to creatures
// ========================================
if (difficulty !== 'easy') {
actions.push({ delay: delay, fn: function() {
aiEquipCreatures(playerIdx);
}});
delay += 600;
}
// ========================================
// COMBAT PHASE: Smart attack decisions with multi-target
// ========================================
var shouldAttack = difficulty === 'easy' ? Math.random() > 0.3 : true;
if (shouldAttack) {
actions.push({ delay: delay, fn: function() {
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) {
aiAddThought(playerIdx, 'No creatures ready to attack');
return;
}
// Smart attacker selection based on difficulty
var attackerCards = [];
if (difficulty === 'easy') {
// Easy: random subset
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
// For evaluation, pick a primary target first
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
// Normal/Hard: evaluate each attack
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
// Hard mode: hold back 1-2 creatures as blockers
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
aiAddThought(playerIdx, 'Holding back ' + holdBack.name + ' to block');
}
}
}
if (attackerCards.length === 0) {
aiAddThought(playerIdx, 'No favorable attacks available');
return;
}
// Use multi-target attack allocation in multiplayer
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
// Set up combat state with potentially multiple targets
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
// Tap attackers (unless vigilance)
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
// Log attacks grouped by target
var targetGroups = {};
assignments.forEach(function(a) {
if (!targetGroups[a.targetIdx]) targetGroups[a.targetIdx] = [];
targetGroups[a.targetIdx].push(a.card.name);
});
var targetCount = Object.keys(targetGroups).length;
for (var tIdx in targetGroups) {
var targetName = gameState.players[tIdx] ? gameState.players[tIdx].name : '?';
var names = targetGroups[tIdx].join(', ');
addLogEntry('\u{1F916} \u2694 ' + player.name + ' attacks ' + targetName + ' with: ' + names);
}
if (targetCount > 1) {
aiAddThought(playerIdx, 'Splitting attacks across ' + targetCount + ' opponents');
} else {
aiAddThought(playerIdx, 'Attacking with ' + attackerCards.length + ' creature' + (attackerCards.length > 1 ? 's' : ''));
}
fireBattlefieldTriggers('attack');
}});
delay += 800;
// Let any AI defenders assign blockers, then check for human defenders
actions.push({ delay: delay, fn: function() {
if (gameState.combat.attackers.length === 0) return;
// AI defenders assign blockers first
aiAssignBlockers();
// Check if any human player is being attacked
var defenderSet = {};
gameState.combat.attackers.forEach(function(a) { defenderSet[a.targetPlayerId] = true; });
var humanDefended = false;
for (var dIdx in defenderSet) {
var def = gameState.players[dIdx];
if (def && !def.isAI && !def.eliminated) { humanDefended = true; break; }
}
if (humanDefended) {
// PAUSE: Show blocker UI for human player, store resume callback
gameState.combat.phase = 'declaring-blockers';
gameState.combat.pendingBlockerFor = null;
showCombatOverlay();
refreshCombatOverlay();
addLogEntry('\u{1F6E1} Declare your blockers!');
// Store the resume function â confirmBlockers will call it
_aiBlockerPauseState = {
playerIdx: playerIdx,
resumeFn: function() {
_aiBlockerPauseState = null;
// Continue with damage resolution
if (gameState.combat.attackers.length > 0) {
resolveCombatDamage();
setTimeout(function() {
endCombat();
renderPlayerPanels();
// Continue with Main Phase 2 and End Turn
_aiRunPostCombat(playerIdx, player, difficulty, heldCards);
}, 600);
} else {
_aiRunPostCombat(playerIdx, player, difficulty, heldCards);
}
}
};
// STOP the action queue here â remaining actions are handled by resumeFn
return '__PAUSE_AI_TURN__';
}
}});
delay += 600;
// Resolve combat damage (only runs if not paused for human blockers)
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
if (gameState.combat.attackers.length > 0) {
resolveCombatDamage();
setTimeout(function() {
endCombat();
renderPlayerPanels();
}, 600);
}
}});
delay += 1000;
}
// ========================================
// MAIN PHASE 2: Post-combat spell casting
// Hard: play held-back removal + instants + any remaining affordable spells
// Normal/Easy: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
{
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
var bluffReserve = 0;
var bluffCardName = '';
if (difficulty === 'hard' || difficulty === 'normal') {
// Check for instant-speed cards in hand we want to bluff with
var instantsInHand = player.zones.hand.filter(function(c) {
var tl = (c.typeLine || '').toLowerCase();
return tl.indexOf('instant') !== -1;
});
// Also check for cards with flash
var flashCards = player.zones.hand.filter(function(c) {
var text = (c.oracleText || '').toLowerCase();
return text.indexOf('flash') !== -1;
});
var bluffCandidates = instantsInHand.concat(flashCards);
if (bluffCandidates.length > 0) {
// Pick the cheapest instant to bluff with
var cheapestInstant = bluffCandidates.reduce(function(best, c) {
var cmc = aiGetCmc(c);
return cmc < aiGetCmc(best) ? c : best;
}, bluffCandidates[0]);
bluffReserve = aiGetCmc(cheapestInstant);
bluffCardName = cheapestInstant.name;
// Hard mode: always bluff if possible. Normal: 50% chance
if (difficulty === 'normal' && Math.random() > 0.5) {
bluffReserve = 0;
}
}
// Even without instants in hand, hard mode sometimes bluffs with 2 open mana
if (bluffReserve === 0 && difficulty === 'hard' && Math.random() > 0.6) {
bluffReserve = 2;
bluffCardName = ''; // Pure bluff, no actual card
}
}
// First: play held-back cards (hard mode sequencing)
if (heldCards && heldCards.length > 0) {
var heldPlayed = 0;
for (var hi = 0; hi < heldCards.length && heldPlayed < 3; hi++) {
var he = heldCards[hi];
// Don't play the card we're bluffing with
if (bluffReserve > 0 && he.card.name === bluffCardName) continue;
var hci = player.zones.hand.indexOf(he.card);
if (hci === -1) continue;
var havailable = aiCountUntappedLands(playerIdx);
var hcmc = aiGetCmc(he.card);
// Reserve mana for bluff
if (havailable - hcmc < bluffReserve) continue;
if (havailable < hcmc) continue;
if (!he.card.imageUrl) he.card.imageUrl = generateAiCardSVG(he.card);
playCardFromHand(playerIdx, hci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + he.card.name + ' (post-combat)');
if (he.card.isRemoval) {
aiAddThought(playerIdx, 'Using held removal: ' + he.card.name);
}
heldPlayed++;
}
}
// Then: play any remaining affordable spells from hand (respecting bluff reserve)
var remainingHand = player.zones.hand
.map(function(c, i) { return { card: c, index: i, score: (typeof aiScoreCardEnhanced === 'function' ? aiScoreCardEnhanced(c, difficulty) : aiScoreCard(c, difficulty)) }; })
.filter(function(e) { return !aiIsLand(e.card); })
.sort(function(a, b) { return b.score - a.score; });
for (var ri = 0; ri < remainingHand.length && ri < 2; ri++) {
var re = remainingHand[ri];
// Don't play bluff card
if (bluffReserve > 0 && re.card.name === bluffCardName) continue;
var ci = player.zones.hand.indexOf(re.card);
if (ci === -1) continue;
var available = aiCountUntappedLands(playerIdx);
var cardCmc = aiGetCmc(re.card);
// Reserve mana for bluff
if (available - cardCmc < bluffReserve) continue;
if (available < cardCmc) continue;
if (!re.card.imageUrl) re.card.imageUrl = generateAiCardSVG(re.card);
playCardFromHand(playerIdx, ci);
addLogEntry('\u{1F916} ' + player.name + ' cast ' + re.card.name + ' (post-combat)');
}
// Log bluff status
if (bluffReserve > 0) {
var untappedAfter = aiCountUntappedLands(playerIdx);
if (untappedAfter >= bluffReserve) {
if (bluffCardName) {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (has ' + bluffCardName + ')');
} else {
aiAddThought(playerIdx, 'Holding ' + untappedAfter + ' mana open (bluffing)');
}
addLogEntry('\u{1F916} ' + player.name + ' passes with ' + untappedAfter + ' mana open...');
}
}
}});
delay += 800;
}
// ========================================
// END TURN
// ========================================
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip â human blocker flow handles this
addLogEntry('\u{1F916} ' + player.name + ' passes the turn');
aiTurnActive = false;
nextTurn();
}});
// Execute queued actions with delays (wrapped in try/catch so aiTurnActive resets on error)
actions.forEach(function(action) {
setTimeout(function() {
try { action.fn(); } catch(e) { console.error('[AI Turn Error]', e); }
}, action.delay);
});
// Safety timeout: if aiTurnActive is still true after all actions should be done, force-reset it
var safetyDelay = delay + 5000;
setTimeout(function() {
if (aiTurnActive && !_aiBlockerPauseState) {
console.warn('[AI] Safety timeout - resetting aiTurnActive');
aiTurnActive = false;
}
}, safetyDelay);
}
// ============================================================
// ============================================================
// CARD ABILITIES & RULES TEXT PARSING ENGINE
// ============================================================
/**
* Parse all abilities from a card's oracle text.
* Returns a structured object with keyword, activated, triggered, static, and mana abilities.
*/
function parseCardAbilities(oracleText, typeLine, cardName) {
if (!oracleText && !typeLine) return { keywords: [], activated: [], triggered: [], static: [], mana: [], loyalty: [], replacement: [] };
const text = oracleText || '';
const type = (typeLine || '').toLowerCase();
const result = {
keywords: [],
activated: [],
triggered: [],
static: [],
mana: [],
loyalty: [],
replacement: [],
};
// --- 1. KEYWORD ABILITIES ---
const textLower = text.toLowerCase();
for (const [keyword, data] of Object.entries(KEYWORD_ABILITIES)) {
const kw = keyword.toLowerCase();
// Match keyword as standalone word or at start of a sentence
const re = new RegExp(`(?:^|[,;.\\n\\s])${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\b|\\s|,|\\.|$)`, 'i');
if (re.test(textLower) || re.test(',' + textLower)) {
result.keywords.push({
name: keyword,
icon: data.icon,
color: data.color,
description: data.desc,
});
}
}
// --- 2. ACTIVATED ABILITIES ---
// Pattern: "[cost]: [effect]" where cost contains mana symbols or {T}
const lines = text.split('\n');
for (const line of lines) {
// Activated abilities have a colon separating cost from effect
// Match patterns like "{2}{B}, {T}: Do something" or "{T}: Add {G}"
const activatedMatch = line.match(/^([^:"]+(?:\{[^}]+\})[^:]*?):\s*(.+)$/i);
if (activatedMatch) {
const costPart = activatedMatch[1].trim();
const effectPart = activatedMatch[2].trim();
// Verify it has a mana symbol or tap symbol in cost
if (/\{[WUBRGCTX0-9]+\}/.test(costPart) || /sacrifice|discard|pay|exile|remove|tap an untapped/i.test(costPart)) {
// Check if it's a mana ability (produces mana)
if (/add \{[WUBRGC]\}/i.test(effectPart) || /add one mana/i.test(effectPart)) {
result.mana.push({
cost: costPart,
effect: effectPart,
type: 'mana',
});
} else {
result.activated.push({
cost: costPart,
effect: effectPart,
type: 'activated',
});
}
}
}
}
// --- 3. TRIGGERED ABILITIES ---
const triggerPatterns = [
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+enters(?:\s+the\s+battlefield)?)/gi, sub: 'etb' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+leaves\s+the\s+battlefield)/gi, sub: 'ltb' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+dies)/gi, sub: 'dies' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+attacks)/gi, sub: 'attack' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+blocks)/gi, sub: 'block' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes?\s+(?:the\s+)?target)/gi, sub: 'targeted' },
{ re: /\b(when(?:ever)?\s+(?:you|a player)\s+(?:cast|draw|discard|gain|lose|pay))/gi, sub: 'action' },
{ re: /\b(at the beginning of (?:your|each|the)\s+(?:upkeep|end step|draw step|combat|main phase|untap step))/gi, sub: 'upkeep' },
{ re: /\b(when(?:ever)?\s+(?:a|another|one or more)\s+(?:creature|permanent|artifact|enchantment|land|nontoken))/gi, sub: 'permanent' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+deals\s+(?:combat\s+)?damage)/gi, sub: 'damage' },
{ re: /\b(when(?:ever)?\s+(?:this|~|[^,]+?)\s+becomes\s+(?:tapped|untapped))/gi, sub: 'tap' },
];
for (const { re, sub } of triggerPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
// Grab the rest of the sentence for context
const startIdx = match.index;
let endIdx = text.indexOf('.', startIdx + match[0].length);
if (endIdx === -1) endIdx = text.indexOf('\n', startIdx + match[0].length);
if (endIdx === -1) endIdx = text.length;
const fullAbilityText = text.substring(startIdx, endIdx + 1).trim();
result.triggered.push({
trigger: match[1].trim(),
effect: fullAbilityText,
subType: sub,
type: 'triggered',
});
}
}
// --- 4. STATIC ABILITIES ---
const staticPatterns = [
{ re: /\b(other\s+(?:creatures?|permanents?)\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' },
{ re: /\b(creatures?\s+you\s+control\s+(?:get|have|gain)[^.]+)/gi, sub: 'anthem' },
{ re: /\b((?:creatures?|spells?)\s+(?:you\s+cast\s+)?cost\s+\{?\d+\}?\s+(?:less|more))/gi, sub: 'cost-mod' },
{ re: /\b(you\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' },
{ re: /\b(players?\s+(?:can't|cannot)\s+[^.]+)/gi, sub: 'restriction' },
{ re: /\b((?:this|~|enchanted|equipped)\s+(?:creature|permanent)\s+(?:gets?|has|gains)\s+[^.]+)/gi, sub: 'buff' },
{ re: /\b((?:each|all)\s+(?:creatures?|opponents?|players?)\s+[^.]*(?:get|have|gain|lose|deal|take)[^.]+)/gi, sub: 'global' },
{ re: /\b((?:as long as|while)\s+[^,]+,\s+[^.]+)/gi, sub: 'conditional' },
];
for (const { re, sub } of staticPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
result.static.push({
text: match[1].trim(),
subType: sub,
type: 'static',
});
}
}
// --- 5. LOYALTY ABILITIES (Planeswalkers) ---
if (type.includes('planeswalker')) {
const loyaltyRe = /([+\-\u2212]\d+|0):\s*([^\n]+)/g;
let lm;
while ((lm = loyaltyRe.exec(text)) !== null) {
result.loyalty.push({
cost: lm[1].replace('\u2212', '-'),
effect: lm[2].trim(),
type: 'loyalty',
});
}
}
// --- 6. REPLACEMENT EFFECTS ---
const replacementPatterns = [
/\b(if .+ would .+, .+ instead[^.]*)/gi,
/\b(as .+ enters the battlefield[^.]*)/gi,
/\b(.+ enters the battlefield (?:tapped|with)[^.]*)/gi,
];
for (const re of replacementPatterns) {
let match;
re.lastIndex = 0;
while ((match = re.exec(text)) !== null) {
result.replacement.push({
text: match[1].trim(),
type: 'replacement',
});
}
}
return result;
}
/**
* Get a compact badge set for battlefield card display.
* Returns max 4 most important keyword icons.
*/
function getKeywordBadges(cardData) {
if (!cardData || !cardData.oracleText) return [];
const abilities = parseCardAbilities(cardData.oracleText, cardData.typeLine, cardData.name);
// Priority order for display (combat-relevant first)
const priorityOrder = [
'flying', 'deathtouch', 'lifelink', 'first strike', 'double strike',
'trample', 'haste', 'vigilance', 'menace', 'hexproof', 'indestructible',
'reach', 'flash', 'infect', 'toxic', 'ward', 'defender', 'shroud',
'prowess', 'annihilator', 'cascade', 'undying', 'persist',
];
const badges = [];
for (const kw of priorityOrder) {
const found = abilities.keywords.find(k => k.name === kw);
if (found) badges.push(found);
if (badges.length >= 4) break;
}
// If less than 4, add remaining keywords not in priority list
if (badges.length < 4) {
for (const kw of abilities.keywords) {
if (!badges.find(b => b.name === kw.name)) {
badges.push(kw);
if (badges.length >= 4) break;
}
}
}
return badges;
}
/**
* Convert mana symbols in text to styled HTML spans.
*/
function formatManaSymbols(text) {
if (!text) return '';
return text.replace(/\{([^}]+)\}/g, (match) => {
const sym = MANA_DISPLAY_MAP[match];
if (sym) {
return `${sym.text}`;
}
return `${match.replace(/[{}]/g, '')}`;
});
}
/**
* Format oracle text with mana symbols, line breaks, and ability highlighting.
*/
function formatOracleText(text) {
if (!text) return 'Vanilla (no abilities)';
const escaped = text.replace(//g, '>');
let formatted = formatManaSymbols(escaped);
// Add line breaks
formatted = formatted.replace(/\n/g, ' ');
// Highlight keyword abilities in bold
for (const kw of Object.keys(KEYWORD_ABILITIES)) {
const re = new RegExp(`\\b(${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi');
formatted = formatted.replace(re, '$1');
}
return formatted;
}
/**
* Build the full abilities detail panel HTML for a card.
*/
function buildAbilitiesPanel(cardData) {
const abilities = parseCardAbilities(cardData.oracleText || '', cardData.typeLine || '', cardData.name || '');
const sections = [];
// Card identity header
const isCreature = (cardData.typeLine || '').toLowerCase().includes('creature');
const pt = cardData.pt || (cardData.extraData && cardData.extraData.pt) || '';
let headerInfo = `
`;
headerInfo += `
${cardData.name || 'Unknown Card'}
`;
headerInfo += `
${cardData.typeLine || 'Unknown Type'}
`;
if (isCreature && pt) headerInfo += `
${pt}
`;
headerInfo += `
`;
sections.push(headerInfo);
// Oracle text with formatted mana symbols
sections.push(`
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 += `
';
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 = '
Keyword Reference
';
html += '
';
// Sort alphabetically
const sortedKeywords = Object.entries(KEYWORD_ABILITIES).sort((a, b) => a[0].localeCompare(b[0]));
for (const [name, data] of sortedKeywords) {
if (!data.desc) continue; // Skip empty descriptions
html += `
`;
html += `${data.icon}`;
html += `${name.charAt(0).toUpperCase() + name.slice(1)}`;
html += `${data.desc}`;
html += `
`;
}
html += '
';
panel.innerHTML = html;
panel.style.display = 'block';
// Search filter
const searchInput = document.getElementById('kref-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const q = e.target.value.toLowerCase();
document.querySelectorAll('.kref-item').forEach(item => {
const kw = item.dataset.kw || '';
const desc = item.querySelector('.kref-desc')?.textContent || '';
item.style.display = (kw.includes(q) || desc.toLowerCase().includes(q)) ? '' : 'none';
});
});
searchInput.focus();
}
}
// ============================================================
// COMBAT SYSTEM
// ============================================================
/**
* Checks if a card's oracleText contains a given MTG keyword.
* Case-insensitive, matches whole words.
*/
function hasKeyword(cardData, keyword) {
if (!cardData) return false;
var kw = keyword.toLowerCase();
// Check temporary keywords from CardScript system
if (cardData.tempKeywords && cardData.tempKeywords.length > 0) {
for (var i = 0; i < cardData.tempKeywords.length; i++) {
if (cardData.tempKeywords[i].toLowerCase() === kw) return true;
}
}
if (!cardData.oracleText) return false;
const text = cardData.oracleText.toLowerCase();
// Match keyword as a standalone word (not substring of another word)
const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
return re.test(text);
}
/** Parse power as integer from a pt string like "3/4" */
function getCreaturePower(cardData) {
if (!cardData) return 1;
// Use getEffectivePT for full calculation including counters, equipment, anthems
var ept = getEffectivePT(cardData);
if (ept) return Math.max(0, ept.power);
// Fallback if getEffectivePT returns null
var base = 0;
if (cardData.pt) {
var parts = cardData.pt.split('/');
var val = parseInt(parts[0], 10);
if (!isNaN(val)) base = val;
} else if (cardData.power !== undefined && cardData.power !== '') {
var val2 = parseInt(cardData.power, 10);
if (!isNaN(val2)) base = val2;
}
if (cardData.powerMod) base += cardData.powerMod;
return Math.max(0, base);
}
/** Parse toughness as integer from a pt string like "3/4" */
function getCreatureToughness(cardData) {
if (!cardData) return 1;
// Use getEffectivePT for full calculation including counters, equipment, anthems
var ept = getEffectivePT(cardData);
if (ept) {
// If modifiers reduced toughness below base, allow 0 or negative (for SBA)
// But if no modifiers at all, floor at 1 to avoid false positives from bad data
if (ept.bonusTough < 0) {
return ept.toughness; // Allow negative â SBA should catch this
}
return Math.max(1, ept.toughness);
}
// Fallback if getEffectivePT returns null
var base = 1;
if (cardData.pt) {
var parts = cardData.pt.split('/');
var val = parseInt(parts[1], 10);
if (!isNaN(val)) base = val;
} else if (cardData.toughness !== undefined && cardData.toughness !== '') {
var val2 = parseInt(cardData.toughness, 10);
if (!isNaN(val2)) base = val2;
}
if (cardData.toughnessMod) base += cardData.toughnessMod;
return Math.max(1, base);
}
/**
* Summoning sickness: creature that entered this turn cannot attack
* unless it has haste. Only applies to creatures.
*/
function hasSummoningSickness(cardData) {
if (!cardData || !cardData.typeLine) return false;
if (!cardData.typeLine.toLowerCase().includes('creature')) return false;
if (!gameState) return false;
const currentKey = `${gameState.turnNumber}_${gameState.currentPlayerIndex}`;
if (cardData.turnPlayed !== currentKey) return false;
return !hasKeyword(cardData, 'haste');
}
// ---- Combat Overlay UI ----
function showCombatOverlay() {
const overlay = document.getElementById('combat-overlay');
if (overlay) overlay.style.display = 'flex';
}
function hideCombatOverlay() {
const overlay = document.getElementById('combat-overlay');
if (overlay) overlay.style.display = 'none';
}
/**
* Refresh the combat overlay display with current state.
*/
function refreshCombatOverlay() {
if (!gameState) return;
const combat = gameState.combat;
const phase = combat.phase;
const phaseLabel = document.getElementById('combat-phase-label');
const instructions = document.getElementById('combat-instructions');
const attackersList = document.getElementById('combat-attackers-list');
const targetSelector = document.getElementById('combat-target-selector');
const blockersSection = document.getElementById('combat-blockers-section');
const blockingPairs = document.getElementById('combat-blocking-pairs');
const dmgPreview = document.getElementById('combat-damage-preview');
const confirmBtn = document.getElementById('combat-confirm');
const skipBtn = document.getElementById('combat-skip');
if (!phaseLabel) return;
if (phase === 'declaring-attackers') {
phaseLabel.textContent = 'â Declare Attackers';
instructions.textContent = 'Click your untapped creatures to declare them as attackers. Creatures with summoning sickness cannot attack.';
blockersSection.style.display = 'none';
dmgPreview.style.display = 'none';
confirmBtn.textContent = 'â Confirm Attackers';
// Build attackers list
if (combat.attackers.length === 0) {
attackersList.innerHTML = 'No attackers declared yet.';
targetSelector.innerHTML = '';
} else {
// Each attacker gets a target selector for multiplayer
attackersList.innerHTML = combat.attackers.map(a => {
const card = gameState.battlefieldCards.find(c => c.id === a.cardId);
const cardName = card ? card.name : `Card #${a.cardId}`;
const targetName = gameState.players[a.targetPlayerId]?.name || '?';
const pt = card ? (card.pt || '?/?' ) : '?/?';
return `
â${cardName}${pt}â
`;
}).join('');
targetSelector.innerHTML = '';
}
} else if (phase === 'declaring-blockers') {
phaseLabel.textContent = 'ðĄ Declare Blockers';
blockersSection.style.display = 'block';
dmgPreview.style.display = 'none';
confirmBtn.textContent = 'â Confirm Blockers';
if (skipBtn) skipBtn.textContent = _aiBlockerPauseState ? "\u23e9 No Blockers" : "\u23e9 Skip Combat";
// Show all attackers and their current blockers
const attackerGroups = {};
combat.attackers.forEach(a => {
if (!attackerGroups[a.cardId]) attackerGroups[a.cardId] = { attacker: a, blockers: [] };
});
combat.blockers.forEach(b => {
if (attackerGroups[b.blockingCardId]) {
attackerGroups[b.blockingCardId].blockers.push(b.cardId);
}
});
const pendingId = combat.pendingBlockerFor;
const groups = Object.values(attackerGroups);
if (groups.length === 0) {
attackersList.innerHTML = 'No attackers â proceed to damage.';
blockingPairs.innerHTML = '';
instructions.textContent = 'No creatures are attacking. Click Confirm to proceed.';
targetSelector.innerHTML = '';
} else {
const defenders = new Set(combat.attackers.map(a => a.targetPlayerId));
const humanDefenders = [...defenders].filter(pid => !gameState.players[pid].isAI);
if (humanDefenders.length === 0) {
instructions.textContent = 'No human players are being attacked. Click Confirm to proceed to damage.';
} else {
instructions.textContent = `Defending players: Click an attacking creature (to select target), then click your untapped creatures to assign as blockers.`;
}
// Summary of attackers
attackersList.innerHTML = groups.map(group => {
const atkCard = gameState.battlefieldCards.find(c => c.id === group.attacker.cardId);
const atkName = atkCard ? atkCard.name : `Attacker #${group.attacker.cardId}`;
const atkPt = atkCard ? (atkCard.pt || '?/?') : '?/?';
const targetName = gameState.players[group.attacker.targetPlayerId]?.name || '?';
const isPending = pendingId === group.attacker.cardId;
const blockerNames = group.blockers.map(bid => {
const bc = gameState.battlefieldCards.find(c => c.id === bid);
return bc ? bc.name : `#${bid}`;
}).join(', ');
return `
`;
html += `\u2694${escapeHtml(m.attackerName)}${escapeHtml(m.attackerPt)}${atkKwBadges}`;
if (!m.isBlocked) {
html += `\u2192${escapeHtml(m.targetPlayerName)} (${m.targetPlayerLife}\u2764)`;
}
html += `
`; // header end
if (m.isBlocked) {
// Show blocker assignments
html += `
`;
html += `\uD83D\uDEE1 ${escapeHtml(b.name)} ${escapeHtml(b.pt)}${bKwBadges}`;
if (m.blockerDetails.length > 1) {
// Editable damage for multi-blocker
html += ``;
html += ``;
html += `${dmg}`;
html += ``;
html += ``;
} else {
html += `${dmg} dmg`;
}
html += `${willDie ? '\u2620 Dies' : '\u2714 Survives'}`;
html += `
`;
});
// Trample line
if (m.attackerKeywords.trample) {
const trampleDmg = Math.max(0, totalDmgPool - usedDmg);
html += `
`;
html += `\uD83E\uDD8F Trample: ${trampleDmg} to ${escapeHtml(m.targetPlayerName)}`;
html += `
`;
}
// Attacker receives damage from blockers
if (m.damageToAttacker > 0) {
html += `
`;
html += `\uD83D\uDEE1 Blockers deal ${m.damageToAttacker} to ${escapeHtml(m.attackerName)}`;
html += ` â ${m.attackerDies ? '\u2620 Dies' : '\u2714 Survives'}`;
html += `
`;
}
html += `
`; // blocker-assignments end
} else {
// Unblocked damage display
html += `
`;
html += `${m.damageToPlayer}`;
html += `${m.attackerKeywords.infect ? 'poison' : 'damage'}`;
if (m.commanderDamage > 0) {
html += `\uD83D\uDC51 ${m.commanderDamage} cmd`;
}
html += `
`;
}
// Lifelink
if (m.lifelinkGain > 0) {
const ownerName = gameState.players[m.attackerOwnerId]?.name || '?';
html += `
\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 += `
`; // container end
previewEl.innerHTML = html;
}
/**
* Build keyword badges HTML for damage preview.
*/
function buildKeywordBadges(kw) {
let badges = '';
if (kw.deathtouch) badges += '\u2620 Deathtouch';
if (kw.trample) badges += '\uD83E\uDD8F Trample';
if (kw.lifelink) badges += '\u2764 Lifelink';
if (kw.firstStrike) badges += '\u26A1 First Strike';
if (kw.doubleStrike) badges += '\u26A1\u26A1 Double Strike';
if (kw.infect) badges += '\u2623 Infect';
if (kw.indestructible) badges += '\uD83D\uDC8E Indestructible';
if (kw.isCommander) badges += '\uD83D\uDC51 Commander';
return badges;
}
/**
* Adjust damage assigned to a specific blocker in multi-blocker matchup.
* Ensures total damage doesn't exceed attacker's power and each blocker
* gets at least 0 damage.
*/
function adjustBlockerDamage(matchupIdx, blockerIdx, delta) {
if (!gameState || !gameState.combat.damagePreview) return;
const matchup = gameState.combat.damagePreview[matchupIdx];
if (!matchup) return;
const totalPool = matchup.attackerKeywords.doubleStrike
? matchup.attackerPower * 2
: matchup.attackerPower;
const currentVal = matchup.damageToBlockers[blockerIdx] || 0;
const usedByOthers = matchup.damageToBlockers.reduce((s, d, i) => i === blockerIdx ? s : s + d, 0);
const maxForThis = totalPool - usedByOthers;
const newVal = Math.max(0, Math.min(maxForThis, currentVal + delta));
matchup.damageToBlockers[blockerIdx] = newVal;
// Recalculate trample damage
const totalAssigned = matchup.damageToBlockers.reduce((s, d) => s + d, 0);
if (matchup.attackerKeywords.trample) {
const trampleDmg = Math.max(0, totalPool - totalAssigned);
matchup.damageToPlayer = trampleDmg;
matchup.playerLifeChange = matchup.attackerKeywords.infect ? 0 : -trampleDmg;
matchup.playerPoisonChange = matchup.attackerKeywords.infect ? trampleDmg : 0;
matchup.commanderDamage = matchup.attackerKeywords.isCommander ? trampleDmg : 0;
}
// Recalculate lifelink
if (matchup.attackerKeywords.lifelink) {
matchup.lifelinkGain = totalAssigned + matchup.damageToPlayer;
}
// Re-render
renderDamagePreviewContent();
}
/**
* Apply all damage from the preview, resolving life changes, deaths,
* commander damage, poison, and lifelink, then clean up combat.
*/
function applyDamageFromPreview() {
if (!gameState || !gameState.combat.damagePreview) return;
pushUndoState();
const matchups = gameState.combat.damagePreview;
const combat = gameState.combat;
const toKill = new Set();
const logLines = [];
matchups.forEach(m => {
const attacker = gameState.battlefieldCards.find(c => c.id === m.attackerId);
// Check if target is a planeswalker
var isPlaneswalkerTarget = (m.targetPlaneswalkerCardId != null);
if (!m.isBlocked) {
// Unblocked â damage to player or planeswalker
if (m.damageToPlayer > 0) {
if (isPlaneswalkerTarget) {
// Damage to planeswalker
var pwKillId = applyDamageToPlaneswalker(m.targetPlaneswalkerCardId, m.damageToPlayer, logLines);
if (pwKillId) toKill.add(pwKillId);
showFloatingDamage('bc-' + m.targetPlaneswalkerCardId, m.damageToPlayer, 'damage');
} else if (m.attackerKeywords.infect) {
adjustPoison(m.targetPlayerId, m.damageToPlayer);
logLines.push(`\u2694 ${escapeHtml(m.attackerName)} deals ${m.damageToPlayer} poison to ${escapeHtml(m.targetPlayerName)} â unblocked.`);
showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'poison');
} else {
adjustLife(m.targetPlayerId, -m.damageToPlayer);
logLines.push(`\u2694 ${escapeHtml(m.attackerName)} (${m.attackerPower} power) deals ${m.damageToPlayer} damage to ${escapeHtml(m.targetPlayerName)} â unblocked.`);
showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'damage');
}
// Commander damage
if (m.commanderDamage > 0 && !isPlaneswalkerTarget) {
adjustCommanderDamage(m.targetPlayerId, m.attackerOwnerId, m.commanderDamage);
logLines.push(`\uD83D\uDC51 Commander damage: ${m.commanderDamage} to ${escapeHtml(m.targetPlayerName)}.`);
}
}
} else {
// Blocked â apply damage to each blocker
m.blockerDetails.forEach((b, bi) => {
const dmg = m.damageToBlockers[bi] || 0;
if (dmg > 0) {
logLines.push(`\u2694 ${escapeHtml(m.attackerName)} deals ${dmg}${m.attackerKeywords.deathtouch ? ' (deathtouch)' : ''} to ${escapeHtml(b.name)}.`);
// Feature 3: track damage on creature
var blockerCard = gameState.battlefieldCards.find(function(c) { return c.id === b.cardId; });
if (blockerCard) {
if (!blockerCard.damageThisTurn) blockerCard.damageThisTurn = 0;
blockerCard.damageThisTurn += dmg;
updateDamageBadge(blockerCard);
}
showFloatingDamage('bc-' + b.cardId, dmg, 'damage');
const isLethal = m.attackerKeywords.deathtouch ? true : (dmg >= b.toughness);
if (isLethal && !b.keywords.indestructible) {
toKill.add(b.cardId);
logLines.push(`\u2620 ${escapeHtml(b.name)} is destroyed (${dmg} damage, ${b.toughness} toughness).`);
showFloatingDamage('bc-' + b.cardId, '\u2620', 'death');
}
}
});
// Blocker damage back to attacker
if (m.damageToAttacker > 0 && attacker) {
const anyBlockerDeathtouch = m.blockerDetails.some(b => b.keywords.deathtouch);
logLines.push(`\uD83D\uDEE1 Blockers deal ${m.damageToAttacker}${anyBlockerDeathtouch ? ' (deathtouch)' : ''} to ${escapeHtml(m.attackerName)}.`);
// Feature 3: track damage on attacker
if (!attacker.damageThisTurn) attacker.damageThisTurn = 0;
attacker.damageThisTurn += m.damageToAttacker;
updateDamageBadge(attacker);
showFloatingDamage('bc-' + m.attackerId, m.damageToAttacker, 'damage');
if (m.attackerDies) {
toKill.add(m.attackerId);
logLines.push(`\u2620 ${escapeHtml(m.attackerName)} is destroyed.`);
showFloatingDamage('bc-' + m.attackerId, '\u2620', 'death');
}
}
// Trample overflow to player
if (m.damageToPlayer > 0) {
if (m.attackerKeywords.infect) {
adjustPoison(m.targetPlayerId, m.damageToPlayer);
logLines.push(`\uD83E\uDD8F Trample (infect): ${m.damageToPlayer} poison to ${escapeHtml(m.targetPlayerName)}.`);
showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'poison');
} else {
adjustLife(m.targetPlayerId, -m.damageToPlayer);
logLines.push(`\uD83E\uDD8F Trample: ${m.damageToPlayer} damage to ${escapeHtml(m.targetPlayerName)}.`);
showFloatingDamage('player-panel-' + m.targetPlayerId, m.damageToPlayer, 'damage');
}
if (m.commanderDamage > 0) {
adjustCommanderDamage(m.targetPlayerId, m.attackerOwnerId, m.commanderDamage);
}
}
}
// Lifelink
if (m.lifelinkGain > 0) {
adjustLife(m.attackerOwnerId, m.lifelinkGain);
const ownerName = gameState.players[m.attackerOwnerId]?.name || '?';
logLines.push(`\u2764 Lifelink: ${escapeHtml(ownerName)} gains ${m.lifelinkGain} life.`);
showFloatingDamage('player-panel-' + m.attackerOwnerId, m.lifelinkGain, 'heal');
}
});
// Log all
logLines.forEach(line => addLogEntry(line));
// Send killed creatures to graveyard
toKill.forEach(id => {
combat.attackers = combat.attackers.filter(a => a.cardId !== id);
combat.blockers = combat.blockers.filter(b => b.cardId !== id && b.blockingCardId !== id);
moveCardToZone(id, 'graveyard');
});
// Fire damage triggers
fireBattlefieldTriggers('damage');
// Feature 4: Show combat recap banner
showCombatRecap(logLines);
// Clean up and advance
hideCombatOverlay();
clearCombatArrows();
combat.damagePreview = null;
gameState.combatPhaseIndex = 4; // end-combat
renderPlayerPanels();
renderPhaseTracker();
addLogEntry('Combat damage resolved.');
checkStateBasedActions();
setTimeout(() => nextPhase(), 800);
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 1: SVG COMBAT ARROWS
// ============================================================
/**
* Clear all combat arrow SVG paths.
*/
function clearCombatArrows() {
var svg = document.getElementById('combat-arrows-svg');
if (!svg) return;
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
/**
* Get the center point of an element relative to the combat-arrows-svg container.
* Returns {x, y} or null if element not found.
*/
function _getElementCenterRelSvg(elId) {
var svg = document.getElementById('combat-arrows-svg');
if (!svg) return null;
var el = document.getElementById(elId);
if (!el) return null;
var svgRect = svg.getBoundingClientRect();
var elRect = el.getBoundingClientRect();
return {
x: elRect.left + elRect.width / 2 - svgRect.left,
y: elRect.top + elRect.height / 2 - svgRect.top
};
}
/**
* Draw a single animated curved arrow in the SVG.
* @param {SVGElement} svg - the SVG container
* @param {{x,y}} from - start point
* @param {{x,y}} to - end point
* @param {string} color - stroke color hex
* @param {string} markerId - arrowhead marker id
*/
function _drawCombatArrow(svg, from, to, color, markerId) {
if (!from || !to) return;
// Quadratic bezier control point (bow outward)
var dx = to.x - from.x;
var dy = to.y - from.y;
var cx = from.x + dx * 0.5 - dy * 0.25;
var cy = from.y + dy * 0.5 + dx * 0.25;
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M' + from.x + ',' + from.y + ' Q' + cx + ',' + cy + ' ' + to.x + ',' + to.y);
path.setAttribute('stroke', color);
path.setAttribute('stroke-width', '2.5');
path.setAttribute('fill', 'none');
path.setAttribute('opacity', '0.85');
path.setAttribute('marker-end', 'url(#' + markerId + ')');
path.classList.add('combat-arrow-path');
svg.appendChild(path);
}
/**
* Build arrowhead marker defs in the SVG.
*/
function _buildArrowDefs(svg) {
var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
var markers = [
{ id: 'arrow-red', color: '#D3202A' },
{ id: 'arrow-blue', color: '#0E68AB' },
{ id: 'arrow-orange', color: '#ff8c00' },
];
markers.forEach(function(m) {
var marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', m.id);
marker.setAttribute('markerWidth', '8');
marker.setAttribute('markerHeight', '8');
marker.setAttribute('refX', '6');
marker.setAttribute('refY', '3');
marker.setAttribute('orient', 'auto');
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', '0 0, 6 3, 0 6');
poly.setAttribute('fill', m.color);
marker.appendChild(poly);
defs.appendChild(marker);
});
svg.appendChild(defs);
}
/**
* Update all combat arrows based on current combat state.
* Called from refreshCombatOverlay, toggleAttacker, toggleBlocker,
* confirmAttackers, confirmBlockers, showDamagePreview.
*/
function updateCombatArrows() {
var svg = document.getElementById('combat-arrows-svg');
if (!svg) return;
clearCombatArrows();
if (!gameState) return;
var combat = gameState.combat;
if (!combat || !combat.phase) return;
_buildArrowDefs(svg);
if (combat.phase === 'declaring-attackers') {
// Red arrows: attacker card â target player panel (or planeswalker card)
combat.attackers.forEach(function(a) {
var from = _getElementCenterRelSvg('bc-' + a.cardId);
var toId = a.targetPlaneswalkerCardId ? ('bc-' + a.targetPlaneswalkerCardId) : ('player-panel-' + a.targetPlayerId);
var to = _getElementCenterRelSvg(toId);
_drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red');
});
} else if (combat.phase === 'declaring-blockers') {
// Blue arrows: blocker â attacker it's blocking
var blockedAttackers = new Set();
combat.blockers.forEach(function(b) {
var from = _getElementCenterRelSvg('bc-' + b.cardId);
var to = _getElementCenterRelSvg('bc-' + b.blockingCardId);
_drawCombatArrow(svg, from, to, '#0E68AB', 'arrow-blue');
blockedAttackers.add(b.blockingCardId);
});
// Red arrows: unblocked attackers â target player/planeswalker
combat.attackers.forEach(function(a) {
if (!blockedAttackers.has(a.cardId)) {
var from = _getElementCenterRelSvg('bc-' + a.cardId);
var uToId = a.targetPlaneswalkerCardId ? ('bc-' + a.targetPlaneswalkerCardId) : ('player-panel-' + a.targetPlayerId);
var to = _getElementCenterRelSvg(uToId);
_drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red');
}
});
} else if (combat.phase === 'previewing-damage') {
// Arrows from preview data
if (combat.damagePreview) {
combat.damagePreview.forEach(function(m) {
if (!m.isBlocked) {
// Red: attacker â target player/planeswalker
var from = _getElementCenterRelSvg('bc-' + m.attackerId);
var pToId = m.targetPlaneswalkerCardId ? ('bc-' + m.targetPlaneswalkerCardId) : ('player-panel-' + m.targetPlayerId);
var to = _getElementCenterRelSvg(pToId);
_drawCombatArrow(svg, from, to, '#D3202A', 'arrow-red');
} else {
// Orange: attacker â each blocker
m.blockerDetails.forEach(function(b) {
var from = _getElementCenterRelSvg('bc-' + m.attackerId);
var to = _getElementCenterRelSvg('bc-' + b.cardId);
_drawCombatArrow(svg, from, to, '#ff8c00', 'arrow-orange');
});
// If trample, also red arrow to player/planeswalker
if (m.attackerKeywords && m.attackerKeywords.trample && m.damageToPlayer > 0) {
var from2 = _getElementCenterRelSvg('bc-' + m.attackerId);
var tToId = m.targetPlaneswalkerCardId ? ('bc-' + m.targetPlaneswalkerCardId) : ('player-panel-' + m.targetPlayerId);
var to2 = _getElementCenterRelSvg(tToId);
_drawCombatArrow(svg, from2, to2, '#D3202A', 'arrow-red');
}
}
});
}
}
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 2: FLOATING DAMAGE NUMBERS
// ============================================================
/**
* Show a floating damage/heal/poison/death number at a card or player element.
* @param {string} elementId - DOM element ID (e.g. 'bc-123' or 'player-panel-0')
* @param {number|string} amount - the number or symbol to show
* @param {string} type - 'damage' | 'heal' | 'poison' | 'death'
*/
function showFloatingDamage(elementId, amount, type) {
var el = document.getElementById(elementId);
if (!el) return;
// Trigger visual flash on the element
triggerDamageFlash(elementId, type);
// Screen shake for big damage (10+)
if (type === 'damage' && typeof amount === 'number' && amount >= 10) {
triggerScreenShake();
}
var rect = el.getBoundingClientRect();
var div = document.createElement('div');
div.className = 'floating-damage floating-damage-' + type;
if (type === 'damage') {
div.textContent = '-' + amount;
} else if (type === 'heal') {
div.textContent = '+' + amount;
} else if (type === 'poison') {
div.textContent = '+' + amount + '\u2623';
} else if (type === 'death') {
div.textContent = '\u2620';
} else {
div.textContent = amount;
}
// Randomize x position slightly so multiple numbers don't stack
var xOff = (Math.random() * 20) - 10;
div.style.left = (rect.left + rect.width / 2 - 15 + xOff) + 'px';
div.style.top = (rect.top + rect.height / 3) + 'px';
document.body.appendChild(div);
// Remove after animation completes
setTimeout(function() {
if (div.parentNode) div.parentNode.removeChild(div);
}, 1600);
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 3: DAMAGE MARKERS
// ============================================================
/**
* Update the damage badge on a battlefield card.
* Creates or removes the .bf-damage-badge based on card.damageThisTurn.
*/
function updateDamageBadge(cardData) {
var cardEl = document.getElementById('bc-' + cardData.id);
if (!cardEl) return;
var existing = cardEl.querySelector('.bf-damage-badge');
if (cardData.damageThisTurn && cardData.damageThisTurn > 0) {
if (!existing) {
existing = document.createElement('div');
existing.className = 'bf-damage-badge';
cardEl.appendChild(existing);
}
existing.textContent = cardData.damageThisTurn;
// Also update P/T overlay if creature has taken damage
var ptOverlay = cardEl.querySelector('.card-pt-overlay');
if (ptOverlay && cardData.pt) {
var parts = cardData.pt.split('/');
var origTou = parseInt(parts[1], 10);
if (!isNaN(origTou)) {
var remaining = origTou - cardData.damageThisTurn;
ptOverlay.textContent = parts[0] + '/' + remaining;
ptOverlay.classList.add('pt-overlay-damaged');
}
}
} else {
if (existing) existing.parentNode.removeChild(existing);
// Reset P/T overlay
var ptOverlay2 = cardEl.querySelector('.card-pt-overlay');
if (ptOverlay2 && cardData.pt) {
ptOverlay2.textContent = cardData.pt;
ptOverlay2.classList.remove('pt-overlay-damaged');
}
}
}
/**
* Clear all damage-this-turn counters from all battlefield cards.
* Called from nextTurn().
*/
function clearAllDamageCounters() {
if (!gameState) return;
gameState.battlefieldCards.forEach(function(card) {
if (card.damageThisTurn && card.damageThisTurn > 0) {
card.damageThisTurn = 0;
updateDamageBadge(card);
}
});
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 4: COMBAT RECAP SUMMARY
// ============================================================
/**
* Show an animated combat recap banner at the top of the battlefield.
* Auto-dismisses after 5 seconds or on click.
* @param {string[]} logLines - array of HTML log lines from combat resolution
*/
function showCombatRecap(logLines) {
// Remove any existing recap
var old = document.getElementById('combat-recap-banner');
if (old && old.parentNode) old.parentNode.removeChild(old);
if (!logLines || logLines.length === 0) return;
// Build summary by extracting key events
var summaryLines = [];
// Damage to players
var playerDmgMap = {};
var lifeGainMap = {};
var deaths = [];
var poisonMap = {};
logLines.forEach(function(line) {
// Player damage: lines containing "damage to " not from trample text about a creature
var dmgMatch = line.match(/deals.*?(\d+).*?damage to ([^<]+)<\/strong>/);
if (dmgMatch) {
var amt = parseInt(dmgMatch[1], 10);
var name = dmgMatch[2];
// Only player damage (players match names in gameState)
if (gameState) {
gameState.players.forEach(function(p) {
if (p.name === name) {
playerDmgMap[name] = (playerDmgMap[name] || 0) + amt;
}
});
}
}
// Deaths
var deathMatch = line.match(/\u2620.*?([^<]+)<\/strong>.*?is destroyed/);
if (deathMatch) deaths.push(deathMatch[1]);
// Life gain (lifelink)
var lifeMatch = line.match(/Lifelink.*?([^<]+)<\/strong>.*?gains.*?(\d+)<\/strong>/);
if (lifeMatch) {
lifeGainMap[lifeMatch[1]] = (lifeGainMap[lifeMatch[1]] || 0) + parseInt(lifeMatch[2], 10);
}
// Poison
var poisonMatch = line.match(/poison.*?([^<]+)<\/strong>/);
if (poisonMatch) {
poisonMap[poisonMatch[1]] = (poisonMap[poisonMatch[1]] || 0) + 1;
}
});
var parts = [];
Object.entries(playerDmgMap).forEach(function(pair) {
parts.push('' + escapeHtml(pair[0]) + ' takes ' + pair[1] + ' damage');
});
deaths.forEach(function(n) {
parts.push('\u2620 ' + escapeHtml(n) + ' destroyed');
});
Object.entries(lifeGainMap).forEach(function(pair) {
parts.push('\u2764 ' + escapeHtml(pair[0]) + ' gains ' + pair[1] + ' life');
});
Object.entries(poisonMap).forEach(function(pair) {
parts.push('\u2623 ' + escapeHtml(pair[0]) + ' poisoned');
});
if (parts.length === 0) return;
var banner = document.createElement('div');
banner.id = 'combat-recap-banner';
banner.className = 'combat-recap-banner';
banner.innerHTML = '\u2694 Combat: ' + parts.join(' • ')
+ '';
var battlefield = document.getElementById('battlefield');
if (battlefield) {
battlefield.appendChild(banner);
} else {
document.body.appendChild(banner);
}
// Auto-dismiss after 5 seconds
setTimeout(function() {
var b = document.getElementById('combat-recap-banner');
if (b && b.parentNode) {
b.classList.add('recap-fade-out');
setTimeout(function() {
var b2 = document.getElementById('combat-recap-banner');
if (b2 && b2.parentNode) b2.parentNode.removeChild(b2);
}, 500);
}
}, 5000);
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 5: KEYWORD ENFORCEMENT
// ============================================================
/**
* Check if a card has a given color.
* Uses card.colors array or falls back to parsing oracle/type text.
* @param {object} card
* @param {string} colorCode - 'W','U','B','R','G'
*/
function cardHasColor(card, colorCode) {
if (!card) return false;
// Try colors array first
if (Array.isArray(card.colors)) {
return card.colors.indexOf(colorCode.toUpperCase()) !== -1;
}
// Try manaCost parsing if available
if (card.manaCost) {
var mc = card.manaCost.toUpperCase();
return mc.indexOf(colorCode.toUpperCase()) !== -1;
}
return false;
}
/**
* Check if a card is an artifact creature (for intimidate/fear blocking).
*/
function cardIsArtifactCreature(card) {
if (!card || !card.typeLine) return false;
var tl = card.typeLine.toLowerCase();
return tl.indexOf('artifact') !== -1 && tl.indexOf('creature') !== -1;
}
/**
* Menace: check that all menace attackers have 2+ blockers.
* Returns the first menace attacker with exactly 1 blocker, or null.
*/
function checkMenaceViolation() {
if (!gameState) return null;
var combat = gameState.combat;
for (var i = 0; i < combat.attackers.length; i++) {
var a = combat.attackers[i];
var attacker = gameState.battlefieldCards.find(function(c) { return c.id === a.cardId; });
if (!attacker) continue;
if (!hasKeyword(attacker, 'menace')) continue;
// Count blockers assigned to this attacker
var blockerCount = combat.blockers.filter(function(b) { return b.blockingCardId === a.cardId; }).length;
if (blockerCount === 1) return attacker;
}
return null;
}
// ============================================================
// COMBAT ENGINE EXPANSION â FEATURE 6: PLANESWALKER TARGETS
// ============================================================
/**
* Get all planeswalker cards on the battlefield controlled by opponents
* of the current player.
*/
function getOpponentPlaneswalkers() {
if (!gameState) return [];
return gameState.battlefieldCards.filter(function(c) {
if (c.ownerId === gameState.currentPlayerIndex) return false;
if (!c.typeLine) return false;
return c.typeLine.toLowerCase().indexOf('planeswalker') !== -1;
});
}
/**
* Apply combat damage to a planeswalker target (reduce loyalty counters).
* @param {number} planeswalkerCardId
* @param {number} damage
* @param {string[]} logLines - array to append log entries to
*/
function applyDamageToPlaneswalker(planeswalkerCardId, damage, logLines) {
if (!gameState) return;
var pw = gameState.battlefieldCards.find(function(c) { return c.id === planeswalkerCardId; });
if (!pw) return;
if (!pw.counters) pw.counters = {};
var currentLoyalty = pw.counters['loyalty'] || 0;
var newLoyalty = Math.max(0, currentLoyalty - damage);
pw.counters['loyalty'] = newLoyalty;
if (logLines) {
logLines.push('\u2694 ' + escapeHtml(pw.name) + ' takes ' + damage + ' damage (' + currentLoyalty + ' \u2192 ' + newLoyalty + ' loyalty).');
}
// Planeswalker dies at 0 loyalty
if (newLoyalty <= 0) {
if (logLines) logLines.push('\u2620 ' + escapeHtml(pw.name) + ' is defeated (0 loyalty).');
return planeswalkerCardId; // Signal to kill this card
}
// Update the card visual
updateDamageBadge(pw);
return null;
}
// ============================================================
// PLANESWALKER LOYALTY ABILITY SYSTEM
// ============================================================
/**
* Reset loyalty ability usage for all planeswalkers at start of turn.
* Called from advancePhase when transitioning to a new turn.
*/
function resetPlaneswalkerLoyaltyUsage() {
if (!gameState) return;
gameState.battlefieldCards.forEach(function(c) {
if (c.typeLine && /planeswalker/i.test(c.typeLine)) {
c.loyaltyUsedThisTurn = false;
}
});
}
/**
* Show the loyalty ability picker overlay for a planeswalker card.
* Only the controller can activate, and only once per turn, at sorcery speed.
*/
function showPlaneswalkerAbilities(cardId) {
if (!gameState) return;
var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; });
if (!card) return;
if (!card.typeLine || !/planeswalker/i.test(card.typeLine)) return;
// Check ownership
if (card.ownerIndex !== gameState.currentPlayerIndex) {
addLogEntry('\u26a0 Only the controller can activate loyalty abilities.');
return;
}
// Check if already used this turn
if (card.loyaltyUsedThisTurn) {
addLogEntry('\u26a0 ' + card.name + ' has already used a loyalty ability this turn.');
return;
}
// Parse loyalty abilities from oracle text
var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || '');
if (!abilities.loyalty || abilities.loyalty.length === 0) {
addLogEntry('\u26a0 No loyalty abilities found on ' + card.name + '.');
return;
}
var currentLoyalty = (card.counters && card.counters['loyalty']) || 0;
// Build overlay
var existing = document.getElementById('pw-ability-overlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'pw-ability-overlay';
overlay.className = 'pw-ability-overlay';
var html = '
';
html += '
';
html += '' + escapeHtml(card.name) + '';
html += '\u25c6 ' + currentLoyalty + '';
html += '';
html += '
';
html += '
';
for (var i = 0; i < abilities.loyalty.length; i++) {
var ab = abilities.loyalty[i];
var costNum = parseInt(ab.cost.replace('\u2212', '-'), 10);
var canAfford = true;
// For minus abilities, check if we have enough loyalty
if (costNum < 0 && currentLoyalty + costNum < 0) {
canAfford = false;
}
var isPlus = ab.cost.startsWith('+');
var isZero = ab.cost === '0';
var badgeClass = isPlus ? 'pw-cost-plus' : isZero ? 'pw-cost-zero' : 'pw-cost-minus';
var disabledClass = canAfford ? '' : ' pw-ability-disabled';
var disabledAttr = canAfford ? '' : ' disabled';
html += '';
}
html += '
';
html += '
';
overlay.innerHTML = html;
// Click outside to close
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closePlaneswalkerAbilities();
});
document.body.appendChild(overlay);
}
function closePlaneswalkerAbilities() {
var overlay = document.getElementById('pw-ability-overlay');
if (overlay) overlay.remove();
}
/**
* Activate a specific loyalty ability on a planeswalker.
* Adjusts loyalty counters and attempts to execute the effect.
*/
function activatePlaneswalkerAbility(cardId, abilityIndex) {
if (!gameState) return;
var card = gameState.battlefieldCards.find(function(c) { return c.id === cardId; });
if (!card) return;
var abilities = parseCardAbilities(card.oracleText || '', card.typeLine || '', card.name || '');
if (!abilities.loyalty || !abilities.loyalty[abilityIndex]) return;
var ab = abilities.loyalty[abilityIndex];
var costNum = parseInt(ab.cost.replace('\u2212', '-'), 10);
if (isNaN(costNum)) costNum = 0;
if (!card.counters) card.counters = {};
var currentLoyalty = card.counters['loyalty'] || 0;
// Verify we can pay
if (costNum < 0 && currentLoyalty + costNum < 0) {
addLogEntry('\u26a0 Not enough loyalty on ' + card.name + ' to activate [' + ab.cost + '].');
closePlaneswalkerAbilities();
return;
}
pushUndoState('PW Ability: ' + card.name);
// Pay the cost (adjust loyalty)
var newLoyalty = currentLoyalty + costNum;
card.counters['loyalty'] = newLoyalty;
card.loyaltyUsedThisTurn = true;
addLogEntry('\u25c6 ' + escapeHtml(card.name) + ' [' + ab.cost + ']: ' + escapeHtml(ab.effect) + ' (Loyalty: ' + currentLoyalty + ' \u2192 ' + newLoyalty + ')');
// Execute the effect
executePlaneswalkerEffect(card, ab.effect, ab.cost);
// Check if planeswalker dies from 0 loyalty
if (newLoyalty <= 0) {
addLogEntry('\u2620 ' + escapeHtml(card.name) + ' is defeated (0 loyalty).');
moveCardToZone(cardId, 'graveyard');
} else {
// Update the visual
var bcEl = document.getElementById('bc-' + card.id);
if (bcEl) bcEl.remove();
renderBattlefieldCard(card);
}
closePlaneswalkerAbilities();
}
/**
* Attempt to auto-execute common planeswalker ability effects.
* Falls back to a log message for complex effects.
*/
function executePlaneswalkerEffect(card, effectText, cost) {
if (!effectText || !gameState) return;
var owner = card.ownerIndex;
var text = effectText.toLowerCase();
// --- Draw cards ---
var drawMatch = text.match(/draw (a card|one card|two cards?|three cards?|four cards?|five cards?|(\d+) cards?)/i);
if (drawMatch) {
var drawCount = 1;
var drawWords = { 'a card': 1, 'one card': 1, 'two cards': 2, 'three cards': 3, 'four cards': 4, 'five cards': 5 };
var dKey = drawMatch[1].toLowerCase().replace(/s$/, '').replace(/cards/, 'card');
if (drawWords[dKey] !== undefined) drawCount = drawWords[dKey];
else if (drawMatch[2]) drawCount = parseInt(drawMatch[2], 10);
for (var d = 0; d < drawCount; d++) drawCard(owner);
addLogEntry('\u2713 Auto-effect: Drew ' + drawCount + ' card(s)');
}
// --- Gain life ---
var gainMatch = text.match(/you gain (\d+) life/i);
if (gainMatch) {
var gain = parseInt(gainMatch[1], 10);
adjustLife(owner, gain);
addLogEntry('\u2713 Auto-effect: Gained ' + gain + ' life');
}
// --- Lose life (opponents) ---
var oppLoseMatch = text.match(/each opponent loses (\d+) life/i);
if (oppLoseMatch) {
var oppLoss = parseInt(oppLoseMatch[1], 10);
gameState.players.forEach(function(p, i) {
if (i !== owner && !p.eliminated) {
adjustLife(i, -oppLoss);
}
});
addLogEntry('\u2713 Auto-effect: Each opponent loses ' + oppLoss + ' life');
}
// --- Deal damage ---
var dmgMatch = text.match(/deals? (\d+) damage to (?:any target|target)/i);
if (dmgMatch) {
var dmgAmt = parseInt(dmgMatch[1], 10);
addLogEntry('\u2b50 Effect: Deal ' + dmgAmt + ' damage to any target \u2014 choose target manually');
}
// --- Create tokens ---
var tokenSpecs = parseTokenCreationAbilities(effectText);
if (tokenSpecs.length > 0) {
for (var ti = 0; ti < tokenSpecs.length; ti++) {
var ts = tokenSpecs[ti];
createQuickToken(owner, ts.name, ts.power, ts.toughness, ts.typeLine, ts.color, ts.abilities, ts.count);
addLogEntry('\u2713 Auto-effect: Created ' + ts.displayLabel);
}
}
// --- Create Treasure ---
if (/create .* treasure/i.test(effectText) && tokenSpecs.length === 0) {
var treasureCountMatch = effectText.match(/(two|three|four|five|\d+) treasure/i);
var tCount = 1;
if (treasureCountMatch) {
var tw = { two: 2, three: 3, four: 4, five: 5 };
tCount = tw[treasureCountMatch[1].toLowerCase()] || parseInt(treasureCountMatch[1], 10) || 1;
}
createQuickToken(owner, 'Treasure', '', '', 'Artifact \u2014 Treasure', 'C', 'Sacrifice: Add one mana of any color', tCount);
addLogEntry('\u2713 Auto-effect: Created ' + tCount + 'x Treasure');
}
// --- Mill / self-mill ---
var millMatch = text.match(/mills? (\d+) cards?/i) || text.match(/put the top (\d+) cards? .* into .* graveyard/i);
if (millMatch) {
var millAmt = parseInt(millMatch[1], 10);
millCards(owner, millAmt);
addLogEntry('\u2713 Auto-effect: Milled ' + millAmt + ' cards');
}
// --- Scry ---
var scryMatch = text.match(/scry (\d+)/i);
if (scryMatch) {
var scryAmt = parseInt(scryMatch[1], 10);
openScryModal(owner, scryAmt);
addLogEntry('\u2713 Auto-effect: Scry ' + scryAmt);
}
// --- Destroy target ---
if (/destroy (target|all|each)/i.test(text)) {
addLogEntry('\u2b50 Effect: Destroy \u2014 choose target(s) manually');
}
// --- Exile ---
if (/exile (target|a |up to)/i.test(text)) {
addLogEntry('\u2b50 Effect: Exile \u2014 choose target(s) manually');
}
// --- Return to hand ---
if (/return .* to .* (hand|owner)/i.test(text)) {
addLogEntry('\u2b50 Effect: Return \u2014 choose target(s) manually');
}
// --- +1/+1 counters ---
var counterMatch = text.match(/put (a|one|two|three|\d+) \+1\/\+1 counter/i);
if (counterMatch) {
var cw = { 'a': 1, 'one': 1, 'two': 2, 'three': 3 };
var cAmt = cw[counterMatch[1].toLowerCase()] || parseInt(counterMatch[1], 10) || 1;
addLogEntry('\u2b50 Effect: Put ' + cAmt + ' +1/+1 counter(s) \u2014 choose target manually');
}
// --- Ultimate / Emblem ---
if (/you get an emblem/i.test(text)) {
addLogEntry('\u2b50 ULTIMATE: You get an emblem \u2014 ' + escapeHtml(effectText));
}
// --- Search library ---
if (/search your library/i.test(text)) {
addLogEntry('\u2b50 Effect: Search your library \u2014 use deck viewer to find the card');
}
// --- Discard ---
var discardMatch = text.match(/discard (a|\d+) cards?/i);
if (discardMatch) {
addLogEntry('\u2b50 Effect: Discard \u2014 choose card(s) from hand manually');
}
}
// ============================================================
// AI SPELL CAST RESPONSE SYSTEM
// ============================================================
/**
* Global state for the AI cast pause system.
* When an AI casts a spell, the action queue is paused until the human responds.
*/
var _aiCastPauseState = null;
/**
* AI target selection for removal/targeted spells.
* Returns { targetName, targetCardId, targetType } or null.
*/
function aiSelectSpellTarget(card, aiPlayerIdx) {
if (!gameState || !card) return null;
var oracle = (card.oracleText || '').toLowerCase();
var typeLine = (card.typeLine || '').toLowerCase();
// Check if this is a targeted spell
var isRemoval = /destroy target|exile target|deals? \d+ damage to (target|any target|any one target)/.test(oracle);
var isTargetedBuff = /target creature gets|put .* counter on target|enchant creature/.test(oracle);
var isTargetedBounce = /return target .* to .* (hand|owner)/.test(oracle);
var isBoardWipe = /destroy all|all creatures get \-|exile all|each creature/.test(oracle);
if (isBoardWipe) {
return { targetName: 'All', targetCardId: null, targetType: 'board_wipe' };
}
if (!isRemoval && !isTargetedBounce) return null;
// Find best target among opponents' battlefield cards
var candidates = gameState.battlefieldCards.filter(function(c) {
if (c.ownerIndex === aiPlayerIdx) return false;
// Match type restriction
var cType = (c.typeLine || '').toLowerCase();
if (/destroy target creature|exile target creature|damage to target creature/.test(oracle)) {
return /creature/.test(cType);
}
if (/destroy target artifact/.test(oracle)) return /artifact/.test(cType);
if (/destroy target enchantment/.test(oracle)) return /enchantment/.test(cType);
if (/destroy target planeswalker/.test(oracle)) return /planeswalker/.test(cType);
if (/destroy target permanent|exile target permanent|exile target nonland/.test(oracle)) {
return !/land/.test(cType) || /destroy target permanent/.test(oracle);
}
// Generic "target" â prefer creatures
return true;
});
if (candidates.length === 0) return null;
// Score candidates by threat (power, CMC, keywords)
candidates.sort(function(a, b) {
var scoreA = 0, scoreB = 0;
var aType = (a.typeLine || '').toLowerCase();
var bType = (b.typeLine || '').toLowerCase();
// Prefer creatures with high power
if (a.power) scoreA += parseInt(a.power, 10) || 0;
if (b.power) scoreB += parseInt(b.power, 10) || 0;
// Planeswalkers are high priority
if (/planeswalker/.test(aType)) scoreA += 5;
if (/planeswalker/.test(bType)) scoreB += 5;
// Commander bonus
if (a.isCommander) scoreA += 4;
if (b.isCommander) scoreB += 4;
// Higher CMC = bigger threat
scoreA += (a.cmc || 0);
scoreB += (b.cmc || 0);
// Keywords that make things dangerous
var aText = (a.oracleText || '').toLowerCase();
var bText = (b.oracleText || '').toLowerCase();
if (/flying|trample|double strike|lifelink|hexproof|indestructible/.test(aText)) scoreA += 3;
if (/flying|trample|double strike|lifelink|hexproof|indestructible/.test(bText)) scoreB += 3;
return scoreB - scoreA;
});
var target = candidates[0];
return {
targetName: target.name,
targetCardId: target.id,
targetType: /creature/.test((target.typeLine || '').toLowerCase()) ? 'creature' :
/planeswalker/.test((target.typeLine || '').toLowerCase()) ? 'planeswalker' : 'permanent'
};
}
/**
* Show the AI cast response popup.
* Pauses the AI action queue and gives the human player a chance to respond.
*/
function showAiCastPopup(card, aiPlayerIdx, handIndex, aiTarget) {
if (!gameState) return;
var aiPlayer = gameState.players[aiPlayerIdx];
if (!aiPlayer) return;
// Store pause state so the AI action queue knows to wait
_aiCastPauseState = {
card: card,
aiPlayerIdx: aiPlayerIdx,
handIndex: handIndex,
aiTarget: aiTarget,
};
var existing = document.getElementById('ai-cast-popup');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'ai-cast-popup';
overlay.className = 'ai-cast-overlay';
var targetHtml = '';
if (aiTarget) {
if (aiTarget.targetType === 'board_wipe') {
targetHtml = '
';
updateDbCountDisplay();
return;
}
// Group by type
var groups = {};
DB_TYPE_ORDER.forEach(function(g) { groups[g.key] = []; });
cards.forEach(function(card, idx) {
var group = getCardGroup(card.typeLine);
groups[group].push({ card: card, idx: idx });
});
var html = '';
DB_TYPE_ORDER.forEach(function(g) {
var group = groups[g.key];
if (!group.length) return;
var groupQty = group.reduce(function(s, item) { return s + item.card.quantity; }, 0);
html += '
'
+ '
'
+ '' + g.key + ''
+ '' + groupQty + ''
+ '
'
+ '
';
group.forEach(function(item) {
var card = item.card;
var idx = item.idx;
html += '
'; }).join('');
} else {
warningsEl.style.display = 'none';
}
}
// Info section
if (infoEl) {
if (info.length) {
infoEl.style.display = '';
infoEl.innerHTML = '
Info
'
+ info.map(function(i2) { return '
âđ' + escapeHtml(i2) + '
'; }).join('');
} else {
infoEl.style.display = 'none';
}
}
// Composition bar
if (compositionEl) {
var typeColors = {
Creatures: '#2ecc71', Planeswalkers: '#f39c12', Instants: '#3498db',
Sorceries: '#e74c3c', Artifacts: '#95a5a6', Enchantments: '#9b59b6',
Lands: '#8B7355', Other: '#555'
};
var totalNonCmd = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0);
if (totalNonCmd > 0) {
var barHtml = '
';
var labelHtml = '
';
DB_TYPE_ORDER.forEach(function(g) {
var count = composition[g.key] || 0;
if (count > 0) {
var pct = (count / totalNonCmd * 100).toFixed(1);
var color = typeColors[g.key] || '#555';
barHtml += '';
labelHtml += '' + g.key + ' ' + count + '';
}
});
barHtml += '
';
labelHtml += '
';
compositionEl.style.display = '';
compositionEl.innerHTML = labelHtml + barHtml;
} else {
compositionEl.style.display = 'none';
}
}
// Also update per-card indicators
updateCardLegalityDots();
}
// --- Save / Load Decks ---
// --- Legality Panel helpers ---
function toggleLegalityPanel() {
var body = document.getElementById('dlp-body');
var arrow = document.querySelector('.dlp-toggle-arrow');
if (!body) return;
var collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
if (arrow) arrow.textContent = collapsed ? '\u25bc' : '\u25b6';
try { _mtgStore.setItem('mtg_dlp_collapsed', collapsed ? '0' : '1'); } catch(e) {}
}
function initLegalityPanelState() {
var body = document.getElementById('dlp-body');
var arrow = document.querySelector('.dlp-toggle-arrow');
if (!body) return;
try {
var pref = _mtgStore.getItem('mtg_dlp_collapsed');
if (pref === '1') {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
} catch(e) {}
}
async function fullLegalityAudit() {
var btn = document.querySelector('.dlp-validate-btn');
if (btn) { btn.textContent = '\u23f3 Auditing...'; btn.disabled = true; }
var allCards = dbCurrentDeck.cards.slice();
if (dbCurrentDeck.commander) allCards.push(dbCurrentDeck.commander);
var cardIds = allCards
.filter(function(c) { return c.scryfallId; })
.map(function(c) { return { id: c.scryfallId }; });
if (!cardIds.length) {
showToast('No cards to audit');
if (btn) { btn.textContent = '\ud83d\udd0d Full Audit (re-check via Scryfall)'; btn.disabled = false; }
return;
}
// Batch in groups of 75
var batches = [];
for (var i = 0; i < cardIds.length; i += 75) {
batches.push(cardIds.slice(i, i + 75));
}
var legalityMap = {};
for (var b = 0; b < batches.length; b++) {
try {
var resp = await fetch('https://api.scryfall.com/cards/collection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifiers: batches[b] })
});
var result = await resp.json();
if (result.data) {
result.data.forEach(function(sf) {
legalityMap[sf.id] = {
legality: (sf.legalities && sf.legalities.commander) || null,
keywords: sf.keywords || [],
oracleText: sf.oracle_text || ''
};
});
}
} catch(e) {
console.error('Audit batch failed:', e);
}
if (b < batches.length - 1) {
await new Promise(function(r) { setTimeout(r, 120); });
}
}
// Update card legality data
dbCurrentDeck.cards.forEach(function(c) {
var fresh = legalityMap[c.scryfallId];
if (fresh) {
c.legality = fresh.legality;
c.keywords = fresh.keywords;
c.oracleText = fresh.oracleText;
}
});
if (dbCurrentDeck.commander) {
var freshCmd = legalityMap[dbCurrentDeck.commander.scryfallId];
if (freshCmd) {
dbCurrentDeck.commander.legality = freshCmd.legality;
dbCurrentDeck.commander.keywords = freshCmd.keywords;
dbCurrentDeck.commander.oracleText = freshCmd.oracleText;
}
}
if (btn) { btn.textContent = '\ud83d\udd0d Full Audit (re-check via Scryfall)'; btn.disabled = false; }
checkDbLegality();
// Count after fresh check
var errCount = 0;
var warnCount = 0;
// Re-run minimal count for toast
var totalCards = dbCurrentDeck.cards.reduce(function(s, c) { return s + c.quantity; }, 0);
if (dbCurrentDeck.commander) totalCards += 1;
dbCurrentDeck.cards.forEach(function(c) {
if (c.legality === 'banned' || c.legality === 'not_legal') errCount++;
});
if (dbCurrentDeck.commander && (dbCurrentDeck.commander.legality === 'banned' || dbCurrentDeck.commander.legality === 'not_legal')) errCount++;
showToast('Audit complete: ' + errCount + ' legality issue' + (errCount !== 1 ? 's' : '') + ' found');
}
function getCardLegalityStatus(card) {
// Returns 'error', 'warning', or 'ok'
if (!card) return 'ok';
// Check banned/not_legal
if (card.legality === 'banned' || card.legality === 'not_legal') return 'error';
if (!card.legality && COMMANDER_BANNED.includes(card.name)) return 'error';
// Check singleton
if (!BASIC_LANDS.includes(card.name) && card.quantity > 1) return 'error';
// Check color identity
if (dbCurrentDeck.commander) {
var cmdCI = dbCurrentDeck.commander.colorIdentity || [];
var cardCI = card.colorIdentity || [];
var hasViolation = cardCI.some(function(col) { return !cmdCI.includes(col); });
if (hasViolation) return 'error';
}
return 'ok';
}
function updateCardLegalityDots() {
// Update per-card dot indicators already in the rendered list
var dots = document.querySelectorAll('.dlp-card-dot');
dots.forEach(function(dot) {
var idx = parseInt(dot.getAttribute('data-idx'), 10);
if (isNaN(idx)) return;
var card = dbCurrentDeck.cards[idx];
if (!card) return;
var status = getCardLegalityStatus(card);
dot.className = 'dlp-card-dot dlp-dot-' + status;
dot.title = status === 'error' ? 'Legality issue' : status === 'warning' ? 'Warning' : 'Legal';
});
}
function saveCurrentDeck() {
var nameInput = document.getElementById('db-deck-name');
if (nameInput) dbCurrentDeck.name = nameInput.value.trim() || 'Untitled Deck';
if (!dbCurrentDeck.id) dbCurrentDeck.id = 'deck_' + Date.now();
dbCurrentDeck.dateModified = new Date().toISOString();
if (!dbCurrentDeck.dateCreated) dbCurrentDeck.dateCreated = dbCurrentDeck.dateModified;
var decks = getDecks();
var existingIdx = decks.findIndex(function(d) { return d.id === dbCurrentDeck.id; });
if (existingIdx >= 0) {
decks[existingIdx] = JSON.parse(JSON.stringify(dbCurrentDeck));
} else {
decks.push(JSON.parse(JSON.stringify(dbCurrentDeck)));
}
saveDecks(decks);
renderDbSavedDecks();
showToast('Deck "' + dbCurrentDeck.name + '" saved!');
}
function loadDeckIntoBuilder(deckId) {
var decks = getDecks();
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
dbCurrentDeck = JSON.parse(JSON.stringify(deck));
var nameInput = document.getElementById('db-deck-name');
if (nameInput) nameInput.value = dbCurrentDeck.name;
renderDbCommanderSlot();
renderDbList();
renderDbStats();
renderDbResultGrid(dbSearchResults);
checkDbLegality();
showToast('Loaded "' + deck.name + '"');
}
function duplicateDeck(deckId) {
var decks = getDecks();
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
var copy = JSON.parse(JSON.stringify(deck));
copy.id = 'deck_' + Date.now();
copy.name = deck.name + ' (Copy)';
copy.dateCreated = new Date().toISOString();
copy.dateModified = copy.dateCreated;
decks.push(copy);
saveDecks(decks);
renderDbSavedDecks();
showToast('Duplicated "' + deck.name + '"');
}
function deleteSavedDeck(deckId) {
if (!confirm('Delete this deck? This cannot be undone.')) return;
var decks = getDecks().filter(function(d) { return d.id !== deckId; });
saveDecks(decks);
if (dbCurrentDeck.id === deckId) {
dbCurrentDeck = { id: null, name: 'Untitled Deck', commander: null, cards: [], dateCreated: null, dateModified: null };
var nameInput = document.getElementById('db-deck-name');
if (nameInput) nameInput.value = 'Untitled Deck';
renderDbCommanderSlot();
renderDbList();
}
renderDbSavedDecks();
showToast('Deck deleted');
}
function toggleSavedDecks() {
dbSavedOpen = !dbSavedOpen;
var panel = document.getElementById('db-saved-panel');
if (panel) panel.style.display = dbSavedOpen ? '' : 'none';
if (dbSavedOpen) renderDbSavedDecks();
}
function renderDbSavedDecks() {
var listEl = document.getElementById('db-saved-list');
if (!listEl) return;
var decks = getDecks();
if (!decks.length) {
listEl.innerHTML = '
No saved decks.
';
return;
}
var html = '';
decks.forEach(function(deck) {
var cardCount = (deck.cards || []).reduce(function(s, c) { return s + c.quantity; }, 0) + (deck.commander ? 1 : 0);
var ci = (deck.commander && deck.commander.colorIdentity) || [];
var colorPips = ci.map(function(c) {
var colors = { W: '#F9E48B', U: '#5b9bd5', B: '#aaaaaa', R: '#D3202A', G: '#00733E' };
return '';
}).join('');
html += '
'
+ '
' + escapeHtml(deck.name) + '
'
+ '
' + colorPips + ' ' + cardCount + ' cards
'
+ '
'
+ ''
+ ''
+ ''
+ '
'
+ '
';
});
listEl.innerHTML = html;
}
// --- Import / Export ---
function openDeckBuilderImport() {
var modal = document.getElementById('db-import-modal');
if (modal) modal.classList.remove('hidden');
switchDbImportTab('import');
var textarea = document.getElementById('db-import-textarea');
if (textarea) textarea.value = '';
var status = document.getElementById('db-import-status');
if (status) status.textContent = '';
var progress = document.getElementById('db-import-progress');
if (progress) progress.style.display = 'none';
dbImportCancelFlag = false;
}
function closeDbImportModal() {
var modal = document.getElementById('db-import-modal');
if (modal) modal.classList.add('hidden');
dbImportCancelFlag = true;
}
function switchDbImportTab(tab) {
var importPanel = document.getElementById('db-imp-panel-import');
var exportPanel = document.getElementById('db-imp-panel-export');
var importTab = document.getElementById('db-imp-tab-import');
var exportTab = document.getElementById('db-imp-tab-export');
if (tab === 'import') {
if (importPanel) importPanel.style.display = '';
if (exportPanel) exportPanel.classList.add('hidden');
if (importTab) importTab.classList.add('active');
if (exportTab) exportTab.classList.remove('active');
} else {
if (importPanel) importPanel.style.display = 'none';
if (exportPanel) exportPanel.classList.remove('hidden');
if (importTab) importTab.classList.remove('active');
if (exportTab) exportTab.classList.add('active');
generateDbExport();
}
}
async function importDeckBuilder() {
var textarea = document.getElementById('db-import-textarea');
if (!textarea || !textarea.value.trim()) return;
var lines = textarea.value.split('\n');
var status = document.getElementById('db-import-status');
var progressEl = document.getElementById('db-import-progress');
var progressBar = document.getElementById('db-import-progress-bar');
var progressText = document.getElementById('db-import-progress-text');
var importBtn = document.getElementById('db-import-btn');
dbImportCancelFlag = false;
if (importBtn) importBtn.textContent = 'âļ Cancel';
if (importBtn) importBtn.onclick = function() { dbImportCancelFlag = true; };
// Parse lines
var parsedCards = [];
var isCommanderSection = false;
lines.forEach(function(line) {
line = line.trim();
if (!line) return;
if (/^\/\/\s*commander/i.test(line)) { isCommanderSection = true; return; }
if (/^\/\//.test(line)) { isCommanderSection = false; return; }
var match = line.match(/^(\d+)\s+(.+)$/);
if (!match) return;
parsedCards.push({ qty: parseInt(match[1]), name: match[2].trim(), isCommander: isCommanderSection });
isCommanderSection = false;
});
if (!parsedCards.length) {
if (status) status.textContent = 'No valid card lines found.';
return;
}
if (progressEl) progressEl.style.display = '';
if (status) status.textContent = 'Importing...';
// Clear current deck
dbCurrentDeck.cards = [];
dbCurrentDeck.commander = null;
var total = parsedCards.length;
var done = 0;
var errors = [];
for (var i = 0; i < parsedCards.length; i++) {
if (dbImportCancelFlag) {
if (status) status.textContent = 'Import cancelled.';
break;
}
var item = parsedCards[i];
try {
var url = 'https://api.scryfall.com/cards/named?exact=' + encodeURIComponent(item.name);
var resp = await fetch(url);
if (resp.ok) {
var card = await resp.json();
var imgUrl = (card.image_uris && card.image_uris.small) || '';
var cardEntry = {
scryfallId: card.id,
name: card.name,
imageUrl: imgUrl,
typeLine: card.type_line || '',
manaCost: card.mana_cost || '',
cmc: card.cmc || 0,
quantity: item.qty,
colorIdentity: card.color_identity || [],
legality: (card.legalities && card.legalities.commander) || null,
keywords: card.keywords || [],
oracleText: card.oracle_text || ''
};
if (item.isCommander || (item.qty === 1 && /legendary.*creature/i.test(card.type_line || '') && !dbCurrentDeck.commander)) {
if (item.isCommander) {
dbCurrentDeck.commander = { scryfallId: card.id, name: card.name, imageUrl: imgUrl, typeLine: card.type_line || '', colorIdentity: card.color_identity || [], legality: (card.legalities && card.legalities.commander) || null, keywords: card.keywords || [], oracleText: card.oracle_text || '' };
} else {
dbCurrentDeck.cards.push(cardEntry);
}
} else {
dbCurrentDeck.cards.push(cardEntry);
}
} else {
errors.push(item.name);
}
} catch(e) {
errors.push(item.name);
}
done++;
var pct = Math.round((done / total) * 100);
if (progressBar) progressBar.style.width = pct + '%';
if (progressText) progressText.textContent = done + ' / ' + total;
// Rate limit
await new Promise(function(r) { setTimeout(r, 100); });
}
if (!dbImportCancelFlag) {
var msg = 'Import complete! ' + (dbCurrentDeck.cards.length + (dbCurrentDeck.commander ? 1 : 0)) + ' cards loaded.';
if (errors.length) msg += ' (' + errors.length + ' not found: ' + errors.slice(0,3).join(', ') + (errors.length > 3 ? '...' : '') + ')';
if (status) status.textContent = msg;
}
if (importBtn) { importBtn.textContent = 'ðĨ Import'; importBtn.onclick = importDeckBuilder; }
var nameInput = document.getElementById('db-deck-name');
if (nameInput && dbCurrentDeck.commander) nameInput.value = dbCurrentDeck.commander.name + ' Commander';
dbCurrentDeck.name = (nameInput && nameInput.value) || 'Imported Deck';
renderDbCommanderSlot();
renderDbList();
renderDbStats();
renderDbResultGrid(dbSearchResults);
checkDbLegality();
}
function generateDbExport() {
var lines = [];
if (dbCurrentDeck.commander) {
lines.push('// Commander');
lines.push('1 ' + dbCurrentDeck.commander.name);
lines.push('');
}
lines.push('// Deck');
var sorted = dbCurrentDeck.cards.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
sorted.forEach(function(c) {
lines.push(c.quantity + ' ' + c.name);
});
var textarea = document.getElementById('db-export-textarea');
if (textarea) textarea.value = lines.join('\n');
}
function exportDeckBuilderList() {
openDeckBuilderImport();
setTimeout(function() { switchDbImportTab('export'); }, 50);
}
function copyDbExport() {
var textarea = document.getElementById('db-export-textarea');
if (!textarea) return;
textarea.select();
try {
document.execCommand('copy');
showToast('Deck list copied to clipboard!');
} catch(e) {
showToast('Copy failed â please select and copy manually');
}
}
// ============================================================
// TOAST NOTIFICATIONS
// ============================================================
function showToast(message) {
var existing = document.getElementById('app-toast');
if (existing) existing.remove();
var toast = document.createElement('div');
toast.id = 'app-toast';
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(30,30,50,0.95);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 20px;color:#e0e0e0;font-size:14px;font-family:Inter,sans-serif;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);pointer-events:none;transition:opacity 0.3s;max-width:400px;text-align:center;';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
setTimeout(function() { toast.remove(); }, 300);
}, 2500);
}
// ============================================================
// ESCAPE HTML HELPER
// (Define only if not already defined elsewhere)
// ============================================================
if (typeof escapeHtml !== 'function') {
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
}
// ============================================================
// INIT â call on DOMContentLoaded
// ============================================================
function initDeckBuilderAndCollection() {
// Ensure play content is visible by default
var playContent = document.getElementById('mode-play-content');
if (playContent) playContent.style.display = '';
// Ensure collection and deckbuilder are hidden
var collScreen = document.getElementById('collection-screen');
var dbScreen = document.getElementById('deckbuilder-screen');
if (collScreen) collScreen.classList.add('hidden');
if (dbScreen) dbScreen.classList.add('hidden');
// Close ctx menu when clicking outside
document.addEventListener('click', function(e) {
var menu = document.getElementById('db-ctx-menu');
if (menu && !menu.contains(e.target)) {
menu.classList.add('hidden');
}
});
// Initialize import dropzone
initCollImportDropzone();
// Initialize legality panel collapse state
initLegalityPanelState();
}
// ============================================
// HOW TO PLAY GUIDE MODAL
// ============================================
function openGuideModal() {
var modal = document.getElementById('guide-modal');
if (modal) {
modal.style.display = 'flex';
// Scroll body to top of modal content
var body = modal.querySelector('.guide-modal-body');
if (body) body.scrollTop = 0;
}
}
function closeGuideModal() {
var modal = document.getElementById('guide-modal');
if (modal) {
modal.style.display = 'none';
}
}
// ============================================
// FLOATING GAME LOG (right-side panel)
// ============================================
var _floatingLogMinimized = false;
function toggleFloatingLog() {
_floatingLogMinimized = !_floatingLogMinimized;
var panel = document.getElementById('floating-game-log');
var btn = document.getElementById('fgl-minimize-btn');
if (panel) {
panel.classList.toggle('minimized', _floatingLogMinimized);
}
if (btn) {
btn.innerHTML = _floatingLogMinimized ? '➕' : '➖';
btn.title = _floatingLogMinimized ? 'Expand' : 'Minimize';
}
}
function addFloatingLogEntry(text, category) {
var container = document.getElementById('fgl-entries');
if (!container) return;
// Clear the placeholder
var empty = container.querySelector('.fgl-empty');
if (empty) empty.remove();
var cat = category || 'general';
var catInfo = (typeof LOG_CATEGORY_ICONS !== 'undefined' && LOG_CATEGORY_ICONS[cat]) ? LOG_CATEGORY_ICONS[cat] : { icon: '>', color: '#888' };
var turnNum = (gameState && gameState.turnNumber) ? gameState.turnNumber : 1;
var now = new Date();
var time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
var entry = document.createElement('div');
entry.className = 'fgl-entry';
entry.innerHTML =
'' + catInfo.icon + '' +
'[T' + turnNum + ' ' + time + '] ' +
'' + text + '';
container.insertBefore(entry, container.firstChild);
// Keep max 60 entries
while (container.children.length > 60) container.removeChild(container.lastChild);
}
// ============================================================
// RULES ENGINE EXPANSION â CLEANUP STEP
// ============================================================
/**
* Cleanup step: runs at end of each turn after end step.
* - Clears damage counters on all creatures
* - Removes 'until end of turn' effects (if tracked)
* - Checks state-based actions one more time
*/
function performCleanupStep() {
if (!gameState) return;
// Clear damage counters on all creatures
var clearedCount = 0;
gameState.battlefieldCards.forEach(function(card) {
if (card.damageThisTurn && card.damageThisTurn > 0) {
card.damageThisTurn = 0;
clearedCount++;
updateDamageBadge(card);
}
});
if (clearedCount > 0) {
addLogEntry('\u2728 Cleanup: damage cleared from ' + clearedCount + ' creature' + (clearedCount > 1 ? 's' : '') + '.');
}
// Remove 'until end of turn' temporary effects
var removedEffects = 0;
gameState.battlefieldCards.forEach(function(card) {
if (card.tempEffects && card.tempEffects.length > 0) {
card.tempEffects = card.tempEffects.filter(function(e) {
if (e.until === 'end_of_turn') {
removedEffects++;
return false;
}
return true;
});
}
});
if (removedEffects > 0) {
addLogEntry('\u2728 Cleanup: ' + removedEffects + ' temporary effect' + (removedEffects > 1 ? 's' : '') + ' expired.');
}
// Run SBAs after cleanup
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) {
checkStateBasedActions();
}
}
// ============================================================
// RULES ENGINE EXPANSION â MONARCH MECHANIC
// ============================================================
/**
* Become the Monarch. The Monarch draws an extra card at end step.
* If the Monarch is dealt combat damage by a creature, that creature's controller
* becomes the new Monarch.
* @param {number} playerIndex
*/
function becomeMonarch(playerIndex) {
if (!gameState) return;
var oldMonarch = gameState.monarchPlayerIndex;
gameState.monarchPlayerIndex = playerIndex;
var player = gameState.players[playerIndex];
if (!player) return;
addLogEntry('\ud83d\udc51 ' + player.name + ' becomes the Monarch!');
if (oldMonarch !== undefined && oldMonarch !== null && oldMonarch !== playerIndex) {
var old = gameState.players[oldMonarch];
if (old) {
addLogEntry('\ud83d\udc51 ' + old.name + ' is no longer the Monarch.');
}
}
renderPlayerPanels();
updateMonarchBadges();
}
/**
* Check if combat damage to the Monarch should transfer monarchy.
* Called after combat damage is dealt to a player.
*/
function checkMonarchCombatDamage(attackerCard, targetPlayerId) {
if (!gameState) return;
if (gameState.monarchPlayerIndex === undefined || gameState.monarchPlayerIndex === null) return;
if (targetPlayerId !== gameState.monarchPlayerIndex) return;
// The attacker's controller becomes the new Monarch
if (attackerCard && attackerCard.ownerId !== undefined) {
becomeMonarch(attackerCard.ownerId);
}
}
/**
* Update visual monarch badges on player panels.
*/
function updateMonarchBadges() {
if (!gameState) return;
// Remove all existing monarch badges
document.querySelectorAll('.monarch-badge').forEach(function(el) { el.remove(); });
// Add badge to current monarch's panel
if (gameState.monarchPlayerIndex !== undefined && gameState.monarchPlayerIndex !== null) {
var panel = document.getElementById('player-panel-' + gameState.monarchPlayerIndex);
if (panel) {
var badge = document.createElement('div');
badge.className = 'monarch-badge';
badge.title = 'The Monarch \u2014 draws an extra card at end step';
badge.textContent = '\ud83d\udc51';
panel.appendChild(badge);
}
}
}
// ============================================================
// RULES ENGINE EXPANSION â LETHAL DAMAGE SBA
// ============================================================
/**
* Enhanced SBA: check creatures with accumulated damage >= toughness.
* This catches damage from non-combat sources (e.g. burn spells).
* Called from checkStateBasedActions().
*/
function checkLethalDamageSBA(alerts) {
if (!gameState) return;
gameState.battlefieldCards.forEach(function(card) {
if (!card.typeLine || !/creature/i.test(card.typeLine)) return;
if (!card.damageThisTurn || card.damageThisTurn <= 0) return;
if (hasKeyword(card, 'indestructible')) return;
var toughness = getCreatureToughness(card);
// Also account for any -1/-1 counters reducing effective toughness
if (card.counters && card.counters['-1/-1']) {
toughness -= card.counters['-1/-1'];
}
if (card.damageThisTurn >= toughness) {
alerts.push({
type: 'lethal_damage',
player: (gameState.players[card.ownerIndex] || {}).name || 'Unknown',
playerIdx: card.ownerIndex,
cardId: card.id,
cardName: card.name,
msg: card.name + ' has ' + card.damageThisTurn + ' damage (toughness ' + toughness + ') \u2014 should be destroyed',
});
}
});
}
// ============================================================
// VISUAL POLISH: KEYBOARD SHORTCUTS CHEAT SHEET
// ============================================================
function openShortcutsModal() {
var modal = document.getElementById('shortcuts-modal');
if (!modal) return;
renderShortcutsContent();
modal.classList.add('visible');
}
function closeShortcutsModal() {
var modal = document.getElementById('shortcuts-modal');
if (modal) modal.classList.remove('visible');
}
function renderShortcutsContent() {
var body = document.getElementById('shortcuts-modal-body');
if (!body) return;
body.innerHTML = '' +
'
' +
'
Game Flow
' +
'
Next PhaseSpace
' +
'
Next TurnN
' +
'
Pass Priority (when open)Space
' +
'
' +
'
' +
'
Actions
' +
'
Untap AllU
' +
'
Roll Dice / Flip CoinD
' +
'
Trigger StackT
' +
'
Toggle Mana PoolM
' +
'
' +
'
' +
'
Panels & Tools
' +
'
Save / Load GameS
' +
'
Keyword ReferenceK
' +
'
Keyboard Shortcuts?
' +
'
Undo HistoryH
' +
'
Close Any PanelEsc
' +
'
' +
'
' +
'
Edit
' +
'
UndoCtrl + Z
' +
'
RedoCtrl + Y
' +
'
' +
'
' +
'
Cards
' +
'
Tap / UntapDouble-click card
' +
'
Context MenuRight-click card
' +
'
Move CardClick + drag
' +
'
';
}
// ============================================================
// VISUAL POLISH: UNDO HISTORY VIEWER
// ============================================================
// undoHistoryVisible declared in ui.js
function toggleUndoHistory() {
undoHistoryVisible = !undoHistoryVisible;
var panel = document.getElementById('undo-history-panel');
if (!panel) return;
if (undoHistoryVisible) {
panel.classList.add('visible');
renderUndoHistory();
} else {
panel.classList.remove('visible');
}
}
function closeUndoHistory() {
undoHistoryVisible = false;
var panel = document.getElementById('undo-history-panel');
if (panel) panel.classList.remove('visible');
}
function renderUndoHistory() {
var list = document.getElementById('undo-history-list');
var countEl = document.getElementById('undo-history-count');
if (!list) return;
if (typeof undoStack === 'undefined' || undoStack.length === 0) {
list.innerHTML = '
No actions to undo.
';
if (countEl) countEl.textContent = '0 steps';
return;
}
if (countEl) countEl.textContent = undoStack.length + ' step' + (undoStack.length !== 1 ? 's' : '');
var html = '';
// Show most recent first
for (var i = undoStack.length - 1; i >= 0; i--) {
var snap = undoStack[i];
var desc = snap._description || 'Action';
var num = undoStack.length - i;
html += '
' +
'' + num + '' +
'' + desc + '' +
'Undo to here' +
'
';
}
list.innerHTML = html;
}
function undoToStep(targetIndex) {
if (typeof undoStack === 'undefined') return;
// Undo repeatedly until we reach the target
var steps = undoStack.length - 1 - targetIndex;
for (var s = 0; s < steps; s++) {
if (undoStack.length === 0) break;
var current = deepCloneState();
if (current) redoStack.push(current);
undoStack.pop();
}
// Now do the final undo
if (undoStack.length > 0) {
var current2 = deepCloneState();
if (current2) redoStack.push(current2);
var prev = undoStack.pop();
restoreFromSnapshot(prev);
addLogEntry('\u21A9 Undo (' + (steps + 1) + ' steps)');
updateUndoRedoButtons();
}
renderUndoHistory();
}
// ============================================================
// VISUAL POLISH: ENHANCED COMBAT DAMAGE ANIMATIONS
// ============================================================
function triggerDamageFlash(elementId, type) {
var el = document.getElementById(elementId);
if (!el) return;
var cls = type === 'heal' ? 'heal-flash' : (type === 'death' ? 'death-flash' : 'damage-flash');
el.classList.remove(cls);
// Force reflow to restart animation
void el.offsetWidth;
el.classList.add(cls);
setTimeout(function() { el.classList.remove(cls); }, 800);
}
function triggerScreenShake() {
var gameBoard = document.getElementById('game-board');
if (!gameBoard) return;
gameBoard.classList.remove('screen-shake');
void gameBoard.offsetWidth;
gameBoard.classList.add('screen-shake');
setTimeout(function() { gameBoard.classList.remove('screen-shake'); }, 500);
}
// ============================================================
// VISUAL POLISH: CARD ENTER ANIMATION
// ============================================================
function animateCardEnter(cardId) {
var el = document.getElementById('bc-' + cardId);
if (!el) return;
el.classList.add('card-entering');
setTimeout(function() { el.classList.remove('card-entering'); }, 500);
}
// ============================================================
// VISUAL POLISH: CARD EXIT ANIMATION
// ============================================================
function animateCardExit(cardEl, zone) {
if (!cardEl) return;
var cls = 'card-exit-graveyard';
if (zone === 'exile') cls = 'card-exit-exile';
else if (zone === 'hand' || zone === 'library') cls = 'card-exit-hand';
cardEl.classList.add(cls);
return 350; // duration ms
}
// ============================================================
// VISUAL POLISH: BATTLEFIELD ZONES LAYOUT
// ============================================================
var bfZonesVisible = false;
function toggleBattlefieldZones() {
bfZonesVisible = !bfZonesVisible;
var btn = document.getElementById('bf-zones-toggle');
if (btn) btn.classList.toggle('active', bfZonesVisible);
renderBattlefieldZoneLabels();
}
function renderBattlefieldZoneLabels() {
// Add or remove zone labels from each section area
if (!gameState) return;
for (var pi = 0; pi < gameState.players.length; pi++) {
var area = document.getElementById('bf-section-area-' + pi);
if (!area) continue;
// Remove existing zone labels
var existing = area.querySelector('.bf-zone-labels');
if (existing) existing.remove();
if (!bfZonesVisible) continue;
var labelsDiv = document.createElement('div');
labelsDiv.className = 'bf-zone-labels';
labelsDiv.innerHTML =
'Creatures' +
'Artifacts / Enchantments' +
'Lands' +
'Instants / Sorceries' +
'' +
'' +
'';
area.insertBefore(labelsDiv, area.firstChild);
}
}
// ============================================================
// HAND CARD HOVER PREVIEW
// ============================================================
function setupHandCardPreview() {
// Delegate mouseover/mouseout on hand-cards container
var handCardsEl = document.getElementById('hand-cards');
if (!handCardsEl) return;
handCardsEl.addEventListener('mouseover', function(e) {
var cardEl = e.target.closest('.hand-card');
if (!cardEl || !gameState) return;
var playerId = 0; // Player 1 hand
var idx = Array.from(handCardsEl.querySelectorAll('.hand-card')).indexOf(cardEl);
if (idx < 0) return;
var player = gameState.players[playerId];
if (!player) return;
var card = player.zones.hand[idx];
if (!card) return;
var img = card.imageUrl || '';
if (img) {
showFloatingCardPreview(img, e, {
name: card.name,
typeLine: card.typeLine,
oracleText: card.oracleText,
manaCost: card.manaCost || '',
power: card.power,
toughness: card.toughness,
pt: card.pt,
loyalty: card.loyalty
});
var preview = document.getElementById('card-preview');
if (preview) preview.classList.add('hand-preview-mode');
}
});
handCardsEl.addEventListener('mouseout', function(e) {
var cardEl = e.target.closest('.hand-card');
if (cardEl) {
hideFloatingCardPreview();
var preview = document.getElementById('card-preview');
if (preview) preview.classList.remove('hand-preview-mode');
}
});
handCardsEl.addEventListener('mousemove', function(e) {
var cardEl = e.target.closest('.hand-card');
if (cardEl) positionFloatingPreview(e);
});
}
// ============================================================
// AI SELF-PLAY TRAINING ENGINE (Heuristic Hill-Climbing)
// ============================================================
var aiTrainingState = {
running: false,
generation: 0,
totalGens: 0,
bestScore: -Infinity,
bestWeights: null,
currentPhase: 'combat', // 'combat', 'stack', 'politics'
log: [],
gamesPerEval: 6,
improvementsFound: 0,
startTime: 0,
cancelled: false,
};
// --- Headless (DOM-free) game simulation for fast self-play ---
function headlessCreatePlayer(name, deckCards, idx) {
var hand = [];
var library = [];
deckCards.forEach(function(c, i) {
library.push({
id: 90000 + idx * 1000 + i,
name: c.name || 'Card ' + i,
typeLine: c.typeLine || '',
oracleText: c.oracleText || '',
manaCost: c.manaCost || '',
cmc: c.cmc || 0,
pt: c.pt || '',
isRamp: c.isRamp || false,
isRemoval: c.isRemoval || false,
isBoardWipe: c.isBoardWipe || false,
isCommander: false,
});
});
// Shuffle
for (var i = library.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = library[i]; library[i] = library[j]; library[j] = tmp;
}
// Draw 7
for (var d = 0; d < 7 && library.length > 0; d++) {
hand.push(library.pop());
}
return {
name: name,
life: 40,
isAI: true,
eliminated: false,
color: idx === 0 ? '#e8a04a' : idx === 1 ? '#6ba3e8' : idx === 2 ? '#6be86b' : '#e86b6b',
zones: { hand: hand, library: library, graveyard: [], exile: [], command: [] },
manaPool: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 },
commanderDamageDealt: {},
commanderCastCount: 0,
};
}
function headlessSimGame(weightsA, weightsB, deckA, deckB) {
// Minimal game state for evaluation
var sim = {
players: [
headlessCreatePlayer('Player A', deckA, 0),
headlessCreatePlayer('Player B', deckB, 1),
],
battlefieldCards: [],
turn: 0,
maxTurns: 20,
currentPlayerIndex: 0,
combat: { attackers: [], blockers: [] },
nextCardId: 80000,
};
var allWeights = [weightsA, weightsB];
for (var turn = 0; turn < sim.maxTurns; turn++) {
for (var pi = 0; pi < 2; pi++) {
sim.currentPlayerIndex = pi;
var p = sim.players[pi];
if (p.eliminated) continue;
var w = allWeights[pi];
// Draw a card
if (p.zones.library.length > 0) {
p.zones.hand.push(p.zones.library.pop());
}
// Simulate land drop (find land in hand, add to battlefield as mana source)
var landIdx = -1;
for (var li = 0; li < p.zones.hand.length; li++) {
if (p.zones.hand[li].typeLine && p.zones.hand[li].typeLine.toLowerCase().indexOf('land') !== -1) {
landIdx = li;
break;
}
}
if (landIdx !== -1) {
var landCard = p.zones.hand.splice(landIdx, 1)[0];
landCard.ownerId = pi;
landCard.tapped = false;
landCard.id = sim.nextCardId++;
sim.battlefieldCards.push(landCard);
}
// Count available mana (untapped lands)
var availableMana = 0;
sim.battlefieldCards.forEach(function(c) {
if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) {
availableMana++;
}
});
// Score and play best affordable non-land card
var playable = [];
for (var hi = 0; hi < p.zones.hand.length; hi++) {
var card = p.zones.hand[hi];
if (card.typeLine && card.typeLine.toLowerCase().indexOf('land') !== -1) continue;
var cmc = card.cmc || 0;
if (cmc <= availableMana) {
playable.push({ card: card, idx: hi, score: headlessScoreCard(card, w) });
}
}
playable.sort(function(a, b) { return b.score - a.score; });
// Play up to 2 cards per turn
var played = 0;
while (playable.length > 0 && played < 2) {
var best = playable.shift();
var handPos = p.zones.hand.indexOf(best.card);
if (handPos === -1) continue;
var playedCard = p.zones.hand.splice(handPos, 1)[0];
playedCard.ownerId = pi;
playedCard.tapped = false;
playedCard.id = sim.nextCardId++;
playedCard.turnPlayed = turn;
// Tap lands to pay
var manaNeeded = playedCard.cmc || 0;
sim.battlefieldCards.forEach(function(c) {
if (manaNeeded <= 0) return;
if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) {
c.tapped = true;
manaNeeded--;
}
});
// Handle removal: destroy strongest opponent creature
if (playedCard.isRemoval) {
var oppCreatures = sim.battlefieldCards.filter(function(c) {
return c.ownerId !== pi && c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1;
});
if (oppCreatures.length > 0) {
oppCreatures.sort(function(a, b) {
return headlessScoreCard(b, w) - headlessScoreCard(a, w);
});
var killed = oppCreatures[0];
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== killed.id; });
sim.players[killed.ownerId].zones.graveyard.push(killed);
}
p.zones.graveyard.push(playedCard);
} else if (playedCard.isBoardWipe) {
var surviving = [];
sim.battlefieldCards.forEach(function(c) {
if (c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1) {
sim.players[c.ownerId].zones.graveyard.push(c);
} else {
surviving.push(c);
}
});
sim.battlefieldCards = surviving;
p.zones.graveyard.push(playedCard);
} else {
sim.battlefieldCards.push(playedCard);
}
played++;
// Recalculate available mana
availableMana = 0;
sim.battlefieldCards.forEach(function(c) {
if (c.ownerId === pi && !c.tapped && c.typeLine && c.typeLine.toLowerCase().indexOf('land') !== -1) {
availableMana++;
}
});
}
// --- COMBAT PHASE ---
var myCreatures = sim.battlefieldCards.filter(function(c) {
return c.ownerId === pi &&
c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1 &&
!c.tapped &&
(turn > 0 || c.turnPlayed !== turn); // Pseudo summoning sickness
});
var oppIdx = 1 - pi;
var opp = sim.players[oppIdx];
if (opp.eliminated || myCreatures.length === 0) {
// Untap
sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; });
continue;
}
var oppBlockers = sim.battlefieldCards.filter(function(c) {
return c.ownerId === oppIdx &&
c.typeLine && c.typeLine.toLowerCase().indexOf('creature') !== -1 &&
!c.tapped;
});
// Decide which creatures to attack with
var attackers = [];
myCreatures.forEach(function(atk) {
var aPow = headlessGetPower(atk);
var aTou = headlessGetToughness(atk);
var hasFlying = headlessHasKw(atk, 'flying');
var hasTrample = headlessHasKw(atk, 'trample');
// Check if any blocker can kill us
var canDie = oppBlockers.some(function(b) {
if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false;
return headlessGetPower(b) >= aTou || headlessHasKw(b, 'deathtouch');
});
if (!canDie || hasFlying || hasTrample || aPow >= 4) {
attackers.push(atk);
atk.tapped = true;
}
});
// Simple blocking: opponent assigns biggest blocker to biggest attacker
var totalDamage = 0;
var usedBlockers = {};
attackers.forEach(function(atk) {
var aPow = headlessGetPower(atk);
var aTou = headlessGetToughness(atk);
var hasFlying = headlessHasKw(atk, 'flying');
var validBlockers = oppBlockers.filter(function(b) {
if (usedBlockers[b.id]) return false;
if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false;
return true;
});
if (validBlockers.length > 0 && !headlessHasKw(atk, 'menace')) {
// Block with creature that can survive, or chump if life is low
var blocker = validBlockers.find(function(b) { return headlessGetToughness(b) > aPow; });
if (!blocker && opp.life <= aPow * 2) blocker = validBlockers[0]; // chump
if (blocker) {
usedBlockers[blocker.id] = true;
// Assign damage
var bPow = headlessGetPower(blocker);
if (aPow >= headlessGetToughness(blocker) || headlessHasKw(atk, 'deathtouch')) {
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== blocker.id; });
sim.players[oppIdx].zones.graveyard.push(blocker);
}
if (bPow >= aTou || headlessHasKw(blocker, 'deathtouch')) {
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== atk.id; });
sim.players[pi].zones.graveyard.push(atk);
}
if (headlessHasKw(atk, 'trample') && aPow > headlessGetToughness(blocker)) {
totalDamage += aPow - headlessGetToughness(blocker);
}
return;
}
}
// Unblocked
totalDamage += aPow;
if (headlessHasKw(atk, 'lifelink')) p.life += aPow;
});
opp.life -= totalDamage;
if (opp.life <= 0) opp.eliminated = true;
// Untap all our stuff for next turn
sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; });
}
// Check if game is over
var alive = sim.players.filter(function(p) { return !p.eliminated; });
if (alive.length <= 1) break;
}
// Evaluate result
var pA = sim.players[0];
var pB = sim.players[1];
// Score: higher is better for player A
var scoreA = 0;
if (pA.eliminated && !pB.eliminated) scoreA = -100;
else if (!pA.eliminated && pB.eliminated) scoreA = 100;
else {
// Both alive: score by life difference and board state
scoreA = (pA.life - pB.life);
var aBoard = sim.battlefieldCards.filter(function(c) { return c.ownerId === 0; }).length;
var bBoard = sim.battlefieldCards.filter(function(c) { return c.ownerId === 1; }).length;
scoreA += (aBoard - bBoard) * 2;
}
return scoreA;
}
function headlessScoreCard(card, w) {
var cmc = card.cmc || 0;
var score = 10 - cmc * (w.card_baseCmcPenalty || 1);
if (card.pt) {
var pts = card.pt.split('/');
score += ((parseInt(pts[0]) || 0) + (parseInt(pts[1]) || 0)) * (w.card_ptBonus || 1);
}
var text = (card.oracleText || '').toLowerCase();
if (text.indexOf('flying') !== -1) score += (w.kw_flying || 2.5);
if (text.indexOf('deathtouch') !== -1) score += (w.kw_deathtouch || 2.5);
if (text.indexOf('haste') !== -1) score += (w.kw_haste || 2.5);
if (text.indexOf('trample') !== -1) score += (w.kw_trample || 1.5);
if (text.indexOf('lifelink') !== -1) score += (w.kw_lifelink || 1.5);
if (card.isRamp) score += (w.spell_ramp || 6);
if (card.isRemoval) score += (w.spell_destroy || 5);
return score;
}
function headlessGetPower(card) {
if (!card.pt) return 0;
return parseInt(card.pt.split('/')[0]) || 0;
}
function headlessGetToughness(card) {
if (!card.pt) return 0;
return parseInt(card.pt.split('/')[1]) || 0;
}
function headlessHasKw(card, kw) {
var text = (card.oracleText || '').toLowerCase();
return text.indexOf(kw) !== -1;
}
// --- Generate a synthetic deck for training ---
function generateTrainingDeck() {
var deck = [];
// 37 lands
for (var i = 0; i < 37; i++) {
deck.push({ name: 'Forest', typeLine: 'Basic Land - Forest', oracleText: '{T}: Add {G}', cmc: 0, pt: '' });
}
// 20 creatures of varying sizes
var creatures = [
{ name: 'Llanowar Elves', typeLine: 'Creature - Elf Druid', oracleText: '{T}: Add {G}', cmc: 1, pt: '1/1', isRamp: true },
{ name: 'Elvish Mystic', typeLine: 'Creature - Elf Druid', oracleText: '{T}: Add {G}', cmc: 1, pt: '1/1', isRamp: true },
{ name: 'Grizzly Bears', typeLine: 'Creature - Bear', oracleText: '', cmc: 2, pt: '2/2' },
{ name: 'Runeclaw Bear', typeLine: 'Creature - Bear', oracleText: '', cmc: 2, pt: '2/2' },
{ name: 'Centaur Courser', typeLine: 'Creature - Centaur', oracleText: '', cmc: 3, pt: '3/3' },
{ name: 'Alpine Grizzly', typeLine: 'Creature - Bear', oracleText: '', cmc: 3, pt: '4/2' },
{ name: 'Garruk\'s Companion', typeLine: 'Creature - Beast', oracleText: 'Trample', cmc: 2, pt: '3/2' },
{ name: 'Leatherback Baloth', typeLine: 'Creature - Beast', oracleText: '', cmc: 3, pt: '4/5' },
{ name: 'Territorial Scythecat', typeLine: 'Creature - Cat', oracleText: 'Trample', cmc: 3, pt: '2/2' },
{ name: 'Charging Badger', typeLine: 'Creature - Badger', oracleText: 'Trample', cmc: 1, pt: '1/1' },
{ name: 'Sentinel Spider', typeLine: 'Creature - Spider', oracleText: 'Vigilance, reach', cmc: 5, pt: '4/4' },
{ name: 'Vorstclaw', typeLine: 'Creature - Elemental', oracleText: '', cmc: 6, pt: '7/7' },
{ name: 'Pelakka Wurm', typeLine: 'Creature - Wurm', oracleText: 'Trample. When this enters the battlefield, you gain 7 life.', cmc: 7, pt: '7/7' },
{ name: 'Typhoid Rats', typeLine: 'Creature - Rat', oracleText: 'Deathtouch', cmc: 1, pt: '1/1' },
{ name: 'Vampire Nighthawk', typeLine: 'Creature - Vampire', oracleText: 'Flying, deathtouch, lifelink', cmc: 3, pt: '2/3' },
{ name: 'Serra Angel', typeLine: 'Creature - Angel', oracleText: 'Flying, vigilance', cmc: 5, pt: '4/4' },
{ name: 'Air Elemental', typeLine: 'Creature - Elemental', oracleText: 'Flying', cmc: 5, pt: '4/4' },
{ name: 'Giant Spider', typeLine: 'Creature - Spider', oracleText: 'Reach', cmc: 4, pt: '2/4' },
{ name: 'Kalonian Tusker', typeLine: 'Creature - Beast', oracleText: '', cmc: 2, pt: '3/3' },
{ name: 'Baloth Woodcrasher', typeLine: 'Creature - Beast', oracleText: 'Trample', cmc: 6, pt: '4/4' },
];
creatures.forEach(function(c) { deck.push(c); });
// 6 removal spells
for (var r = 0; r < 3; r++) {
deck.push({ name: 'Murder', typeLine: 'Instant', oracleText: 'Destroy target creature.', cmc: 3, pt: '', isRemoval: true });
deck.push({ name: 'Beast Within', typeLine: 'Instant', oracleText: 'Destroy target permanent.', cmc: 3, pt: '', isRemoval: true });
}
// 2 board wipes
deck.push({ name: 'Wrath of God', typeLine: 'Sorcery', oracleText: 'Destroy all creatures.', cmc: 4, pt: '', isBoardWipe: true });
deck.push({ name: 'Damnation', typeLine: 'Sorcery', oracleText: 'Destroy all creatures.', cmc: 4, pt: '', isBoardWipe: true });
// 3 draw spells
deck.push({ name: 'Harmonize', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, pt: '' });
deck.push({ name: 'Divination', typeLine: 'Sorcery', oracleText: 'Draw two cards.', cmc: 3, pt: '' });
deck.push({ name: 'Concentrate', typeLine: 'Sorcery', oracleText: 'Draw three cards.', cmc: 4, pt: '' });
// 2 ramp
deck.push({ name: 'Rampant Growth', typeLine: 'Sorcery', oracleText: 'Search your library for a basic land card and put it onto the battlefield tapped.', cmc: 2, pt: '', isRamp: true });
deck.push({ name: 'Cultivate', typeLine: 'Sorcery', oracleText: 'Search your library for up to two basic land cards.', cmc: 3, pt: '', isRamp: true });
return deck;
}
// --- Mutate weights: small random perturbation ---
function mutateWeights(weights, mutationRate) {
var mutated = JSON.parse(JSON.stringify(weights));
var keys = Object.keys(mutated);
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (typeof mutated[k] === 'boolean') continue; // Skip booleans
if (Math.random() < mutationRate) {
// Perturb by up to +/- 30%
var delta = (Math.random() - 0.5) * 0.6 * Math.abs(mutated[k] || 1);
mutated[k] = Math.round((mutated[k] + delta) * 100) / 100;
}
}
return mutated;
}
// --- Run one generation of self-play training ---
function runTrainingGeneration(callback) {
var st = aiTrainingState;
if (st.cancelled) {
st.running = false;
renderTrainingUI();
return;
}
st.generation++;
var deckA = generateTrainingDeck();
var deckB = generateTrainingDeck();
var candidateWeights = mutateWeights(st.bestWeights || aiWeights, 0.3);
// Play multiple games and average the score
var totalScore = 0;
for (var g = 0; g < st.gamesPerEval; g++) {
// Alternate who goes first
if (g % 2 === 0) {
totalScore += headlessSimGame(candidateWeights, st.bestWeights || aiWeights, deckA, deckB);
} else {
totalScore -= headlessSimGame(st.bestWeights || aiWeights, candidateWeights, deckA, deckB);
}
}
var avgScore = totalScore / st.gamesPerEval;
// Hill climbing: accept if better
if (avgScore > st.bestScore) {
st.bestScore = avgScore;
st.bestWeights = candidateWeights;
st.improvementsFound++;
st.log.push('Gen ' + st.generation + ': IMPROVED (score: ' + avgScore.toFixed(1) + ')');
} else {
st.log.push('Gen ' + st.generation + ': no improvement (score: ' + avgScore.toFixed(1) + ')');
}
// Keep log manageable
if (st.log.length > 100) st.log = st.log.slice(-50);
renderTrainingUI();
if (st.generation < st.totalGens && !st.cancelled) {
// Use setTimeout to avoid blocking the UI
setTimeout(function() { runTrainingGeneration(callback); }, 1);
} else {
st.running = false;
if (callback) callback();
renderTrainingUI();
}
}
function startAiTraining(numGenerations, phase) {
var st = aiTrainingState;
if (st.running) return;
st.running = true;
st.cancelled = false;
st.generation = 0;
st.totalGens = numGenerations || 50;
st.bestScore = -Infinity;
st.bestWeights = JSON.parse(JSON.stringify(aiWeights));
st.currentPhase = phase || 'combat';
st.improvementsFound = 0;
st.startTime = Date.now();
st.log = ['Training started: Phase ' + st.currentPhase + ', ' + st.totalGens + ' generations'];
renderTrainingUI();
setTimeout(function() {
runTrainingGeneration(function() {
st.log.push('Training complete! ' + st.improvementsFound + ' improvements found in ' +
((Date.now() - st.startTime) / 1000).toFixed(1) + 's');
renderTrainingUI();
});
}, 10);
}
function stopAiTraining() {
aiTrainingState.cancelled = true;
aiTrainingState.log.push('Training stopped by user at gen ' + aiTrainingState.generation);
}
function applyTrainedWeights() {
if (!aiTrainingState.bestWeights) return;
aiWeights = JSON.parse(JSON.stringify(aiTrainingState.bestWeights));
saveAiWeights();
aiTrainingState.log.push('Trained weights applied and saved!');
renderTrainingUI();
}
// ============================================================
// TRAINING UI â Renders inside the AI Settings tab
// ============================================================
function renderTrainingUI() {
var container = document.getElementById('ai-training-panel') || document.getElementById('ai-training-modal-panel');
if (!container) return;
// Also update the modal panel if it exists
var modalPanel = document.getElementById('ai-training-modal-panel');
var settingsPanel = document.getElementById('ai-training-panel');
var st = aiTrainingState;
var html = '';
// Progress bar
if (st.running) {
var pct = st.totalGens > 0 ? Math.round((st.generation / st.totalGens) * 100) : 0;
html += '
';
html += '
';
html += 'Generation ' + st.generation + ' / ' + st.totalGens + '';
html += '' + st.improvementsFound + ' improvements';
html += '
';
html += '
';
html += '';
html += '
';
html += '
';
}
// Controls
html += '
';
if (!st.running) {
html += '';
html += '';
html += '';
} else {
html += '';
}
html += '
';
// Apply / Reset
if (st.bestWeights && !st.running) {
html += '
';
html += '';
html += '';
html += '
';
}
// Weight diff summary
if (st.bestWeights) {
var diffCount = 0;
var diffs = [];
var keys = Object.keys(AI_DEFAULT_WEIGHTS);
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (typeof AI_DEFAULT_WEIGHTS[k] === 'boolean') continue;
var def = AI_DEFAULT_WEIGHTS[k];
var cur = st.bestWeights[k];
if (cur !== undefined && Math.abs(cur - def) > 0.01) {
diffCount++;
var pctChange = def !== 0 ? Math.round(((cur - def) / Math.abs(def)) * 100) : 0;
var arrow = pctChange > 0 ? '+' : '';
diffs.push(k.replace(/_/g, ' ') + ': ' + def + ' -> ' + cur + ' (' + arrow + pctChange + '%)');
}
}
if (diffCount > 0) {
html += '
' + diffCount + ' weights changed:
';
html += '
';
html += diffs.slice(0, 20).join(' ');
if (diffs.length > 20) html += ' ...and ' + (diffs.length - 20) + ' more';
html += '
';
}
}
// Log
if (st.log.length > 0) {
html += '
Training Log:
';
html += '
';
var recentLogs = st.log.slice(-20);
for (var li = 0; li < recentLogs.length; li++) {
var logLine = recentLogs[li];
if (logLine.indexOf('IMPROVED') !== -1) {
html += '
' + logLine + '
';
} else {
html += '
' + logLine + '
';
}
}
html += '
';
}
// Current weight status
var isCustom = false;
var ks = Object.keys(AI_DEFAULT_WEIGHTS);
for (var ci = 0; ci < ks.length; ci++) {
if (typeof AI_DEFAULT_WEIGHTS[ks[ci]] === 'boolean') continue;
if (Math.abs(aiWeights[ks[ci]] - AI_DEFAULT_WEIGHTS[ks[ci]]) > 0.01) { isCustom = true; break; }
}
html += '
';
html += 'Active weights: ' + (isCustom ? 'Custom (trained)' : 'Default');
html += '
';
container.innerHTML = html;
// Write to both panels if both exist
if (modalPanel && modalPanel !== container) modalPanel.innerHTML = html;
if (settingsPanel && settingsPanel !== container) settingsPanel.innerHTML = html;
}
// --- Modal open/close for setup screen access ---
function openAiTrainingModal() {
var modal = document.getElementById('ai-training-modal');
if (modal) {
modal.style.display = 'flex';
renderTrainingUI();
}
}
function closeAiTrainingModal() {
var modal = document.getElementById('ai-training-modal');
if (modal) modal.style.display = 'none';
}
// ============================================================
// DECK TESTER â Monte Carlo simulation against precon decks
// ============================================================
var deckTesterState = {
running: false,
cancelled: false,
results: null,
progress: 0,
total: 0,
currentMatchup: '',
// Configuration
gamesPerMatchup: 50,
maxTurns: 25,
selectedPrecons: [], // indexes into PRECON_DATA
customDeck: null, // { name, commander, cards[] }
};
// --- Parse a precon decklist string into card objects ---
function parseDecklistToCards(decklistStr) {
if (!decklistStr) return [];
var cards = [];
var lines = decklistStr.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.indexOf('//') === 0) continue;
var match = line.match(/^(\d+)\s+(.+)$/);
if (match) {
var qty = parseInt(match[1]) || 1;
var name = match[2].trim();
for (var q = 0; q < qty; q++) {
cards.push({ name: name, typeLine: '', oracleText: '', cmc: 0, pt: '', manaCost: '' });
}
}
}
return cards;
}
// --- Enrich cards with type/cost guesses for headless sim ---
function enrichCardForSim(card) {
var name = (card.name || '').toLowerCase();
var typeLine = (card.typeLine || '').toLowerCase();
var oracleText = (card.oracleText || '').toLowerCase();
// Already has data from Scryfall
if (card.cmc > 0 || typeLine.length > 5) return card;
// Guess from name patterns for precon cards without data
var c = JSON.parse(JSON.stringify(card));
// Basic lands
if (/^(plains|island|swamp|mountain|forest)$/i.test(c.name)) {
var landColors = { plains: 'W', island: 'U', swamp: 'B', mountain: 'R', forest: 'G' };
var cl = landColors[c.name.toLowerCase()] || 'C';
c.typeLine = 'Basic Land - ' + c.name;
c.oracleText = '{T}: Add {' + cl + '}';
c.cmc = 0;
return c;
}
// Common artifacts
if (/sol ring/i.test(c.name)) {
c.typeLine = 'Artifact'; c.cmc = 1; c.oracleText = '{T}: Add {C}{C}'; c.isRamp = true; return c;
}
if (/arcane signet|fellwar stone|talisman|signet/i.test(c.name)) {
c.typeLine = 'Artifact'; c.cmc = 2; c.oracleText = '{T}: Add one mana'; c.isRamp = true; return c;
}
if (/commander.?s sphere/i.test(c.name)) {
c.typeLine = 'Artifact'; c.cmc = 3; c.oracleText = '{T}: Add one mana'; c.isRamp = true; return c;
}
// Common instants / removal
if (/swords to plowshares|path to exile/i.test(c.name)) {
c.typeLine = 'Instant'; c.cmc = 1; c.oracleText = 'Exile target creature.'; c.isRemoval = true; return c;
}
if (/beast within|chaos warp|generous gift/i.test(c.name)) {
c.typeLine = 'Instant'; c.cmc = 3; c.oracleText = 'Destroy target permanent.'; c.isRemoval = true; return c;
}
if (/murder|go for the throat|hero.?s downfall/i.test(c.name)) {
c.typeLine = 'Instant'; c.cmc = 3; c.oracleText = 'Destroy target creature.'; c.isRemoval = true; return c;
}
if (/counterspell|negate|arcane denial/i.test(c.name)) {
c.typeLine = 'Instant'; c.cmc = 2; c.oracleText = 'Counter target spell.'; c.isRemoval = true; return c;
}
// Common board wipes
if (/wrath of god|damnation|blasphemous act|day of judgment|farewell|toxic deluge/i.test(c.name)) {
c.typeLine = 'Sorcery'; c.cmc = 4; c.oracleText = 'Destroy all creatures.'; c.isBoardWipe = true; return c;
}
// Common ramp
if (/rampant growth|nature.?s lore/i.test(c.name)) {
c.typeLine = 'Sorcery'; c.cmc = 2; c.oracleText = 'Search your library for a basic land.'; c.isRamp = true; return c;
}
if (/cultivate|kodama.?s reach/i.test(c.name)) {
c.typeLine = 'Sorcery'; c.cmc = 3; c.oracleText = 'Search your library for two basic lands.'; c.isRamp = true; return c;
}
if (/farseek|three visits/i.test(c.name)) {
c.typeLine = 'Sorcery'; c.cmc = 2; c.oracleText = 'Search your library for a land.'; c.isRamp = true; return c;
}
// Common draw
if (/harmonize|concentrate/i.test(c.name)) {
c.typeLine = 'Sorcery'; c.cmc = 4; c.oracleText = 'Draw three cards.'; return c;
}
// Default: treat as a creature with CMC based on position in list
if (!c.typeLine || c.typeLine.length < 3) {
// Estimate CMC from how "big" the card name sounds
var estimatedCmc = Math.floor(Math.random() * 5) + 2;
var estimatedPow = Math.max(1, estimatedCmc - 1);
c.typeLine = 'Creature';
c.cmc = estimatedCmc;
c.pt = estimatedPow + '/' + estimatedPow;
c.power = String(estimatedPow);
c.toughness = String(estimatedPow);
}
return c;
}
// --- Build a simulation-ready deck from dbCurrentDeck ---
function buildSimDeckFromDeckBuilder() {
var deck = [];
var db = dbCurrentDeck;
if (!db || !db.cards || db.cards.length === 0) return null;
// Add commander first (as a card in deck for sim purposes)
if (db.commander) {
var cmd = enrichCardForSim({
name: db.commander.name || db.commander,
typeLine: db.commander.typeLine || 'Legendary Creature',
oracleText: db.commander.oracleText || '',
cmc: db.commander.cmc || 5,
pt: db.commander.pt || '4/4',
manaCost: db.commander.manaCost || '',
isCommander: true
});
deck.push(cmd);
}
db.cards.forEach(function(c) {
var qty = c.quantity || 1;
for (var q = 0; q < qty; q++) {
deck.push(enrichCardForSim({
name: c.name,
typeLine: c.typeLine || '',
oracleText: c.oracleText || '',
cmc: c.cmc || 0,
pt: c.pt || '',
manaCost: c.manaCost || '',
power: c.power || '',
toughness: c.toughness || '',
colorIdentity: c.colorIdentity,
isRamp: c.isRamp || false,
isRemoval: c.isRemoval || false,
isBoardWipe: c.isBoardWipe || false
}));
}
});
return deck;
}
// --- Build a simulation-ready deck from a precon ---
function buildSimDeckFromPrecon(preconIdx) {
var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA[preconIdx] : null;
if (!precon) return null;
var rawCards = parseDecklistToCards(precon.decklist || '');
var deck = rawCards.map(function(c) { return enrichCardForSim(c); });
return deck;
}
// --- Headless create player (reuse from training engine) ---
function dtCreatePlayer(name, deck, idx) {
var shuffled = deck.slice();
for (var i = shuffled.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = shuffled[i]; shuffled[i] = shuffled[j]; shuffled[j] = t;
}
var hand = shuffled.splice(0, 7);
return {
name: name,
life: 40,
eliminated: false,
ownerId: idx,
zones: { library: shuffled, hand: hand, graveyard: [], exile: [] },
stats: {
cardsDrawn: 7, // starting hand
landsPlayed: 0,
spellsCast: 0,
creaturesPlayed: 0,
removalUsed: 0,
boardWipesUsed: 0,
rampPlayed: 0,
damageDealt: 0,
damageReceived: 0,
maxBoardSize: 0,
turnsAlive: 0,
manaSpent: 0,
}
};
}
// --- Enhanced headless sim for deck testing (richer stats tracking) ---
function dtSimGame(deckA, deckB, nameA, nameB) {
var w = (typeof aiWeights !== 'undefined') ? aiWeights : AI_DEFAULT_WEIGHTS;
var sim = {
players: [
dtCreatePlayer(nameA, deckA, 0),
dtCreatePlayer(nameB, deckB, 1),
],
battlefieldCards: [],
turn: 0,
maxTurns: deckTesterState.maxTurns || 25,
nextCardId: 90000,
};
var turnLog = [];
for (var turn = 0; turn < sim.maxTurns; turn++) {
for (var pi = 0; pi < 2; pi++) {
var p = sim.players[pi];
if (p.eliminated) continue;
p.stats.turnsAlive++;
// Draw
if (p.zones.library.length > 0) {
p.zones.hand.push(p.zones.library.pop());
p.stats.cardsDrawn++;
}
// Land drop
var landIdx = -1;
for (var li = 0; li < p.zones.hand.length; li++) {
if (p.zones.hand[li].typeLine && /land/i.test(p.zones.hand[li].typeLine)) {
landIdx = li; break;
}
}
if (landIdx !== -1) {
var landCard = p.zones.hand.splice(landIdx, 1)[0];
landCard.ownerId = pi; landCard.tapped = false; landCard.id = sim.nextCardId++;
sim.battlefieldCards.push(landCard);
p.stats.landsPlayed++;
}
// Available mana
var availableMana = 0;
sim.battlefieldCards.forEach(function(c) {
if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) availableMana++;
});
// Play spells
var playable = [];
for (var hi = 0; hi < p.zones.hand.length; hi++) {
var card = p.zones.hand[hi];
if (card.typeLine && /land/i.test(card.typeLine)) continue;
var cmc = card.cmc || 0;
if (cmc <= availableMana) {
playable.push({ card: card, idx: hi, score: headlessScoreCard(card, w) });
}
}
playable.sort(function(a, b) { return b.score - a.score; });
var played = 0;
while (playable.length > 0 && played < 2) {
var best = playable.shift();
var handPos = p.zones.hand.indexOf(best.card);
if (handPos === -1) continue;
var playedCard = p.zones.hand.splice(handPos, 1)[0];
playedCard.ownerId = pi; playedCard.tapped = false;
playedCard.id = sim.nextCardId++; playedCard.turnPlayed = turn;
// Pay mana
var manaNeeded = playedCard.cmc || 0;
sim.battlefieldCards.forEach(function(c) {
if (manaNeeded <= 0) return;
if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) {
c.tapped = true; manaNeeded--;
}
});
p.stats.manaSpent += (playedCard.cmc || 0);
p.stats.spellsCast++;
// Handle removal
if (playedCard.isRemoval) {
p.stats.removalUsed++;
var oppCreatures = sim.battlefieldCards.filter(function(c) {
return c.ownerId !== pi && c.typeLine && /creature/i.test(c.typeLine);
});
if (oppCreatures.length > 0) {
oppCreatures.sort(function(a, b) { return headlessScoreCard(b, w) - headlessScoreCard(a, w); });
var killed = oppCreatures[0];
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== killed.id; });
sim.players[killed.ownerId].zones.graveyard.push(killed);
}
p.zones.graveyard.push(playedCard);
} else if (playedCard.isBoardWipe) {
p.stats.boardWipesUsed++;
var surviving = [];
sim.battlefieldCards.forEach(function(c) {
if (c.typeLine && /creature/i.test(c.typeLine)) {
sim.players[c.ownerId].zones.graveyard.push(c);
} else {
surviving.push(c);
}
});
sim.battlefieldCards = surviving;
p.zones.graveyard.push(playedCard);
} else {
sim.battlefieldCards.push(playedCard);
if (playedCard.typeLine && /creature/i.test(playedCard.typeLine)) p.stats.creaturesPlayed++;
if (playedCard.isRamp) p.stats.rampPlayed++;
}
played++;
// Recalculate mana
availableMana = 0;
sim.battlefieldCards.forEach(function(c) {
if (c.ownerId === pi && !c.tapped && c.typeLine && /land/i.test(c.typeLine)) availableMana++;
});
}
// Track board size
var boardSize = sim.battlefieldCards.filter(function(c) { return c.ownerId === pi; }).length;
if (boardSize > p.stats.maxBoardSize) p.stats.maxBoardSize = boardSize;
// --- COMBAT ---
var myCreatures = sim.battlefieldCards.filter(function(c) {
return c.ownerId === pi && c.typeLine && /creature/i.test(c.typeLine) && !c.tapped && (turn > 0 || c.turnPlayed !== turn);
});
var oppIdx = 1 - pi;
var opp = sim.players[oppIdx];
if (opp.eliminated || myCreatures.length === 0) {
sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; });
continue;
}
var oppBlockers = sim.battlefieldCards.filter(function(c) {
return c.ownerId === oppIdx && c.typeLine && /creature/i.test(c.typeLine) && !c.tapped;
});
// Decide attackers
var attackers = [];
myCreatures.forEach(function(atk) {
var aPow = headlessGetPower(atk);
var aTou = headlessGetToughness(atk);
var hasFlying = headlessHasKw(atk, 'flying');
var hasTrample = headlessHasKw(atk, 'trample');
var canDie = oppBlockers.some(function(b) {
if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false;
return headlessGetPower(b) >= aTou || headlessHasKw(b, 'deathtouch');
});
if (!canDie || hasFlying || hasTrample || aPow >= 4) {
attackers.push(atk); atk.tapped = true;
}
});
// Blocking and damage
var totalDamage = 0;
var usedBlockers = {};
attackers.forEach(function(atk) {
var aPow = headlessGetPower(atk);
var aTou = headlessGetToughness(atk);
var hasFlying = headlessHasKw(atk, 'flying');
var validBlockers = oppBlockers.filter(function(b) {
if (usedBlockers[b.id]) return false;
if (hasFlying && !headlessHasKw(b, 'flying') && !headlessHasKw(b, 'reach')) return false;
return true;
});
if (validBlockers.length > 0 && !headlessHasKw(atk, 'menace')) {
var blocker = validBlockers.find(function(b) { return headlessGetToughness(b) > aPow; });
if (!blocker && opp.life <= aPow * 2) blocker = validBlockers[0];
if (blocker) {
usedBlockers[blocker.id] = true;
var bPow = headlessGetPower(blocker);
if (aPow >= headlessGetToughness(blocker) || headlessHasKw(atk, 'deathtouch')) {
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== blocker.id; });
sim.players[oppIdx].zones.graveyard.push(blocker);
}
if (bPow >= aTou || headlessHasKw(blocker, 'deathtouch')) {
sim.battlefieldCards = sim.battlefieldCards.filter(function(c) { return c.id !== atk.id; });
sim.players[pi].zones.graveyard.push(atk);
}
if (headlessHasKw(atk, 'trample') && aPow > headlessGetToughness(blocker)) {
totalDamage += aPow - headlessGetToughness(blocker);
}
if (headlessHasKw(atk, 'lifelink')) p.life += Math.min(aPow, headlessGetToughness(blocker));
return;
}
}
totalDamage += aPow;
if (headlessHasKw(atk, 'lifelink')) p.life += aPow;
});
opp.life -= totalDamage;
p.stats.damageDealt += totalDamage;
opp.stats.damageReceived += totalDamage;
if (opp.life <= 0) opp.eliminated = true;
// Untap
sim.battlefieldCards.forEach(function(c) { if (c.ownerId === pi) c.tapped = false; });
}
// Check game over
var alive = sim.players.filter(function(p) { return !p.eliminated; });
if (alive.length <= 1) break;
}
// Determine winner
var winner = -1;
if (sim.players[0].eliminated && !sim.players[1].eliminated) winner = 1;
else if (!sim.players[0].eliminated && sim.players[1].eliminated) winner = 0;
else {
// Both alive or both dead: compare life
winner = sim.players[0].life >= sim.players[1].life ? 0 : 1;
}
return {
winner: winner,
turns: Math.min(turn + 1, sim.maxTurns),
playerA: { name: nameA, life: sim.players[0].life, eliminated: sim.players[0].eliminated, stats: sim.players[0].stats },
playerB: { name: nameB, life: sim.players[1].life, eliminated: sim.players[1].eliminated, stats: sim.players[1].stats }
};
}
// --- Run full deck test suite ---
function runDeckTester() {
var st = deckTesterState;
if (st.running) return;
// Build custom deck
var customDeck = buildSimDeckFromDeckBuilder();
if (!customDeck || customDeck.length < 20) {
var container = document.getElementById('dt-results');
if (container) container.innerHTML = '
Deck needs at least 20 cards to test. Add more cards in the Deck Builder.
';
return;
}
st.customDeck = customDeck;
// Get selected precons
var preconCheckboxes = document.querySelectorAll('.dt-precon-cb:checked');
st.selectedPrecons = [];
preconCheckboxes.forEach(function(cb) {
st.selectedPrecons.push(parseInt(cb.value));
});
if (st.selectedPrecons.length === 0) {
var container = document.getElementById('dt-results');
if (container) container.innerHTML = '
Select at least one precon deck to test against.
';
return;
}
// Get games per matchup
var gpmInput = document.getElementById('dt-games-per');
st.gamesPerMatchup = gpmInput ? parseInt(gpmInput.value) || 50 : 50;
st.running = true;
st.cancelled = false;
st.progress = 0;
st.total = st.selectedPrecons.length * st.gamesPerMatchup;
st.results = {
deckName: dbCurrentDeck.name || 'Custom Deck',
deckSize: customDeck.length,
matchups: [],
aggregate: { wins: 0, losses: 0, draws: 0, totalGames: 0 }
};
renderDeckTesterProgress();
// Run matchups async
var matchupIdx = 0;
function runNextMatchup() {
if (st.cancelled || matchupIdx >= st.selectedPrecons.length) {
st.running = false;
renderDeckTesterResults();
return;
}
var preconIdx = st.selectedPrecons[matchupIdx];
var precon = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA[preconIdx] : null;
if (!precon) { matchupIdx++; runNextMatchup(); return; }
var preconDeck = buildSimDeckFromPrecon(preconIdx);
if (!preconDeck || preconDeck.length < 10) { matchupIdx++; runNextMatchup(); return; }
st.currentMatchup = precon.name;
var matchupResult = {
preconName: precon.name,
preconSet: precon.set || '',
preconColors: precon.colors || '',
preconTheme: precon.theme || '',
wins: 0, losses: 0, draws: 0,
avgTurns: 0, avgLifeDiff: 0,
avgDamageDealt: 0, avgDamageReceived: 0,
avgSpellsCast: 0, avgCreaturesPlayed: 0,
avgRemovalUsed: 0, avgRampPlayed: 0,
avgCardsDrawn: 0, avgBoardSize: 0,
avgManaSpent: 0,
winByTurn: {}, // turn# -> count of wins on that turn
games: []
};
var gameIdx = 0;
function runNextGame() {
if (st.cancelled || gameIdx >= st.gamesPerMatchup) {
// Finalize matchup
var g = matchupResult.wins + matchupResult.losses + matchupResult.draws;
if (g > 0) {
matchupResult.avgTurns = Math.round(matchupResult.avgTurns / g * 10) / 10;
matchupResult.avgLifeDiff = Math.round(matchupResult.avgLifeDiff / g * 10) / 10;
matchupResult.avgDamageDealt = Math.round(matchupResult.avgDamageDealt / g * 10) / 10;
matchupResult.avgDamageReceived = Math.round(matchupResult.avgDamageReceived / g * 10) / 10;
matchupResult.avgSpellsCast = Math.round(matchupResult.avgSpellsCast / g * 10) / 10;
matchupResult.avgCreaturesPlayed = Math.round(matchupResult.avgCreaturesPlayed / g * 10) / 10;
matchupResult.avgRemovalUsed = Math.round(matchupResult.avgRemovalUsed / g * 10) / 10;
matchupResult.avgRampPlayed = Math.round(matchupResult.avgRampPlayed / g * 10) / 10;
matchupResult.avgCardsDrawn = Math.round(matchupResult.avgCardsDrawn / g * 10) / 10;
matchupResult.avgBoardSize = Math.round(matchupResult.avgBoardSize / g * 10) / 10;
matchupResult.avgManaSpent = Math.round(matchupResult.avgManaSpent / g * 10) / 10;
}
st.results.matchups.push(matchupResult);
st.results.aggregate.wins += matchupResult.wins;
st.results.aggregate.losses += matchupResult.losses;
st.results.aggregate.draws += matchupResult.draws;
st.results.aggregate.totalGames += g;
matchupIdx++;
renderDeckTesterProgress();
setTimeout(runNextMatchup, 1);
return;
}
var result = dtSimGame(st.customDeck.slice(), preconDeck.slice(), st.results.deckName, precon.name);
if (result.winner === 0) {
matchupResult.wins++;
var wt = result.turns;
matchupResult.winByTurn[wt] = (matchupResult.winByTurn[wt] || 0) + 1;
} else if (result.winner === 1) {
matchupResult.losses++;
} else {
matchupResult.draws++;
}
var myStats = result.playerA.stats;
matchupResult.avgTurns += result.turns;
matchupResult.avgLifeDiff += (result.playerA.life - result.playerB.life);
matchupResult.avgDamageDealt += myStats.damageDealt;
matchupResult.avgDamageReceived += myStats.damageReceived;
matchupResult.avgSpellsCast += myStats.spellsCast;
matchupResult.avgCreaturesPlayed += myStats.creaturesPlayed;
matchupResult.avgRemovalUsed += myStats.removalUsed;
matchupResult.avgRampPlayed += myStats.rampPlayed;
matchupResult.avgCardsDrawn += myStats.cardsDrawn;
matchupResult.avgBoardSize += myStats.maxBoardSize;
matchupResult.avgManaSpent += myStats.manaSpent;
st.progress++;
gameIdx++;
// Update progress every 10 games
if (gameIdx % 10 === 0) {
renderDeckTesterProgress();
setTimeout(runNextGame, 1);
} else {
runNextGame();
}
}
runNextGame();
}
runNextMatchup();
}
function cancelDeckTester() {
deckTesterState.cancelled = true;
}
// --- Render progress bar ---
function renderDeckTesterProgress() {
var st = deckTesterState;
var container = document.getElementById('dt-results');
if (!container) return;
var pct = st.total > 0 ? Math.round(st.progress / st.total * 100) : 0;
container.innerHTML =
'
';
}
// --- 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 += '