1151 lines
39 KiB
JavaScript
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 };
|