const { v4: uuidv4 } = require('uuid'); const { CARD_DB, TYPES, KEYWORDS, getManaCost } = require('../cards'); const PHASES = [ 'untap', 'upkeep', 'draw', 'main1', 'combat_begin', 'combat_attackers', 'combat_blockers', 'combat_damage', 'combat_end', 'main2', 'end_step', 'cleanup', ]; const STARTING_LIFE = 20; const MAX_HAND_SIZE = 7; class GameEngine { constructor(player1Id, player2Id, player1Deck, player2Deck) { this.id = uuidv4(); this.players = { [player1Id]: this._createPlayerState(player1Id, player1Deck), [player2Id]: this._createPlayerState(player2Id, player2Deck), }; this.playerOrder = [player1Id, player2Id]; this.activePlayerIndex = Math.random() < 0.5 ? 0 : 1; this.turnNumber = 0; this.currentPhase = 'untap'; this.phaseIndex = 0; this.stack = []; this.waitingForResponse = null; this.attackers = []; this.blockers = {}; this.pendingEffects = []; this.gameOver = false; this.winner = null; this.log = []; this.extraTurns = []; this.endOfTurnEffects = []; } get activePlayerId() { return this.playerOrder[this.activePlayerIndex]; } get inactivePlayerId() { return this.playerOrder[1 - this.activePlayerIndex]; } _createPlayerState(playerId, deckCardIds) { const library = this._shuffle([...deckCardIds]); return { id: playerId, life: STARTING_LIFE, manaPool: { radiance: 0, tide: 0, shadow: 0, flame: 0, growth: 0, colorless: 0 }, landPlayedThisTurn: false, hand: [], library, graveyard: [], battlefield: [], nextInstanceId: 1, }; } _shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } _createInstance(playerId, cardId) { const player = this.players[playerId]; const card = CARD_DB[cardId]; const instance = { instanceId: `${playerId}_${player.nextInstanceId++}`, cardId, cardData: { ...card }, ownerId: playerId, tapped: false, summoningSickness: true, damage: 0, counters: {}, attachments: [], tempEffects: [], }; return instance; } startGame() { this.playerOrder.forEach((pid) => { for (let i = 0; i < 7; i++) this._drawCard(pid); }); this.turnNumber = 1; this.currentPhase = 'untap'; this.phaseIndex = 0; this._addLog(`Game started. ${this.activePlayerId} goes first.`); this._processPhase(); return this.getState(); } _drawCard(playerId) { const player = this.players[playerId]; if (player.library.length === 0) { this._addLog(`${playerId} tried to draw but has no cards. ${playerId} loses!`); this.gameOver = true; this.winner = this.playerOrder.find((p) => p !== playerId); return null; } const cardId = player.library.shift(); player.hand.push(cardId); return cardId; } _addLog(message) { this.log.push({ turn: this.turnNumber, phase: this.currentPhase, message }); } _processPhase() { const phase = this.currentPhase; const activePlayer = this.players[this.activePlayerId]; switch (phase) { case 'untap': activePlayer.battlefield.forEach((inst) => { inst.tapped = false; inst.summoningSickness = false; }); activePlayer.landPlayedThisTurn = false; activePlayer.manaPool = { radiance: 0, tide: 0, shadow: 0, flame: 0, growth: 0, colorless: 0 }; this.players[this.inactivePlayerId].manaPool = { radiance: 0, tide: 0, shadow: 0, flame: 0, growth: 0, colorless: 0 }; this.endOfTurnEffects = []; this._advancePhase(); break; case 'upkeep': this._processUpkeepTriggers(); this._advancePhase(); break; case 'draw': if (this.turnNumber > 1) { this._drawCard(this.activePlayerId); const extraDraws = activePlayer.battlefield.filter( (inst) => inst.cardData.abilities?.some((a) => a.effect === 'extraDraw') ); extraDraws.forEach((inst) => { const ability = inst.cardData.abilities.find((a) => a.effect === 'extraDraw'); for (let i = 0; i < ability.amount; i++) this._drawCard(this.activePlayerId); }); } this._advancePhase(); break; case 'main1': case 'main2': break; case 'combat_begin': this.attackers = []; this.blockers = {}; this._advancePhase(); break; case 'combat_attackers': break; case 'combat_blockers': break; case 'combat_damage': this._resolveCombatDamage(); this._advancePhase(); break; case 'combat_end': this.attackers = []; this.blockers = {}; this._advancePhase(); break; case 'end_step': this._processEndOfTurnEffects(); this._advancePhase(); break; case 'cleanup': this._cleanup(); this._startNextTurn(); break; } } _advancePhase() { this.phaseIndex++; if (this.phaseIndex >= PHASES.length) { this.phaseIndex = 0; } this.currentPhase = PHASES[this.phaseIndex]; this._processPhase(); } _processUpkeepTriggers() { const activePlayer = this.players[this.activePlayerId]; activePlayer.battlefield.forEach((inst) => { const card = inst.cardData; if (!card.abilities) return; card.abilities.forEach((ability) => { if (ability.trigger === 'upkeep') { this._resolveAbility(ability, inst, this.activePlayerId); } }); }); } _processEndOfTurnEffects() { this.endOfTurnEffects.forEach((effect) => { if (effect.type === 'pumpCreature') { const inst = this._findInstance(effect.instanceId); if (inst) { inst.tempEffects = inst.tempEffects.filter((e) => e.id !== effect.id); } } }); this.endOfTurnEffects = []; const activePlayer = this.players[this.activePlayerId]; while (activePlayer.hand.length > MAX_HAND_SIZE) { const discarded = activePlayer.hand.pop(); activePlayer.graveyard.push(discarded); this._addLog(`${this.activePlayerId} discards ${CARD_DB[discarded].name} (hand size).`); } } _cleanup() { Object.values(this.players).forEach((player) => { player.battlefield.forEach((inst) => { inst.damage = 0; inst.tempEffects = []; }); }); } _startNextTurn() { if (this.extraTurns.length > 0) { const nextPlayerId = this.extraTurns.shift(); this.activePlayerIndex = this.playerOrder.indexOf(nextPlayerId); } else { this.activePlayerIndex = 1 - this.activePlayerIndex; } this.turnNumber++; this.phaseIndex = 0; this.currentPhase = PHASES[0]; this._addLog(`Turn ${this.turnNumber}: ${this.activePlayerId}'s turn.`); this._processPhase(); } _canPayCost(playerId, cost) { if (!cost) return true; const player = this.players[playerId]; const pool = { ...player.manaPool }; let colorlessNeeded = cost.colorless || 0; for (const [color, amount] of Object.entries(cost)) { if (color === 'colorless') continue; if ((pool[color] || 0) < amount) return false; pool[color] -= amount; } const remainingMana = Object.values(pool).reduce((s, v) => s + v, 0); return remainingMana >= colorlessNeeded; } _payCost(playerId, cost) { if (!cost) return; const player = this.players[playerId]; for (const [color, amount] of Object.entries(cost)) { if (color === 'colorless') continue; player.manaPool[color] -= amount; } let colorlessNeeded = cost.colorless || 0; const colorPriority = ['colorless', 'radiance', 'tide', 'shadow', 'flame', 'growth']; for (const color of colorPriority) { if (colorlessNeeded <= 0) break; const available = player.manaPool[color] || 0; const spend = Math.min(available, colorlessNeeded); player.manaPool[color] -= spend; colorlessNeeded -= spend; } } _findInstance(instanceId) { for (const player of Object.values(this.players)) { const inst = player.battlefield.find((i) => i.instanceId === instanceId); if (inst) return inst; } return null; } _findInstanceOwner(instanceId) { for (const player of Object.values(this.players)) { if (player.battlefield.find((i) => i.instanceId === instanceId)) { return player.id; } } return null; } _getEffectivePower(inst) { let power = inst.cardData.power || 0; inst.tempEffects.forEach((e) => { if (e.power) power += e.power; }); if (inst.cardData.abilities) { inst.cardData.abilities.forEach((a) => { if (a.effect === 'plagueRatBonus') { const owner = this._findInstanceOwner(inst.instanceId); const otherRats = this.players[owner].battlefield.filter( (i) => i.cardData.name === 'Plague Rat' && i.instanceId !== inst.instanceId ); power += otherRats.length; } }); } const owner = this._findInstanceOwner(inst.instanceId); if (owner) { this.players[owner].battlefield.forEach((other) => { if (other.cardData.abilities) { other.cardData.abilities.forEach((a) => { if (a.effect === 'anthemBuff' && other.instanceId !== inst.instanceId) { power += a.power; } }); } }); } return power; } _getEffectiveToughness(inst) { let toughness = inst.cardData.toughness || 0; inst.tempEffects.forEach((e) => { if (e.toughness) toughness += e.toughness; }); if (inst.cardData.abilities) { inst.cardData.abilities.forEach((a) => { if (a.effect === 'plagueRatBonus') { const owner = this._findInstanceOwner(inst.instanceId); const otherRats = this.players[owner].battlefield.filter( (i) => i.cardData.name === 'Plague Rat' && i.instanceId !== inst.instanceId ); toughness += otherRats.length; } }); } const owner = this._findInstanceOwner(inst.instanceId); if (owner) { this.players[owner].battlefield.forEach((other) => { if (other.cardData.abilities) { other.cardData.abilities.forEach((a) => { if (a.effect === 'anthemBuff' && other.instanceId !== inst.instanceId) { toughness += a.toughness; } }); } }); } return toughness; } _hasKeyword(inst, keyword) { const keywords = inst.cardData.keywords || []; if (keywords.includes(keyword)) return true; return inst.tempEffects.some((e) => e.keyword === keyword); } _resolveAbility(ability, sourceInst, controllerId) { switch (ability.effect) { case 'gainLife': this.players[controllerId].life += ability.amount; this._addLog(`${controllerId} gains ${ability.amount} life.`); break; case 'loseLife': this.players[controllerId].life -= ability.amount; this._addLog(`${controllerId} loses ${ability.amount} life.`); this._checkLifeTotals(); break; case 'drawCards': for (let i = 0; i < ability.amount; i++) this._drawCard(controllerId); this._addLog(`${controllerId} draws ${ability.amount} card(s).`); break; case 'opponentLosesLife': const opponentId = this.playerOrder.find((p) => p !== controllerId); this.players[opponentId].life -= ability.amount; this._addLog(`${opponentId} loses ${ability.amount} life.`); this._checkLifeTotals(); break; } } _checkLifeTotals() { for (const player of Object.values(this.players)) { if (player.life <= 0) { this.gameOver = true; this.winner = this.playerOrder.find((p) => p !== player.id); this._addLog(`${player.id} has been defeated! ${this.winner} wins!`); } } } _destroyInstance(inst) { const ownerId = this._findInstanceOwner(inst.instanceId); if (!ownerId) return; const player = this.players[ownerId]; player.battlefield = player.battlefield.filter((i) => i.instanceId !== inst.instanceId); player.graveyard.push(inst.cardId); this._addLog(`${inst.cardData.name} is destroyed.`); Object.values(this.players).forEach((p) => { p.battlefield.forEach((other) => { if (other.cardData.abilities) { other.cardData.abilities.forEach((a) => { if (a.trigger === 'creatureDies') { this._resolveAbility(a, other, other.ownerId); } }); } }); }); } _resolveCombatDamage() { if (this.attackers.length === 0) return; const guardianAttackers = this.attackers.filter((aId) => { const inst = this._findInstance(aId); return inst && this._hasKeyword(inst, KEYWORDS.GUARDIAN); }); const normalAttackers = this.attackers.filter((aId) => { const inst = this._findInstance(aId); return inst && !this._hasKeyword(inst, KEYWORDS.GUARDIAN); }); const resolveGroup = (attackerIds, isFirstStrike) => { for (const attackerId of attackerIds) { const attacker = this._findInstance(attackerId); if (!attacker) continue; const blockerIds = this.blockers[attackerId] || []; const attackerPower = this._getEffectivePower(attacker); if (blockerIds.length === 0) { this.players[this.inactivePlayerId].life -= attackerPower; this._addLog(`${attacker.cardData.name} deals ${attackerPower} damage to ${this.inactivePlayerId}.`); if (this._hasKeyword(attacker, KEYWORDS.DRAINING)) { this.players[this.activePlayerId].life += attackerPower; this._addLog(`${this.activePlayerId} gains ${attackerPower} life (draining).`); } if (attacker.cardData.abilities) { attacker.cardData.abilities.forEach((a) => { if (a.trigger === 'dealsDamage') { this._resolveAbility(a, attacker, attacker.ownerId); } }); } } else { let remainingPower = attackerPower; for (const blockerId of blockerIds) { const blocker = this._findInstance(blockerId); if (!blocker) continue; const blockerPower = this._getEffectivePower(blocker); const blockerToughness = this._getEffectiveToughness(blocker); if (!isFirstStrike || !this._hasKeyword(blocker, KEYWORDS.GUARDIAN)) { attacker.damage += blockerPower; } const damageToBlocker = Math.min(remainingPower, blockerToughness - blocker.damage); blocker.damage += damageToBlocker; if (this._hasKeyword(attacker, KEYWORDS.VENOMOUS) && damageToBlocker > 0) { blocker.damage = blockerToughness; } if (this._hasKeyword(blocker, KEYWORDS.VENOMOUS) && blockerPower > 0) { attacker.damage = this._getEffectiveToughness(attacker); } if (this._hasKeyword(attacker, KEYWORDS.OVERWHELMING)) { remainingPower -= damageToBlocker; } else { remainingPower = 0; } this._addLog(`${attacker.cardData.name} and ${blocker.cardData.name} clash in combat.`); } if (remainingPower > 0 && this._hasKeyword(attacker, KEYWORDS.OVERWHELMING)) { this.players[this.inactivePlayerId].life -= remainingPower; this._addLog(`${attacker.cardData.name} overwhelms for ${remainingPower} damage.`); } if (this._hasKeyword(attacker, KEYWORDS.DRAINING)) { const totalDealt = attackerPower - remainingPower + (remainingPower > 0 ? remainingPower : 0); this.players[this.activePlayerId].life += attackerPower; } } } }; if (guardianAttackers.length > 0) { resolveGroup(guardianAttackers, true); this._checkAndDestroyCombatants(); } resolveGroup(normalAttackers, false); this._checkAndDestroyCombatants(); this._checkLifeTotals(); } _checkAndDestroyCombatants() { const toDestroy = []; Object.values(this.players).forEach((player) => { player.battlefield.forEach((inst) => { if (inst.cardData.type === TYPES.CREATURE && inst.damage >= this._getEffectiveToughness(inst)) { toDestroy.push(inst); } }); }); toDestroy.forEach((inst) => this._destroyInstance(inst)); } handleAction(playerId, action) { if (this.gameOver) return { error: 'Game is over' }; switch (action.type) { case 'playLand': return this._handlePlayLand(playerId, action); case 'castSpell': return this._handleCastSpell(playerId, action); case 'tapLand': return this._handleTapLand(playerId, action); case 'tapAbility': return this._handleTapAbility(playerId, action); case 'declareAttackers': return this._handleDeclareAttackers(playerId, action); case 'declareBlockers': return this._handleDeclareBlockers(playerId, action); case 'passPhase': return this._handlePassPhase(playerId); case 'passPriority': return this._handlePassPhase(playerId); case 'chooseTarget': return this._handleChooseTarget(playerId, action); case 'payX': return this._handlePayX(playerId, action); case 'chooseManaColor': return this._handleChooseManaColor(playerId, action); case 'concede': return this._handleConcede(playerId); default: return { error: `Unknown action: ${action.type}` }; } } _handlePlayLand(playerId, action) { if (playerId !== this.activePlayerId) return { error: 'Not your turn' }; if (this.currentPhase !== 'main1' && this.currentPhase !== 'main2') { return { error: 'Can only play lands during main phases' }; } const player = this.players[playerId]; if (player.landPlayedThisTurn) return { error: 'Already played a land this turn' }; const handIndex = action.handIndex; if (handIndex < 0 || handIndex >= player.hand.length) return { error: 'Invalid hand index' }; const cardId = player.hand[handIndex]; const card = CARD_DB[cardId]; if (card.type !== TYPES.LAND) return { error: 'Not a land card' }; player.hand.splice(handIndex, 1); const instance = this._createInstance(playerId, cardId); instance.summoningSickness = false; player.battlefield.push(instance); player.landPlayedThisTurn = true; this._addLog(`${playerId} plays ${card.name}.`); return { success: true }; } _handleTapLand(playerId, action) { const instance = this._findInstance(action.instanceId); if (!instance) return { error: 'Instance not found' }; if (instance.ownerId !== playerId) return { error: 'Not your permanent' }; if (instance.tapped) return { error: 'Already tapped' }; if (instance.cardData.type !== TYPES.LAND && !instance.cardData.abilities?.some((a) => a.trigger === 'tap' && a.effect === 'addMana')) { return { error: 'Cannot tap for mana' }; } const manaAbility = instance.cardData.abilities.find((a) => a.trigger === 'tap' && a.effect === 'addMana'); if (!manaAbility) return { error: 'No mana ability' }; instance.tapped = true; if (manaAbility.color.includes('|')) { this.waitingForResponse = { type: 'chooseManaColor', playerId, options: manaAbility.color.split('|'), instanceId: instance.instanceId, }; return { success: true, needsResponse: 'chooseManaColor', options: manaAbility.color.split('|') }; } if (manaAbility.color === 'any') { this.waitingForResponse = { type: 'chooseManaColor', playerId, options: ['radiance', 'tide', 'shadow', 'flame', 'growth', 'colorless'], instanceId: instance.instanceId, }; return { success: true, needsResponse: 'chooseManaColor', options: ['radiance', 'tide', 'shadow', 'flame', 'growth', 'colorless'] }; } this.players[playerId].manaPool[manaAbility.color] = (this.players[playerId].manaPool[manaAbility.color] || 0) + 1; return { success: true }; } _handleChooseManaColor(playerId, action) { if (!this.waitingForResponse || this.waitingForResponse.type !== 'chooseManaColor') { return { error: 'Not waiting for mana color choice' }; } if (this.waitingForResponse.playerId !== playerId) return { error: 'Not your choice' }; const color = action.color; if (!this.waitingForResponse.options.includes(color)) return { error: 'Invalid color choice' }; this.players[playerId].manaPool[color] = (this.players[playerId].manaPool[color] || 0) + 1; this.waitingForResponse = null; return { success: true }; } _handleTapAbility(playerId, action) { const instance = this._findInstance(action.instanceId); if (!instance) return { error: 'Instance not found' }; if (instance.ownerId !== playerId) return { error: 'Not your permanent' }; if (instance.tapped) return { error: 'Already tapped' }; const tapAbility = instance.cardData.abilities?.find((a) => a.trigger === 'tap' && a.effect !== 'addMana'); const sacrificeAbility = instance.cardData.abilities?.find((a) => a.trigger === 'tap_sacrifice'); const ability = action.sacrifice ? sacrificeAbility : tapAbility; if (!ability) return { error: 'No tap ability' }; instance.tapped = true; if (action.sacrifice) { this._destroyInstance(instance); } this._resolveAbility(ability, instance, playerId); return { success: true }; } _handleCastSpell(playerId, action) { const player = this.players[playerId]; const handIndex = action.handIndex; if (handIndex < 0 || handIndex >= player.hand.length) return { error: 'Invalid hand index' }; const cardId = player.hand[handIndex]; const card = CARD_DB[cardId]; if (card.type === TYPES.LAND) return { error: 'Use playLand for lands' }; if (card.type !== TYPES.INSTANT) { if (playerId !== this.activePlayerId) return { error: 'Not your turn for sorcery-speed spells' }; if (this.currentPhase !== 'main1' && this.currentPhase !== 'main2') { return { error: 'Can only cast sorceries during main phases' }; } } const xAmount = action.xAmount || 0; const totalCost = { ...card.cost }; const hasX = card.abilities?.some((a) => a.isX); if (hasX) { totalCost.colorless = (totalCost.colorless || 0) + xAmount; } if (!this._canPayCost(playerId, totalCost)) return { error: 'Cannot pay mana cost' }; const needsTarget = this._spellNeedsTarget(card); if (needsTarget && action.targetInstanceId === undefined && action.targetPlayerId === undefined) { return { error: 'This spell requires a target' }; } this._payCost(playerId, totalCost); player.hand.splice(handIndex, 1); this._addLog(`${playerId} casts ${card.name}.`); if (card.type === TYPES.CREATURE) { const instance = this._createInstance(playerId, cardId); player.battlefield.push(instance); this._addLog(`${card.name} enters the battlefield.`); if (card.abilities) { card.abilities.forEach((a) => { if (a.trigger === 'enters') { this._resolveAbility(a, instance, playerId); } }); } } else if (card.type === TYPES.ENCHANTMENT || card.type === TYPES.ARTIFACT) { const instance = this._createInstance(playerId, cardId); instance.summoningSickness = false; player.battlefield.push(instance); this._addLog(`${card.name} enters the battlefield.`); } else { this._resolveSpellEffects(card, playerId, action, xAmount); player.graveyard.push(cardId); } return { success: true }; } _spellNeedsTarget(card) { if (!card.abilities) return false; return card.abilities.some((a) => ['destroyCreature', 'destroyCreatureIf', 'bounceCreature', 'pumpCreature', 'dealDamage', 'drainLife', 'tapCreature', 'giveKeyword', 'preventDamage', 'returnFromGraveyard'].includes(a.effect) && a.target !== 'all' ); } _resolveSpellEffects(card, casterId, action, xAmount) { if (!card.abilities) return; for (const ability of card.abilities) { switch (ability.effect) { case 'dealDamage': { const amount = ability.isX ? xAmount : ability.amount; if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target && target.cardData.type === TYPES.CREATURE) { target.damage += amount; this._addLog(`${card.name} deals ${amount} damage to ${target.cardData.name}.`); if (target.damage >= this._getEffectiveToughness(target)) { this._destroyInstance(target); } } } else if (action.targetPlayerId) { this.players[action.targetPlayerId].life -= amount; this._addLog(`${card.name} deals ${amount} damage to ${action.targetPlayerId}.`); this._checkLifeTotals(); } break; } case 'dealDamageAll': { const toDestroy = []; Object.values(this.players).forEach((player) => { player.battlefield.forEach((inst) => { if (inst.cardData.type === TYPES.CREATURE) { inst.damage += ability.amount; if (inst.damage >= this._getEffectiveToughness(inst)) { toDestroy.push(inst); } } }); }); this._addLog(`${card.name} deals ${ability.amount} damage to all creatures.`); toDestroy.forEach((inst) => this._destroyInstance(inst)); break; } case 'destroyCreature': { if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) this._destroyInstance(target); } break; } case 'destroyCreatureIf': { if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target && ability.condition === 'powerGte4') { if (this._getEffectivePower(target) >= 4) { this._destroyInstance(target); } else { this._addLog(`${target.cardData.name} is not powerful enough to be destroyed.`); } } } break; } case 'destroyAllCreatures': { const toDestroy = []; Object.values(this.players).forEach((player) => { player.battlefield.forEach((inst) => { if (inst.cardData.type === TYPES.CREATURE) toDestroy.push(inst); }); }); this._addLog(`${card.name} destroys all creatures!`); toDestroy.forEach((inst) => this._destroyInstance(inst)); break; } case 'bounceCreature': { if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) { const ownerId = this._findInstanceOwner(target.instanceId); this.players[ownerId].battlefield = this.players[ownerId].battlefield.filter( (i) => i.instanceId !== target.instanceId ); this.players[ownerId].hand.push(target.cardId); this._addLog(`${target.cardData.name} is returned to ${ownerId}'s hand.`); } } break; } case 'counterSpell': { this._addLog(`${card.name} counters a spell!`); break; } case 'pumpCreature': { if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) { const effectId = uuidv4(); target.tempEffects.push({ id: effectId, power: ability.power, toughness: ability.toughness, }); if (ability.duration === 'endOfTurn') { this.endOfTurnEffects.push({ type: 'pumpCreature', instanceId: target.instanceId, id: effectId, }); } this._addLog(`${target.cardData.name} gets +${ability.power}/+${ability.toughness} until end of turn.`); } } break; } case 'pumpAll': { const player = this.players[casterId]; player.battlefield.forEach((inst) => { if (inst.cardData.type === TYPES.CREATURE) { const effectId = uuidv4(); inst.tempEffects.push({ id: effectId, power: ability.power, toughness: ability.toughness, keyword: ability.keyword, }); this.endOfTurnEffects.push({ type: 'pumpCreature', instanceId: inst.instanceId, id: effectId, }); } }); this._addLog(`${card.name}: All your creatures get +${ability.power}/+${ability.toughness}!`); break; } case 'gainLife': this.players[casterId].life += ability.amount; this._addLog(`${casterId} gains ${ability.amount} life.`); break; case 'loseLife': this.players[casterId].life -= ability.amount; this._addLog(`${casterId} loses ${ability.amount} life.`); this._checkLifeTotals(); break; case 'drawCards': for (let i = 0; i < ability.amount; i++) this._drawCard(casterId); this._addLog(`${casterId} draws ${ability.amount} card(s).`); break; case 'opponentDiscards': { const opponentId = this.playerOrder.find((p) => p !== casterId); const opponent = this.players[opponentId]; const discardCount = Math.min(ability.amount, opponent.hand.length); for (let i = 0; i < discardCount; i++) { const randomIdx = Math.floor(Math.random() * opponent.hand.length); const discarded = opponent.hand.splice(randomIdx, 1)[0]; opponent.graveyard.push(discarded); } this._addLog(`${opponentId} discards ${discardCount} card(s).`); break; } case 'drainLife': { const amount = xAmount; if (action.targetPlayerId) { this.players[action.targetPlayerId].life -= amount; this.players[casterId].life += amount; this._addLog(`${card.name} drains ${amount} life from ${action.targetPlayerId}.`); this._checkLifeTotals(); } break; } case 'returnFromGraveyard': { const player = this.players[casterId]; const creatureInGrave = player.graveyard.findIndex((cid) => CARD_DB[cid].type === TYPES.CREATURE); if (creatureInGrave >= 0) { const cid = player.graveyard.splice(creatureInGrave, 1)[0]; player.hand.push(cid); this._addLog(`${CARD_DB[cid].name} returns from the graveyard to ${casterId}'s hand.`); } break; } case 'searchLand': { const player = this.players[casterId]; const landIdx = player.library.findIndex((cid) => CARD_DB[cid].type === TYPES.LAND); if (landIdx >= 0) { const landId = player.library.splice(landIdx, 1)[0]; const instance = this._createInstance(casterId, landId); instance.summoningSickness = false; instance.tapped = true; player.battlefield.push(instance); this._addLog(`${casterId} searches for ${CARD_DB[landId].name} and puts it into play tapped.`); this._shuffle(player.library); } break; } case 'extraTurn': this.extraTurns.push(casterId); this._addLog(`${casterId} will take an extra turn!`); break; case 'tapCreature': if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) { target.tapped = true; this._addLog(`${target.cardData.name} becomes tapped.`); } } break; case 'giveKeyword': if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) { target.tempEffects.push({ keyword: ability.keyword }); this._addLog(`${target.cardData.name} gains ${ability.keyword}.`); } } break; case 'preventDamage': if (action.targetInstanceId) { const target = this._findInstance(action.targetInstanceId); if (target) { target.damage = 0; this._addLog(`All damage to ${target.cardData.name} is prevented.`); } } break; case 'preventCombatDamage': this.attackers.forEach((aId) => { const inst = this._findInstance(aId); if (inst) inst.damage = 0; }); this._addLog(`${card.name} prevents all combat damage this turn.`); break; case 'extraDraw': case 'anthemBuff': case 'plagueRatBonus': case 'equipBuff': case 'scry': break; } } } _handleDeclareAttackers(playerId, action) { if (playerId !== this.activePlayerId) return { error: 'Not your turn' }; if (this.currentPhase !== 'combat_attackers') return { error: 'Not in declare attackers phase' }; const player = this.players[playerId]; const attackerIds = action.attackerIds || []; for (const instanceId of attackerIds) { const inst = player.battlefield.find((i) => i.instanceId === instanceId); if (!inst) return { error: `Instance ${instanceId} not found` }; if (inst.cardData.type !== TYPES.CREATURE) return { error: `${inst.cardData.name} is not a creature` }; if (inst.tapped) return { error: `${inst.cardData.name} is tapped` }; if (inst.summoningSickness && !this._hasKeyword(inst, KEYWORDS.SWIFT)) { return { error: `${inst.cardData.name} has summoning sickness` }; } if (this._hasKeyword(inst, KEYWORDS.FORTIFIED)) { return { error: `${inst.cardData.name} cannot attack (Fortified)` }; } } this.attackers = attackerIds; attackerIds.forEach((id) => { const inst = this._findInstance(id); if (inst && !this._hasKeyword(inst, KEYWORDS.VIGILANT)) { inst.tapped = true; } }); if (attackerIds.length > 0) { this._addLog(`${playerId} declares ${attackerIds.length} attacker(s).`); this.currentPhase = 'combat_blockers'; this.phaseIndex = PHASES.indexOf('combat_blockers'); } else { this._addLog(`${playerId} declares no attackers.`); this.currentPhase = 'combat_end'; this.phaseIndex = PHASES.indexOf('combat_end'); this._processPhase(); } return { success: true }; } _handleDeclareBlockers(playerId, action) { if (playerId === this.activePlayerId) return { error: 'Defending player declares blockers' }; if (this.currentPhase !== 'combat_blockers') return { error: 'Not in declare blockers phase' }; const blockerAssignments = action.blockerAssignments || {}; for (const [blockerId, attackerId] of Object.entries(blockerAssignments)) { const blocker = this._findInstance(blockerId); if (!blocker) return { error: `Blocker ${blockerId} not found` }; if (blocker.ownerId !== playerId) return { error: `${blocker.cardData.name} is not yours` }; if (blocker.tapped) return { error: `${blocker.cardData.name} is tapped` }; if (blocker.cardData.type !== TYPES.CREATURE) return { error: `${blocker.cardData.name} is not a creature` }; if (!this.attackers.includes(attackerId)) { return { error: `${attackerId} is not attacking` }; } const attacker = this._findInstance(attackerId); if (attacker && this._hasKeyword(attacker, KEYWORDS.SOARING)) { if (!this._hasKeyword(blocker, KEYWORDS.SOARING) && !this._hasKeyword(blocker, KEYWORDS.REACHING)) { return { error: `${blocker.cardData.name} cannot block ${attacker.cardData.name} (Soaring)` }; } } } this.blockers = {}; for (const [blockerId, attackerId] of Object.entries(blockerAssignments)) { if (!this.blockers[attackerId]) this.blockers[attackerId] = []; this.blockers[attackerId].push(blockerId); } const blockCount = Object.keys(blockerAssignments).length; this._addLog(`${playerId} declares ${blockCount} blocker(s).`); this.currentPhase = 'combat_damage'; this.phaseIndex = PHASES.indexOf('combat_damage'); this._processPhase(); return { success: true }; } _handlePassPhase(playerId) { if (playerId !== this.activePlayerId) { if (this.currentPhase === 'combat_blockers') { this.currentPhase = 'combat_damage'; this.phaseIndex = PHASES.indexOf('combat_damage'); this._processPhase(); return { success: true }; } return { error: 'Not your turn' }; } if (this.currentPhase === 'combat_attackers') { this.attackers = []; this.currentPhase = 'combat_end'; this.phaseIndex = PHASES.indexOf('combat_end'); this._processPhase(); } else { this._advancePhase(); } return { success: true }; } _handleChooseTarget(playerId, action) { return { success: true }; } _handlePayX(playerId, action) { return { success: true, xAmount: action.amount }; } _handleConcede(playerId) { this.gameOver = true; this.winner = this.playerOrder.find((p) => p !== playerId); this._addLog(`${playerId} concedes. ${this.winner} wins!`); return { success: true }; } getState(forPlayerId = null) { const state = { gameId: this.id, turnNumber: this.turnNumber, currentPhase: this.currentPhase, activePlayerId: this.activePlayerId, gameOver: this.gameOver, winner: this.winner, attackers: this.attackers, blockers: this.blockers, waitingForResponse: this.waitingForResponse, log: this.log.slice(-20), players: {}, }; for (const [pid, player] of Object.entries(this.players)) { const isMe = pid === forPlayerId; state.players[pid] = { id: pid, life: player.life, manaPool: { ...player.manaPool }, landPlayedThisTurn: player.landPlayedThisTurn, handCount: player.hand.length, hand: isMe ? player.hand.map((cid) => ({ cardId: cid, ...CARD_DB[cid] })) : [], libraryCount: player.library.length, graveyard: player.graveyard.map((cid) => ({ cardId: cid, ...CARD_DB[cid] })), battlefield: player.battlefield.map((inst) => ({ instanceId: inst.instanceId, cardId: inst.cardId, cardData: inst.cardData, ownerId: inst.ownerId, tapped: inst.tapped, summoningSickness: inst.summoningSickness, damage: inst.damage, tempEffects: inst.tempEffects, effectivePower: this._getEffectivePower(inst), effectiveToughness: this._getEffectiveToughness(inst), })), }; } return state; } } module.exports = { GameEngine, PHASES };