Files
Star-wars-card-game/server.js
2026-01-26 23:33:33 +03:00

1768 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Star Wars Hearthstone - Game Server
* PvP over LAN (Radmin VPN). 2-4 players.
*/
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/cards', (req, res) => {
res.json(cardDb);
});
const PORT = process.env.PORT || 3542;
const MAX_PLAYERS = 4;
const MIN_PLAYERS = 2;
const cardDb = require('./cards');
const rooms = new Map(); // code -> { lobby, gameState, gameStarted, faction, turnTimerInterval, turnTimeLeft, turnTimerWarnFired }
const TURN_SECONDS = 90;
const TURN_WARN_AT = 15;
function generateRoomCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 5; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
function cleanupEmptyRooms() {
for (const [code, room] of rooms.entries()) {
if (!room.gameStarted && (!room.lobby || room.lobby.length === 0)) {
clearTurnTimer(room);
rooms.delete(code);
}
}
}
// Периодическая очистка пустых комнат
setInterval(cleanupEmptyRooms, 30000); // каждые 30 секунд
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function createDeck(faction) {
const minionIds = Object.keys(cardDb).filter((id) => {
const c = cardDb[id];
if (c.type !== 'minion') return false;
if (faction && c.faction !== faction && c.faction !== 'neutral' && c.faction !== 'animal') return false;
return true;
});
const spellIds = Object.keys(cardDb).filter((id) => {
const c = cardDb[id];
if (c.type !== 'spell') return false;
if (faction && c.faction !== faction && c.faction !== 'neutral') return false;
return true;
});
const deck = [];
const legendaryUsed = new Set();
for (let i = 0; i < 15; i++) {
const pool = minionIds.filter((id) => {
const c = cardDb[id];
if (c.legendary && legendaryUsed.has(id)) return false;
return true;
});
if (!pool.length) break;
const id = pool[Math.floor(Math.random() * pool.length)];
deck.push(id);
if (cardDb[id].legendary) legendaryUsed.add(id);
}
for (let i = 0; i < 5 && spellIds.length; i++) {
deck.push(spellIds[Math.floor(Math.random() * spellIds.length)]);
}
return shuffle(deck);
}
function drawCards(deck, n) {
const drawn = [];
for (let i = 0; i < n && deck.length; i++) {
drawn.push(deck.pop());
}
return drawn;
}
function initGame(room) {
const lobby = room.lobby;
const playerCount = lobby.length;
const factionChoice = room.faction || null;
const players = lobby.map((p, i) => ({
id: p.id,
name: p.name || `Player ${i + 1}`,
deck: createDeck(factionChoice),
hand: [],
board: [],
mana: 0,
maxMana: 0,
health: 30,
hero: (p.hero || ['luke', 'vader', 'yoda', 'leia', 'rey', 'kylo', 'mace'][i % 7]),
manualDrawUsed: false,
fatigueCounter: 0,
heroAbilityUsed: false,
}));
players.forEach((p, i) => {
p.hand = drawCards(p.deck, i < 2 ? 3 : 4);
});
players[0].maxMana = 1;
players[0].mana = 1;
room.gameState = {
phase: 'playing',
turn: 1,
players,
currentPlayerIndex: 0,
log: [],
};
room.gameStarted = true;
startTurnTimer(room);
broadcastGameState(room);
}
function clearTurnTimer(room) {
if (room.turnTimerInterval) {
clearInterval(room.turnTimerInterval);
room.turnTimerInterval = null;
}
room.turnTimeLeft = TURN_SECONDS;
room.turnTimerWarnFired = false;
}
function startTurnTimer(room) {
clearTurnTimer(room);
if (!room.gameState || room.gameState.phase !== 'playing') return;
room.turnTimeLeft = TURN_SECONDS;
room.turnTimerWarnFired = false;
io.to(room.code).emit('turnTime', {
left: room.turnTimeLeft,
currentPlayerIndex: room.gameState.currentPlayerIndex,
warn: false,
ended: false,
});
room.turnTimerInterval = setInterval(() => {
if (!room.gameState || room.gameState.phase !== 'playing') {
clearTurnTimer(room);
return;
}
room.turnTimeLeft--;
io.to(room.code).emit('turnTime', {
left: room.turnTimeLeft,
currentPlayerIndex: room.gameState.currentPlayerIndex,
warn: room.turnTimeLeft === TURN_WARN_AT,
ended: room.turnTimeLeft <= 0,
});
if (room.turnTimeLeft === TURN_WARN_AT) room.turnTimerWarnFired = true;
if (room.turnTimeLeft <= 0) {
clearTurnTimer(room);
endTurn(room);
}
}, 1000);
}
function broadcastGameState(room) {
if (!room.gameState) return;
room.gameState.players.forEach((p, i) => {
const socket = io.sockets.sockets.get(p.id);
if (socket) {
const mergedCardDb = { ...cardDb, ...(room.gameState.forgedCards || {}) };
const yourView = {
...room.gameState,
yourIndex: i,
yourHand: [...p.hand],
yourDeckCount: p.deck.length,
yourManualDrawUsed: !!p.manualDrawUsed,
yourHeroAbilityUsed: !!p.heroAbilityUsed,
cardDb: mergedCardDb,
};
socket.emit('gameState', yourView);
}
});
}
function findPlayerIndex(room, socketId) {
return room.gameState.players.findIndex((p) => p.id === socketId);
}
function getRoomByCode(code) {
return rooms.get(code?.toUpperCase());
}
function getRoomBySocket(socketId) {
for (const room of rooms.values()) {
if (room.lobby?.some((p) => p.id === socketId)) return room;
if (room.gameState?.players?.some((p) => p.id === socketId)) return room;
}
return null;
}
function endTurn(room) {
const gameState = room.gameState;
const prev = gameState.currentPlayerIndex;
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
const next = gameState.currentPlayerIndex;
const np = gameState.players[next];
np.maxMana = Math.min(10, np.maxMana + 1);
np.mana = np.maxMana;
// Эффекты структур в конце хода предыдущего игрока
const prevPlayer = gameState.players[prev];
if (prevPlayer) {
prevPlayer.board.forEach((m) => {
const card = cardDb[m.cardId];
if (!card) return;
if (card.name === 'Храм джедаев') {
prevPlayer.board.forEach((minion) => {
if (minion.id !== m.id && minion.health < (minion.maxHealth || minion.health)) {
minion.health = Math.min(minion.maxHealth || minion.health, minion.health + 1);
}
});
} else if (card.name === 'Станция Звезды Смерти') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
if (enemies.length) {
const target = enemies[Math.floor(Math.random() * enemies.length)];
target.health = Math.max(0, target.health - 3);
gameState.log.push({ type: 'structure', effect: 'death_star_damage', fromPlayer: prev, toPlayer: gameState.players.indexOf(target), damage: 3 });
}
} else if (card.name === 'Кантина Мос-Эйсли') {
if (prevPlayer.deck.length > 0) {
const drawn = drawCards(prevPlayer.deck, 1);
if (drawn.length) prevPlayer.hand.push(...drawn);
gameState.log.push({ type: 'structure', effect: 'cantina_draw', playerIndex: prev });
}
} else if (card.name === 'Сарлак') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
if (enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
if (enemy.board && enemy.board.length > 0) {
const weak = enemy.board.filter(min => min.attack <= 3);
if (weak.length > 0) {
const target = weak[Math.floor(Math.random() * weak.length)];
const boardIdx = enemy.board.indexOf(target);
const targetCard = cardDb[target.cardId];
enemy.board.splice(boardIdx, 1);
gameState.log.push({ type: 'structure', effect: 'sarlacc_consume', fromPlayer: prev, toPlayer: gameState.players.indexOf(enemy), toIdx: boardIdx });
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, gameState.players.indexOf(enemy));
}
}
}
}
}
// Обработка планет
else if (card.name === 'Korriban') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
if (enemies.length) {
const target = enemies[Math.floor(Math.random() * enemies.length)];
target.health = Math.max(0, target.health - 2);
gameState.log.push({ type: 'planet', effect: 'korriban_damage', fromPlayer: prev, toPlayer: gameState.players.indexOf(target), damage: 2 });
}
} else if (card.name === 'Tatooine') {
if (prevPlayer.deck.length > 0) {
const drawn = drawCards(prevPlayer.deck, 1);
if (drawn.length) prevPlayer.hand.push(...drawn);
gameState.log.push({ type: 'planet', effect: 'tatooine_draw', playerIndex: prev });
}
} else if (card.name === 'Coruscant') {
prevPlayer.board.forEach((minion) => {
if (minion.id !== m.id && minion.health < (minion.maxHealth || minion.health)) {
minion.health = Math.min(minion.maxHealth || minion.health, minion.health + 1);
}
});
gameState.log.push({ type: 'planet', effect: 'coruscant_heal', playerIndex: prev });
} else if (card.name === 'Zakuul') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
enemies.forEach((enemy) => {
enemy.health = Math.max(0, enemy.health - 3);
});
gameState.log.push({ type: 'planet', effect: 'zakuul_damage', fromPlayer: prev });
} else if (card.name === 'Kamino') {
if (prevPlayer.hand.length < 10) {
prevPlayer.hand.push('clone_trooper');
gameState.log.push({ type: 'planet', effect: 'kamino_summon', playerIndex: prev });
}
} else if (card.name === 'Mustafar') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
enemies.forEach((enemy) => {
enemy.health = Math.max(0, enemy.health - 1);
if (enemy.board) {
enemy.board.forEach((min) => {
min.health = Math.max(0, min.health - 1);
});
enemy.board = enemy.board.filter((m) => m.health > 0);
}
});
gameState.log.push({ type: 'planet', effect: 'mustafar_damage', fromPlayer: prev });
} else if (card.name === 'Naboo') {
prevPlayer.health = Math.min(30, prevPlayer.health + 2);
gameState.log.push({ type: 'planet', effect: 'naboo_heal', playerIndex: prev });
} else if (card.name === 'Endor') {
if (prevPlayer.hand.length < 10) {
prevPlayer.hand.push('ewok');
gameState.log.push({ type: 'planet', effect: 'endor_summon', playerIndex: prev });
}
} else if (card.name === 'Hoth') {
const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0);
enemies.forEach((enemy) => {
if (enemy.board) {
enemy.board.forEach((min) => {
min.frozen = true;
});
}
});
gameState.log.push({ type: 'planet', effect: 'hoth_freeze', fromPlayer: prev });
} else if (card.name === 'Dagobah') {
if (prevPlayer.deck.length > 0) {
const drawn = drawCards(prevPlayer.deck, 1);
if (drawn.length) prevPlayer.hand.push(...drawn);
gameState.log.push({ type: 'planet', effect: 'dagobah_draw', playerIndex: prev });
}
}
});
}
np.board.forEach((m) => {
if (m.frozen) { m.frozen = false; return; }
const card = cardDb[m.cardId];
if (card && card.attack > 0) {
m.canAttack = true;
if (card.canAttackTwice) {
m.attacksUsed = 0; // Сбрасываем счётчик атак для двойной атаки
}
}
});
np.manualDrawUsed = false;
np.heroAbilityUsed = false;
if (np.deck.length === 0) {
np.fatigueCounter = (np.fatigueCounter || 0) + 1;
np.health = Math.max(0, np.health - np.fatigueCounter);
gameState.log.push({ type: 'fatigue', playerIndex: next, damage: np.fatigueCounter });
} else {
const drawn = drawCards(np.deck, 1);
if (drawn.length) np.hand.push(...drawn);
}
gameState.turn++;
gameState.log.push({ type: 'turn', from: prev, to: next });
// Применяем синергии в начале каждого хода
applySynergies(room);
checkGameOver(room);
startTurnTimer(room);
broadcastGameState(room);
}
function manualDraw(room, socketId) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.manualDrawUsed || p.hand.length >= 10) return;
if (p.deck.length === 0) {
p.manualDrawUsed = true;
p.fatigueCounter = (p.fatigueCounter || 0) + 1;
const dmg = p.fatigueCounter;
p.health = Math.max(0, p.health - dmg);
gameState.log.push({ type: 'fatigue', playerIndex: pi, damage: dmg });
checkGameOver(room);
broadcastGameState(room);
return;
}
p.manualDrawUsed = true;
const drawn = drawCards(p.deck, 1);
if (drawn.length) p.hand.push(...drawn);
gameState.log.push({ type: 'draw', playerIndex: pi });
broadcastGameState(room);
}
function runBattlecry(room, card, playerIndex) {
const gameState = room.gameState;
const p = gameState.players[playerIndex];
const enemies = gameState.players.filter((pl, i) => i !== playerIndex && pl.health > 0);
const id = card.battlecryId;
if (id === 'deal_1_hero' && enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
const idx = gameState.players.indexOf(t);
t.health = Math.max(0, t.health - 1);
gameState.log.push({ type: 'battlecry', effect: 'deal_1_hero', fromPlayer: playerIndex, toPlayer: idx, damage: 1 });
} else if (id === 'deal_2_hero' && enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
const idx = gameState.players.indexOf(t);
t.health = Math.max(0, t.health - 2);
gameState.log.push({ type: 'battlecry', effect: 'deal_2_hero', fromPlayer: playerIndex, toPlayer: idx, damage: 2 });
} else if (id === 'draw_1') {
const drawn = drawCards(p.deck, 1);
if (drawn.length) p.hand.push(...drawn);
gameState.log.push({ type: 'battlecry', effect: 'draw_1', playerIndex });
} else if (id === 'draw_2') {
const drawn = drawCards(p.deck, 2);
if (drawn.length) p.hand.push(...drawn);
gameState.log.push({ type: 'battlecry', effect: 'draw_2', playerIndex });
} else if (id === 'deal_2_minion' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const target = enemy.board[Math.floor(Math.random() * enemy.board.length)];
target.health = Math.max(0, target.health - 2);
const boardIdx = enemy.board.indexOf(target);
gameState.log.push({ type: 'battlecry', effect: 'deal_2_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx, damage: 2 });
if (target.health <= 0) {
enemy.board.splice(boardIdx, 1);
runDeathrattle(room, cardDb[target.cardId], enemyIdx);
}
}
} else if (id === 'destroy_weak_minion' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const weak = enemy.board.filter(m => m.attack <= 3);
if (weak.length > 0) {
const target = weak[Math.floor(Math.random() * weak.length)];
const boardIdx = enemy.board.indexOf(target);
const targetCard = cardDb[target.cardId];
enemy.board.splice(boardIdx, 1);
gameState.log.push({ type: 'battlecry', effect: 'destroy_weak_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx });
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, enemyIdx);
}
}
}
} else if (id === 'heal_hero_2') {
p.health = Math.min(30, p.health + 2);
gameState.log.push({ type: 'battlecry', effect: 'heal_hero_2', playerIndex });
} else if (id === 'heal_hero_3') {
p.health = Math.min(30, p.health + 3);
gameState.log.push({ type: 'battlecry', effect: 'heal_hero_3', playerIndex });
} else if (id === 'buff_all_friendly_attack') {
p.board.forEach((m) => {
m.attack += 1;
});
gameState.log.push({ type: 'battlecry', effect: 'buff_all_friendly_attack', playerIndex });
} else if (id === 'deal_3_all_enemies') {
enemies.forEach((e) => {
e.health = Math.max(0, e.health - 3);
});
gameState.log.push({ type: 'battlecry', effect: 'deal_3_all_enemies', fromPlayer: playerIndex });
} else if (id === 'deal_3_any') {
if (enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
const idx = gameState.players.indexOf(t);
if (t.board && t.board.length > 0) {
const target = t.board[Math.floor(Math.random() * t.board.length)];
target.health = Math.max(0, target.health - 3);
const boardIdx = t.board.indexOf(target);
gameState.log.push({ type: 'battlecry', effect: 'deal_3_any', fromPlayer: playerIndex, toPlayer: idx, toIdx: boardIdx, damage: 3 });
if (target.health <= 0) {
t.board.splice(boardIdx, 1);
runDeathrattle(room, cardDb[target.cardId], idx);
}
} else {
t.health = Math.max(0, t.health - 3);
gameState.log.push({ type: 'battlecry', effect: 'deal_3_any', fromPlayer: playerIndex, toPlayer: idx, damage: 3 });
}
}
} else if (id === 'draw_1_gain_mana') {
const drawn = drawCards(p.deck, 1);
if (drawn.length) p.hand.push(...drawn);
p.maxMana = Math.min(10, p.maxMana + 1);
p.mana = Math.min(10, p.mana + 1);
gameState.log.push({ type: 'battlecry', effect: 'draw_1_gain_mana', playerIndex });
} else if (id === 'destroy_medium_minion' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const weak = enemy.board.filter(m => m.attack <= 4);
if (weak.length > 0) {
const target = weak[Math.floor(Math.random() * weak.length)];
const boardIdx = enemy.board.indexOf(target);
const targetCard = cardDb[target.cardId];
enemy.board.splice(boardIdx, 1);
gameState.log.push({ type: 'battlecry', effect: 'destroy_medium_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx });
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, enemyIdx);
}
}
}
} else if (id === 'buff_friendly_2_1') {
if (p.board && p.board.length > 0) {
const target = p.board[Math.floor(Math.random() * p.board.length)];
target.attack += 2;
target.health += 1;
target.maxHealth = (target.maxHealth || target.health) + 1;
const boardIdx = p.board.indexOf(target);
gameState.log.push({ type: 'battlecry', effect: 'buff_friendly_2_1', playerIndex, toIdx: boardIdx });
}
} else if (id === 'buff_2_2') {
if (p.board && p.board.length > 0) {
const target = p.board[Math.floor(Math.random() * p.board.length)];
target.attack += 2;
target.health += 2;
target.maxHealth = (target.maxHealth || target.health) + 2;
const boardIdx = p.board.indexOf(target);
gameState.log.push({ type: 'battlecry', effect: 'buff_2_2', playerIndex, toIdx: boardIdx });
}
} else if (id === 'heal_hero_5') {
p.health = Math.min(30, p.health + 5);
gameState.log.push({ type: 'battlecry', effect: 'heal_hero_5', playerIndex });
} else if (id === 'return_hand_enemy') {
// Для Ezra Bridger требуется выбор конкретного игрока
// Отправляем запрос клиенту для выбора цели
const socket = io.sockets.sockets.get(p.id);
if (socket && enemies.length > 0) {
socket.emit('battlecryTargetRequest', {
battlecryId: 'return_hand_enemy',
cardId: card.id || Object.keys(cardDb).find(k => cardDb[k] === card),
availableTargets: enemies.map((e, idx) => ({
playerIndex: gameState.players.indexOf(e),
playerName: e.name || `Игрок ${gameState.players.indexOf(e) + 1}`,
hasMinions: e.board && e.board.length > 0
}))
});
}
return; // Не выполняем сразу, ждём выбора цели
} else if (id === 'destroy_strongest_enemy' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const strongest = enemy.board.reduce((max, m) => (m.attack > (max?.attack || 0) ? m : max), null);
if (strongest) {
const boardIdx = enemy.board.indexOf(strongest);
const targetCard = cardDb[strongest.cardId];
enemy.board.splice(boardIdx, 1);
gameState.log.push({ type: 'battlecry', effect: 'destroy_strongest_enemy', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx });
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, enemyIdx);
}
}
}
} else if (id === 'freeze_all_enemies') {
enemies.forEach((enemy) => {
if (enemy.board) {
enemy.board.forEach((m) => {
m.frozen = true;
});
}
});
gameState.log.push({ type: 'battlecry', effect: 'freeze_all_enemies', fromPlayer: playerIndex });
} else if (id === 'random_effect') {
const effects = [
() => { const drawn = drawCards(p.deck, 1); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'battlecry', effect: 'random_draw', playerIndex }); },
() => { if (enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; t.health = Math.max(0, t.health - 1); gameState.log.push({ type: 'battlecry', effect: 'random_damage', fromPlayer: playerIndex, toPlayer: gameState.players.indexOf(t), damage: 1 }); } },
() => { p.health = Math.min(30, p.health + 1); gameState.log.push({ type: 'battlecry', effect: 'random_heal', playerIndex }); }
];
const randomEffect = effects[Math.floor(Math.random() * effects.length)];
randomEffect();
} else if (id === 'deal_2_or_4_hero' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const target = enemy.board[Math.floor(Math.random() * enemy.board.length)];
target.health = Math.max(0, target.health - 2);
const boardIdx = enemy.board.indexOf(target);
gameState.log.push({ type: 'battlecry', effect: 'deal_2_or_4_hero', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx, damage: 2 });
if (target.health <= 0) {
enemy.board.splice(boardIdx, 1);
runDeathrattle(room, cardDb[target.cardId], enemyIdx);
}
} else {
enemy.health = Math.max(0, enemy.health - 4);
gameState.log.push({ type: 'battlecry', effect: 'deal_2_or_4_hero', fromPlayer: playerIndex, toPlayer: enemyIdx, damage: 4 });
}
} else if (id === 'deal_2_all_enemy_minions') {
enemies.forEach((enemy) => {
if (enemy.board) {
enemy.board.forEach((m) => {
m.health = Math.max(0, m.health - 2);
if (m.health <= 0) {
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, gameState.players.indexOf(enemy));
}
});
enemy.board = enemy.board.filter((m) => m.health > 0);
}
});
gameState.log.push({ type: 'battlecry', effect: 'deal_2_all_enemy_minions', fromPlayer: playerIndex });
} else if (id === 'heal_all_friendly_2') {
p.board.forEach((m) => {
m.health = Math.min(m.maxHealth || m.health, m.health + 2);
});
gameState.log.push({ type: 'battlecry', effect: 'heal_all_friendly_2', playerIndex });
} else if (id === 'heal_all_5') {
p.health = Math.min(30, p.health + 5);
p.board.forEach((m) => {
m.health = Math.min(m.maxHealth || m.health, m.health + 5);
});
gameState.log.push({ type: 'battlecry', effect: 'heal_all_5', playerIndex });
} else if (id === 'destroy_strong_minion' && enemies.length) {
const enemy = enemies[Math.floor(Math.random() * enemies.length)];
const enemyIdx = gameState.players.indexOf(enemy);
if (enemy.board && enemy.board.length > 0) {
const strong = enemy.board.filter(m => m.attack <= 5);
if (strong.length > 0) {
const target = strong[Math.floor(Math.random() * strong.length)];
const boardIdx = enemy.board.indexOf(target);
const targetCard = cardDb[target.cardId];
enemy.board.splice(boardIdx, 1);
gameState.log.push({ type: 'battlecry', effect: 'destroy_strong_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx });
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, enemyIdx);
}
}
}
} else if (id === 'buff_all_friendly_1_1') {
p.board.forEach((m) => {
m.attack += 1;
m.health += 1;
m.maxHealth = (m.maxHealth || m.health) + 1;
});
gameState.log.push({ type: 'battlecry', effect: 'buff_all_friendly_1_1', playerIndex });
}
}
function runDeathrattle(room, card, ownerIndex) {
if (!card) return;
// Проверка на превращение при смерти
if (card.transformOnDeath) {
handleTransformOnDeath(room, card, ownerIndex);
}
if (!card.deathrattleId) return;
const gameState = room.gameState;
const owner = gameState.players[ownerIndex];
const enemies = gameState.players.filter((pl, i) => i !== ownerIndex && pl.health > 0);
const id = card.deathrattleId;
if (id === 'draw_1') {
const drawn = drawCards(owner.deck, 1);
if (drawn.length) owner.hand.push(...drawn);
gameState.log.push({ type: 'deathrattle', effect: 'draw_1', playerIndex: ownerIndex });
} else if (id === 'deal_2_all_enemies') {
enemies.forEach((e) => {
e.health = Math.max(0, e.health - 2);
});
gameState.log.push({ type: 'deathrattle', effect: 'deal_2_all_enemies', fromPlayer: ownerIndex });
} else if (id === 'deal_1_random_enemy' && enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
t.health = Math.max(0, t.health - 1);
gameState.log.push({ type: 'deathrattle', effect: 'deal_1_random_enemy', fromPlayer: ownerIndex });
} else if (id === 'heal_hero_2') {
owner.health = Math.min(30, owner.health + 2);
gameState.log.push({ type: 'deathrattle', effect: 'heal_hero_2', playerIndex: ownerIndex });
} else if (id === 'draw_3') {
const drawn = drawCards(owner.deck, 3);
if (drawn.length) owner.hand.push(...drawn);
gameState.log.push({ type: 'deathrattle', effect: 'draw_3', playerIndex: ownerIndex });
} else if (id === 'deal_4_random' && enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
t.health = Math.max(0, t.health - 4);
gameState.log.push({ type: 'deathrattle', effect: 'deal_4_random', fromPlayer: ownerIndex, toPlayer: gameState.players.indexOf(t), damage: 4 });
} else if (id === 'deal_3_random' && enemies.length) {
const t = enemies[Math.floor(Math.random() * enemies.length)];
t.health = Math.max(0, t.health - 3);
gameState.log.push({ type: 'deathrattle', effect: 'deal_3_random', fromPlayer: ownerIndex, toPlayer: gameState.players.indexOf(t), damage: 3 });
}
}
function handleTransformOnDeath(room, card, ownerIndex) {
if (card.transformOnDeath) {
const owner = room.gameState.players[ownerIndex];
const minionOnBoard = owner.board.find(m => {
const mCard = cardDb[m.cardId];
return mCard && (mCard.id === card.id || mCard.name === card.name);
});
if (minionOnBoard) {
minionOnBoard.cardId = card.transformOnDeath;
minionOnBoard.health = Math.max(1, minionOnBoard.health);
const newCard = cardDb[card.transformOnDeath];
if (newCard) {
minionOnBoard.maxHealth = newCard.health;
minionOnBoard.attack = newCard.attack;
room.gameState.log.push({ type: 'transform', fromCard: card.name, toCard: newCard.name, playerIndex: ownerIndex });
}
}
}
}
function handleTransformOnDeath(room, card, ownerIndex) {
if (card.transformOnDeath) {
const owner = room.gameState.players[ownerIndex];
const minionOnBoard = owner.board.find(m => m.cardId === card.id || m.cardId === Object.keys(cardDb).find(k => cardDb[k] === card));
if (minionOnBoard) {
minionOnBoard.cardId = card.transformOnDeath;
minionOnBoard.health = Math.max(1, minionOnBoard.health);
const newCard = cardDb[card.transformOnDeath];
if (newCard) {
minionOnBoard.maxHealth = newCard.health;
minionOnBoard.attack = newCard.attack;
room.gameState.log.push({ type: 'transform', fromCard: card.id || card.name, toCard: card.transformOnDeath, playerIndex: ownerIndex });
}
}
}
}
function checkGameOver(room) {
const gameState = room.gameState;
const alive = gameState.players.filter((pp) => pp.health > 0);
const dead = gameState.players.filter((pp) => pp.health <= 0);
// Помечаем мёртвых игроков
dead.forEach((p) => {
if (p.health <= 0 && !p.isDead) {
p.isDead = true;
gameState.log.push({ type: 'playerDefeated', playerIndex: gameState.players.indexOf(p), playerName: p.name });
}
});
// Если остался один или меньше живых - игра окончена
if (alive.length <= 1) {
gameState.phase = 'ended';
gameState.winner = alive.length === 1 ? alive[0] : null;
}
// Если текущий игрок мёртв, переключаем ход
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
if (currentPlayer && currentPlayer.health <= 0 && alive.length > 1) {
// Пропускаем ход мёртвого игрока
gameState.log.push({ type: 'skipTurn', playerIndex: gameState.currentPlayerIndex, reason: 'defeated' });
endTurn(room);
}
}
function playCard(room, socketId, handIndex, boardPos) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть
const cid = p.hand[handIndex];
if (!cid) return;
const card = cardDb[cid];
if (!card || card.type !== 'minion' || p.board.length >= 7) return;
const cost = card.cost || 0;
if (p.mana < cost) return;
p.mana -= cost;
p.hand.splice(handIndex, 1);
const minion = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
cardId: cid,
attack: card.attack,
health: card.health,
maxHealth: card.health,
canAttack: card.attack > 0,
};
p.board.splice(typeof boardPos === 'number' ? boardPos : p.board.length, 0, minion);
gameState.log.push({ type: 'play', playerIndex: pi, cardId: cid, minionId: minion.id });
// Для battlecry, требующих выбора цели (Ezra Bridger), отправляем запрос клиенту
if (card.battlecryId === 'return_hand_enemy') {
const enemies = gameState.players.filter((pl, i) => i !== pi && pl.health > 0);
if (enemies.length > 0) {
const socket = io.sockets.sockets.get(p.id);
if (socket) {
// Сохраняем информацию о миньоне для последующего выполнения battlecry
minion.pendingBattlecry = { battlecryId: card.battlecryId, cardId: cid };
socket.emit('battlecryTargetRequest', {
battlecryId: 'return_hand_enemy',
cardId: cid,
minionId: minion.id,
availableTargets: enemies.flatMap(enemy => {
const enemyIdx = gameState.players.indexOf(enemy);
if (!enemy.board || enemy.board.length === 0) return [];
return enemy.board.map((m, boardIdx) => ({
playerIndex: enemyIdx,
boardIndex: boardIdx,
playerName: enemy.name || `Игрок ${enemyIdx + 1}`,
minionName: cardDb[m.cardId]?.name || m.cardId
}));
})
});
broadcastGameState(room);
return; // Не применяем синергии пока, ждём выбора цели
}
}
} else if (card.battlecryId) {
runBattlecry(room, card, pi);
}
// Применяем синергии после размещения карты
applySynergies(room);
checkGameOver(room);
broadcastGameState(room);
}
// Функция для применения синергий между картами
function applySynergies(room) {
const gameState = room.gameState;
const cardDb = require('./cards.js');
gameState.players.forEach((player, playerIndex) => {
if (!player.board || player.board.length === 0) return;
// Сбрасываем все бонусы синергий перед пересчётом
player.board.forEach(m => {
m.synergyAttackBonus = 0;
m.synergyHealthBonus = 0;
});
player.board.forEach((minion, idx) => {
const card = cardDb[minion.cardId];
if (!card) return;
// Дарт Вейдер даёт +1/+1 всем штурмовикам и клонам
if (card.id === 'vader' || card.name === 'Darth Vader') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'stormtrooper' || otherCard.name === 'Stormtrooper' ||
otherCard.id === 'clone_trooper' || otherCard.name === 'Clone Trooper' ||
otherCard.id === 'clone_commando' || otherCard.name === 'Clone Commando' ||
otherCard.id === 'arc_trooper' || otherCard.name === 'ARC Trooper')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Люк Скайуокер даёт +1/+1 всем повстанцам
if (card.id === 'luke' || card.name === 'Luke Skywalker') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && otherCard.faction === 'rebellion' && otherCard.type === 'minion') {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Император Палпатин даёт +1/+1 всем ситхам и имперским картам
if (card.id === 'palpatine' || card.name === 'Emperor Palpatine') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.faction === 'empire' ||
otherCard.name?.includes('Darth') || otherCard.id === 'maul' ||
otherCard.id === 'vader' || otherCard.id === 'dooku' || otherCard.id === 'kylo')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Хан Соло и Чубакка дают друг другу +1/+1
if (card.id === 'han' || card.name === 'Han Solo') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'chewie' || otherCard.name === 'Chewbacca')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
if (card.id === 'chewie' || card.name === 'Chewbacca') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'han' || otherCard.name === 'Han Solo')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// R2-D2 и C-3PO дают друг другу +1/+1
if (card.id === 'r2d2' || card.name === 'R2-D2') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'c3po' || otherCard.name === 'C-3PO')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
if (card.id === 'c3po' || card.name === 'C-3PO') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'r2d2' || otherCard.name === 'R2-D2')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Лея даёт +1/+1 Люку
if (card.id === 'leia' || card.name === 'Princess Leia') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'luke' || otherCard.name === 'Luke Skywalker')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Люк даёт +1/+1 Лее
if (card.id === 'luke' || card.name === 'Luke Skywalker') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'leia' || otherCard.name === 'Princess Leia')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Оби-Ван даёт +1/+1 Энакину/Анакину
if (card.id === 'obiwan' || card.name === 'Obi-Wan Kenobi') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'anakin' || otherCard.name === 'Anakin Skywalker')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Энакин даёт +1/+1 Оби-Вану
if (card.id === 'anakin' || card.name === 'Anakin Skywalker') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'obiwan' || otherCard.name === 'Obi-Wan Kenobi')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Йода даёт +1/+1 всем джедаям
if (card.id === 'yoda' || card.name === 'Yoda') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && otherCard.faction === 'rebellion' &&
(otherCard.name?.includes('Jedi') || otherCard.id === 'luke' ||
otherCard.id === 'obiwan' || otherCard.id === 'anakin' ||
otherCard.id === 'ahsoka' || otherCard.id === 'mace' ||
otherCard.id === 'quigon' || otherCard.id === 'plo_koon' ||
otherCard.id === 'ki_adi' || otherCard.id === 'aayla' ||
otherCard.id === 'shaak_ti' || otherCard.id === 'kanan' ||
otherCard.id === 'ezra' || otherCard.id === 'cal_kestis')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Боба Фетт даёт +1/+1 Джанго Фетту
if (card.id === 'boba' || card.name === 'Boba Fett') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'jango' || otherCard.name === 'Jango Fett')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Джанго Фетт даёт +1/+1 Бобе Фетту
if (card.id === 'jango' || card.name === 'Jango Fett') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'boba' || otherCard.name === 'Boba Fett')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Эйсока даёт +1/+1 Рексу
if (card.id === 'ahsoka' || card.name === 'Ahsoka Tano') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'rex' || otherCard.name === 'Captain Rex')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Рекс даёт +1/+1 Эйсоке
if (card.id === 'rex' || card.name === 'Captain Rex') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'ahsoka' || otherCard.name === 'Ahsoka Tano')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Мандалорец даёт +1/+1 Грогу
if (card.id === 'mando' || card.name === 'Din Djarin') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'grogu' || otherCard.name === 'Grogu')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Грогу даёт +1/+1 Мандалорцу
if (card.id === 'grogu' || card.name === 'Grogu') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'mando' || otherCard.name === 'Din Djarin')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Таркин даёт +1/+1 имперским картам
if (card.id === 'tarkin' || card.name === 'Grand Moff Tarkin') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && otherCard.faction === 'empire' && otherCard.type === 'minion') {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Дарт Мол даёт +1/+1 Сэвиджу Оппрессу
if (card.id === 'maul' || card.name === 'Darth Maul') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'savage' || otherCard.name === 'Savage Opress')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Сэвидж Оппресс даёт +1/+1 Дарту Молу
if (card.id === 'savage' || card.name === 'Savage Opress') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'maul' || otherCard.name === 'Darth Maul')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Падме даёт +1/+1 Энакину
if (card.id === 'padme' || card.name === 'Padmé Amidala') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'anakin' || otherCard.name === 'Anakin Skywalker')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
// Энакин даёт +1/+1 Падме
if (card.id === 'anakin' || card.name === 'Anakin Skywalker') {
player.board.forEach((other, otherIdx) => {
if (idx !== otherIdx) {
const otherCard = cardDb[other.cardId];
if (otherCard && (otherCard.id === 'padme' || otherCard.name === 'Padmé Amidala')) {
other.synergyAttackBonus = (other.synergyAttackBonus || 0) + 1;
other.synergyHealthBonus = (other.synergyHealthBonus || 0) + 1;
}
}
});
}
});
});
}
function playSpell(room, socketId, handIndex, targetPlayerIndex, targetBoardIndex) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть
const cid = p.hand[handIndex];
if (!cid) return;
const card = cardDb[cid];
if (!card || card.type !== 'spell') return;
const cost = card.cost || 0;
if (p.mana < cost) return;
const eff = card.spellEffect;
const needTarget = card.spellTarget && card.spellTarget !== 'none';
// Специальная обработка для steal_cards - требует только выбор противника (не требует targetBoardIndex)
if (eff === 'steal_cards') {
if (targetPlayerIndex == null || targetPlayerIndex === pi) return;
const targetPlayer = gameState.players[targetPlayerIndex];
if (!targetPlayer || targetPlayer.health <= 0 || !targetPlayer.deck || targetPlayer.deck.length === 0) return;
// Отправляем запрос на выбор карт для кражи
const socket = io.sockets.sockets.get(p.id);
if (socket) {
socket.emit('stealCardsRequest', {
targetPlayerIndex: targetPlayerIndex,
targetPlayerName: targetPlayer.name || `Игрок ${targetPlayerIndex + 1}`,
targetDeckSize: targetPlayer.deck.length,
maxCards: Math.min(2, targetPlayer.deck.length)
});
}
return; // Не тратим ману и не удаляем карту пока - это сделаем после выбора
}
// Для других заклинаний проверяем targetBoardIndex только если это не выбор игрока
if (needTarget && card.spellTarget !== 'enemy_player' && (targetPlayerIndex == null || targetBoardIndex == null)) return;
if (needTarget && card.spellTarget === 'enemy_player' && targetPlayerIndex == null) return;
const enemies = gameState.players.filter((pl, i) => i !== pi && pl.health > 0);
if (eff === 'deal_2') {
if (targetBoardIndex === -1) {
const t = gameState.players[targetPlayerIndex];
if (!t || t.health <= 0) return;
t.health = Math.max(0, t.health - 2);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_2_hero', damage: 2 });
} else {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.health -= 2;
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_2_minion', damage: 2 });
if (m.health <= 0) {
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex);
}
}
} else if (eff === 'draw_2') {
const drawn = drawCards(p.deck, 2);
if (drawn.length) p.hand.push(...drawn);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'draw_2' });
} else if (eff === 'buff_2_2') {
const m = p.board[targetBoardIndex];
if (!m) return;
m.attack += 2;
m.health += 2;
m.maxHealth = (m.maxHealth || m.health) + 2;
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toIdx: targetBoardIndex, effect: 'buff_2_2' });
} else if (eff === 'deal_1_all_enemies') {
gameState.players.forEach((pl, i) => {
if (i === pi) return;
pl.health = Math.max(0, pl.health - 1);
pl.board.forEach((m) => {
m.health -= 1;
if (m.health <= 0) {
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, i);
}
});
pl.board = pl.board.filter((m) => m.health > 0);
});
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'deal_1_all_enemies' });
} else if (eff === 'freeze') {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.frozen = true;
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'freeze' });
} else if (eff === 'return_hand') {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
t.board.splice(targetBoardIndex, 1);
t.hand.push(m.cardId);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'return_hand' });
} else if (eff === 'deal_4') {
if (targetBoardIndex === -1) {
const t = gameState.players[targetPlayerIndex];
if (!t || t.health <= 0) return;
t.health = Math.max(0, t.health - 4);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_4_hero', damage: 4 });
} else {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.health = Math.max(0, m.health - 4);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_4_minion', damage: 4 });
if (m.health <= 0) {
t.board.splice(targetBoardIndex, 1);
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex);
}
}
} else if (eff === 'heal_4') {
if (targetBoardIndex === -1) {
const t = gameState.players[targetPlayerIndex];
if (!t) return;
t.health = Math.min(30, t.health + 4);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'heal_4_hero' });
} else {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.health = Math.min(m.maxHealth || m.health, m.health + 4);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'heal_4_minion' });
}
} else if (eff === 'deal_3_minion') {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.health = Math.max(0, m.health - 3);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_3_minion', damage: 3 });
if (m.health <= 0) {
t.board.splice(targetBoardIndex, 1);
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex);
}
} else if (eff === 'freeze_damage') {
const t = gameState.players[targetPlayerIndex];
const m = t?.board?.[targetBoardIndex];
if (!m) return;
m.frozen = true;
m.health = Math.max(0, m.health - 2);
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'freeze_damage', damage: 2 });
if (m.health <= 0) {
t.board.splice(targetBoardIndex, 1);
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex);
}
} else if (eff === 'buff_all_friendly') {
p.board.forEach((m) => {
m.attack += 1;
m.health += 1;
m.maxHealth = (m.maxHealth || m.health) + 1;
});
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'buff_all_friendly' });
} else if (eff === 'destroy_weak_2') {
enemies.forEach((enemy) => {
if (enemy.board && enemy.board.length > 0) {
const weak = enemy.board.filter(m => m.attack <= 2);
weak.forEach((target) => {
const boardIdx = enemy.board.indexOf(target);
const targetCard = cardDb[target.cardId];
enemy.board.splice(boardIdx, 1);
if (targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, gameState.players.indexOf(enemy));
}
});
}
});
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'destroy_weak_2' });
} else if (eff === 'buff_3_2') {
const m = p.board[targetBoardIndex];
if (!m) return;
m.attack += 3;
m.health += 2;
m.maxHealth = (m.maxHealth || m.health) + 2;
gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toIdx: targetBoardIndex, effect: 'buff_3_2' });
} else return;
p.mana -= cost;
p.hand.splice(handIndex, 1);
// Применяем синергии после размещения карты
applySynergies(room);
checkGameOver(room);
broadcastGameState(room);
}
function stealCardsFromDeck(room, socketId, handIndex, targetPlayerIndex, cardIndices) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return;
const cid = p.hand[handIndex];
if (!cid) return;
const card = cardDb[cid];
if (!card || card.type !== 'spell' || card.spellEffect !== 'steal_cards') return;
const cost = card.cost || 0;
if (p.mana < cost) return;
const targetPlayer = gameState.players[targetPlayerIndex];
if (!targetPlayer || targetPlayerIndex === pi || targetPlayer.health <= 0) return;
if (!targetPlayer.deck || targetPlayer.deck.length === 0) return;
// Проверяем индексы карт
if (!Array.isArray(cardIndices) || cardIndices.length === 0 || cardIndices.length > 2) return;
const validIndices = cardIndices.filter(idx => idx >= 0 && idx < targetPlayer.deck.length);
if (validIndices.length === 0) return;
// Убираем дубликаты и сортируем по убыванию (чтобы удалять с конца)
const uniqueIndices = [...new Set(validIndices)].sort((a, b) => b - a);
// Крадём карты
const stolenCards = [];
uniqueIndices.forEach(idx => {
if (idx >= 0 && idx < targetPlayer.deck.length) {
stolenCards.push(targetPlayer.deck[idx]);
targetPlayer.deck.splice(idx, 1);
}
});
// Добавляем украденные карты в руку игрока
stolenCards.forEach(cardId => {
if (p.hand.length < 10) {
p.hand.push(cardId);
}
});
// Тратим ману и удаляем заклинание
p.mana -= cost;
p.hand.splice(handIndex, 1);
gameState.log.push({
type: 'spell',
spell: cid,
fromPlayer: pi,
toPlayer: targetPlayerIndex,
effect: 'steal_cards',
stolenCount: stolenCards.length
});
// Применяем синергии после изменений на доске
applySynergies(room);
// Применяем синергии после боя
applySynergies(room);
// Применяем синергии после изменений на доске
applySynergies(room);
checkGameOver(room);
broadcastGameState(room);
}
function heroAbility(room, socketId, targetPlayerIndex, targetBoardIndex) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть
if (p.heroAbilityUsed || p.mana < 2) return;
if (targetPlayerIndex == null || targetBoardIndex == null) return;
const targetPlayer = gameState.players[targetPlayerIndex];
if (!targetPlayer) return;
if (targetBoardIndex === -1) {
targetPlayer.health = Math.max(0, targetPlayer.health - 1);
gameState.log.push({ type: 'heroAbility', fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_1_hero' });
} else {
const m = targetPlayer.board[targetBoardIndex];
if (!m) return;
m.health -= 1;
gameState.log.push({ type: 'heroAbility', fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_1_minion' });
}
p.mana -= 2;
p.heroAbilityUsed = true;
if (targetBoardIndex !== -1) {
const m = targetPlayer.board[targetBoardIndex];
if (m && m.health <= 0) {
const card = cardDb[m.cardId];
if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex);
}
}
gameState.players.forEach((pl) => {
pl.board = pl.board.filter((min) => min.health > 0);
});
// Применяем синергии после изменений на доске
applySynergies(room);
// Применяем синергии после изменений на доске
applySynergies(room);
checkGameOver(room);
broadcastGameState(room);
}
function attack(room, socketId, attackerBoardIndex, targetPlayerIndex, targetBoardIndex) {
const gameState = room.gameState;
const pi = findPlayerIndex(room, socketId);
if (pi < 0 || gameState.currentPlayerIndex !== pi) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может атаковать
const attacker = p.board[attackerBoardIndex];
if (!attacker || !attacker.canAttack) return;
const targetPlayer = gameState.players[targetPlayerIndex];
if (!targetPlayer) return;
// Применяем синергии перед расчётом урона
applySynergies(room);
if (targetBoardIndex === -1) {
const attackerAttack = attacker.attack + (attacker.synergyAttackBonus || 0);
targetPlayer.health = Math.max(0, targetPlayer.health - attackerAttack);
gameState.log.push({
type: 'attackHero',
fromPlayer: pi,
toPlayer: targetPlayerIndex,
attackerMinionId: attacker.id,
damage: attacker.attack,
});
// Применяем синергии перед расчётом урона
applySynergies(room);
checkGameOver(room); // Проверяем после атаки по герою
} else {
const target = targetPlayer.board[targetBoardIndex];
if (!target) return;
// Применяем синергии перед расчётом урона
applySynergies(room);
// Учитываем бонусы синергий при расчёте урона (бонусы уже применены в applySynergies)
const attackerAttack = attacker.attack + (attacker.synergyAttackBonus || 0);
const targetAttack = target.attack + (target.synergyAttackBonus || 0);
target.health -= attackerAttack;
attacker.health -= targetAttack;
const attackerDied = attacker.health <= 0;
const targetDied = target.health <= 0;
gameState.log.push({
type: 'attack',
fromPlayer: pi,
toPlayer: targetPlayerIndex,
fromIdx: attackerBoardIndex,
toIdx: targetBoardIndex,
attackerMinionId: attacker.id,
targetMinionId: target.id,
attackerDied,
targetDied,
damage: attacker.attack,
reverseDamage: target.attack,
});
const targetCard = cardDb[target.cardId];
const attackerCard = cardDb[attacker.cardId];
if (targetDied && targetCard && targetCard.deathrattleId) {
runDeathrattle(room, targetCard, targetPlayerIndex);
}
if (attackerDied && attackerCard && attackerCard.deathrattleId) {
runDeathrattle(room, attackerCard, pi);
}
}
// Механика двойной атаки
const attackerCard = cardDb[attacker.cardId];
if (attackerCard && attackerCard.canAttackTwice && !attacker.attacksUsed) {
attacker.attacksUsed = 1; // Использована одна атака
} else {
attacker.canAttack = false;
attacker.attacksUsed = 0;
}
[...gameState.players].forEach((pl) => {
pl.board = pl.board.filter((m) => m.health > 0);
});
checkGameOver(room);
broadcastGameState(room);
}
io.on('connection', (socket) => {
socket.on('createRoom', (name) => {
const code = generateRoomCode();
const room = {
code,
lobby: [{ id: socket.id, name: name || 'Host' }],
gameState: null,
gameStarted: false,
faction: null,
turnTimerInterval: null,
turnTimeLeft: TURN_SECONDS,
turnTimerWarnFired: false,
};
rooms.set(code, room);
socket.join(code);
socket.emit('roomCreated', {
code,
you: room.lobby[0],
lobby: room.lobby,
serverIP: serverIP,
serverPort: serverPort,
});
cleanupEmptyRooms();
});
socket.on('joinRoom', (data) => {
const { code, name } = data || {};
if (!code) {
socket.emit('error', 'Код комнаты не указан');
return;
}
const room = getRoomByCode(code);
if (!room) {
socket.emit('error', 'Комната не найдена');
return;
}
if (room.gameStarted) {
socket.emit('error', 'Игра уже началась');
return;
}
if (room.lobby.length >= MAX_PLAYERS) {
socket.emit('error', 'Комната заполнена');
return;
}
room.lobby.push({ id: socket.id, name: name || `Player ${room.lobby.length + 1}` });
socket.join(code);
io.to(code).emit('lobbyUpdate', room.lobby);
socket.emit('roomJoined', { code, you: room.lobby[room.lobby.length - 1], lobby: room.lobby });
cleanupEmptyRooms();
});
socket.on('leaveLobby', () => {
const room = getRoomBySocket(socket.id);
if (!room) return;
room.lobby = room.lobby.filter((p) => p.id !== socket.id);
socket.leave(room.code);
if (room.lobby.length === 0 && !room.gameStarted) {
clearTurnTimer(room);
rooms.delete(room.code);
} else {
io.to(room.code).emit('lobbyUpdate', room.lobby);
}
cleanupEmptyRooms();
});
socket.on('startGame', (data) => {
const room = getRoomBySocket(socket.id);
if (!room) {
socket.emit('error', 'Комната не найдена');
return;
}
if (room.lobby[0]?.id !== socket.id) {
socket.emit('error', 'Только хост может начать игру');
return;
}
if (room.lobby.length < MIN_PLAYERS) {
socket.emit('error', `Нужно минимум ${MIN_PLAYERS} игроков`);
return;
}
const validFactions = ['rebellion', 'empire', 'pirates', 'mandalorians'];
const faction = data?.faction && validFactions.includes(data.faction) ? data.faction : null;
room.faction = faction;
initGame(room);
});
socket.on('playCard', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
playCard(room, socket.id, data.handIndex, data.boardPos);
});
socket.on('playSpell', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
playSpell(room, socket.id, data.handIndex, data.targetPlayerIndex, data.targetBoardIndex);
});
socket.on('heroAbility', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
heroAbility(room, socket.id, data.targetPlayerIndex, data.targetBoardIndex);
});
socket.on('attack', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
attack(
room,
socket.id,
data.attackerBoardIndex,
data.targetPlayerIndex,
data.targetBoardIndex ?? -1
);
});
socket.on('endTurn', () => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
if (findPlayerIndex(room, socket.id) !== room.gameState.currentPlayerIndex) return;
endTurn(room);
});
socket.on('drawCard', () => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
manualDraw(room, socket.id);
});
socket.on('forgeCard', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
forgeCard(room, socket.id, data.cardIds);
});
socket.on('stealCards', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
stealCardsFromDeck(room, socket.id, data.handIndex, data.targetPlayerIndex, data.cardIndices);
});
socket.on('battlecryTarget', (data) => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || room.gameState.phase !== 'playing') return;
const gameState = room.gameState;
const pi = findPlayerIndex(room, socket.id);
if (pi < 0) return;
const p = gameState.players[pi];
if (p.health <= 0 || p.isDead) return;
if (data.battlecryId === 'return_hand_enemy') {
const targetPlayerIndex = data.targetPlayerIndex;
const targetBoardIndex = data.targetBoardIndex;
const targetPlayer = gameState.players[targetPlayerIndex];
if (!targetPlayer || targetPlayerIndex === pi || targetPlayer.health <= 0) return;
if (!targetPlayer.board || targetPlayer.board.length === 0) return;
if (targetBoardIndex == null || targetBoardIndex < 0 || targetBoardIndex >= targetPlayer.board.length) return;
const target = targetPlayer.board[targetBoardIndex];
targetPlayer.board.splice(targetBoardIndex, 1);
if (targetPlayer.hand.length < 10) {
targetPlayer.hand.push(target.cardId);
}
// Удаляем pendingBattlecry с миньона
const playedMinion = p.board?.find(m => m.pendingBattlecry && m.pendingBattlecry.battlecryId === 'return_hand_enemy');
if (playedMinion) {
delete playedMinion.pendingBattlecry;
}
gameState.log.push({ type: 'battlecry', effect: 'return_hand_enemy', fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex });
// Применяем синергии после изменений на доске
applySynergies(room);
checkGameOver(room);
broadcastGameState(room);
}
});
socket.on('resetToLobby', () => {
const room = getRoomBySocket(socket.id);
if (!room || !room.gameState || !room.gameState.players?.length) return;
const hostId = room.gameState.players[0].id;
if (socket.id !== hostId) return;
clearTurnTimer(room);
room.lobby = room.gameState.players.map((p) => ({ id: p.id, name: p.name }));
room.gameState = null;
room.gameStarted = false;
io.to(room.code).emit('backToLobby');
});
socket.on('disconnect', () => {
const room = getRoomBySocket(socket.id);
if (!room) return;
if (!room.gameStarted) {
room.lobby = room.lobby.filter((p) => p.id !== socket.id);
socket.leave(room.code);
if (room.lobby.length === 0) {
clearTurnTimer(room);
rooms.delete(room.code);
} else {
io.to(room.code).emit('lobbyUpdate', room.lobby);
}
cleanupEmptyRooms();
return;
}
if (room.gameState) {
const idx = findPlayerIndex(room, socket.id);
if (idx >= 0) {
room.gameState.players[idx].health = 0;
const alive = room.gameState.players.filter((p) => p.health > 0);
if (alive.length <= 1) {
room.gameState.phase = 'ended';
room.gameState.winner = alive[0] || null;
}
broadcastGameState(room);
}
}
});
});
let serverIP = 'localhost';
let serverPort = PORT;
server.listen(PORT, '0.0.0.0', () => {
const os = require('os');
const nets = os.networkInterfaces();
let lan = 'localhost';
for (const name of Object.keys(nets)) {
for (const n of nets[name]) {
if (n.family === 'IPv4' && !n.internal) {
lan = n.address;
break;
}
}
}
serverIP = lan;
serverPort = PORT;
console.log(`
╔══════════════════════════════════════════════════════════╗
║ STAR WARS HEARTHSTONE - Server running ║
║ Local: http://localhost:${PORT}
║ LAN: http://${lan}:${PORT} (use for Radmin VPN) ║
╚══════════════════════════════════════════════════════════╝
`);
});