Files
arcane-duels/server/game/GameEngine.js
2026-03-13 16:01:39 -04:00

1151 lines
39 KiB
JavaScript

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 };