poker/server.js

1589 lines
49 KiB
JavaScript
Raw Permalink 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.

/**
* =============================================================================
* Texas Hold'em WebSocket Server
* Node.js + ws для мультиплеера 2-6 игроков
* =============================================================================
*/
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const path = require('path');
const database = require('./database');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Middleware для JSON
app.use(express.json());
// Статические файлы
app.use(express.static(path.join(__dirname, 'public')));
// =============================================================================
// REST API - АВТОРИЗАЦИЯ
// =============================================================================
/**
* Middleware проверки авторизации
*/
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Требуется авторизация' });
}
const decoded = database.verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: 'Недействительный токен' });
}
req.user = decoded;
next();
}
/**
* Middleware проверки админа
*/
function adminMiddleware(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Требуются права администратора' });
}
next();
}
// Регистрация
app.post('/api/auth/register', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Введите логин и пароль' });
}
if (username.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Логин мин. 3 символа, пароль мин. 6' });
}
const result = database.registerUser(username, password);
if (result.success) {
res.json(result);
} else {
res.status(400).json({ error: result.error });
}
});
// Вход
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Введите логин и пароль' });
}
// Получаем IP адрес клиента
const ipAddress = req.headers['x-forwarded-for'] || req.connection.remoteAddress || req.socket.remoteAddress;
const result = database.loginUser(username, password, ipAddress);
if (result.success) {
res.json(result);
} else {
res.status(401).json({ error: result.error });
}
});
// Проверка токена
app.get('/api/auth/me', authMiddleware, (req, res) => {
const user = database.getUserById(req.user.userId);
if (user) {
res.json({ user });
} else {
res.status(404).json({ error: 'Пользователь не найден' });
}
});
// =============================================================================
// REST API - НАСТРОЙКИ
// =============================================================================
// Получить пользовательские настройки
app.get('/api/settings/user', authMiddleware, (req, res) => {
const settings = database.getUserSettings(req.user.userId);
res.json({ settings });
});
// Сохранить пользовательские настройки
app.post('/api/settings/user', authMiddleware, (req, res) => {
database.saveUserSettings(req.user.userId, req.body);
database.logAction(req.user.userId, req.user.username, 'settings_update', req.body);
res.json({ success: true });
});
// Получить админские настройки
app.get('/api/settings/admin', authMiddleware, adminMiddleware, (req, res) => {
const settings = database.getAdminSettings();
console.log('📤 Отправка админских настроек:', settings);
res.json({ settings });
});
// Сохранить админские настройки
app.post('/api/settings/admin', authMiddleware, adminMiddleware, (req, res) => {
database.saveAdminSettings(req.body, req.user.userId);
database.logAction(req.user.userId, req.user.username, 'admin_settings_update', req.body);
res.json({ success: true });
});
// Получить публичные настройки (LLM статус для всех)
app.get('/api/settings/public', (req, res) => {
const adminSettings = database.getAdminSettings();
res.json({
llmEnabled: adminSettings.llmEnabled,
llmProvider: adminSettings.llmProvider,
llmApiUrl: adminSettings.llmApiUrl,
llmModel: adminSettings.llmModel
});
});
// =============================================================================
// REST API - ЛОГИ (только для админа)
// =============================================================================
// Получить логи
app.get('/api/logs', authMiddleware, adminMiddleware, (req, res) => {
const { limit, offset, actionType, userId, startDate, endDate } = req.query;
const logs = database.getLogs({
limit: parseInt(limit) || 100,
offset: parseInt(offset) || 0,
actionType,
userId,
startDate,
endDate
});
res.json({ logs });
});
// Статистика логов
app.get('/api/logs/stats', authMiddleware, adminMiddleware, (req, res) => {
const stats = database.getLogStats();
res.json({ stats });
});
// =============================================================================
// REST API - УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ (только для админа)
// =============================================================================
// Получить список пользователей с фильтрами
app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
try {
const { role, status, search } = req.body;
// Получаем всех пользователей
const allUsers = database.getAllUsers();
// Применяем фильтры
let filteredUsers = allUsers;
if (role) {
filteredUsers = filteredUsers.filter(u => u.role === role);
}
if (status === 'banned') {
filteredUsers = filteredUsers.filter(u => u.banned === 1);
} else if (status === 'active') {
filteredUsers = filteredUsers.filter(u => u.banned === 0);
}
if (search) {
const searchLower = search.toLowerCase();
filteredUsers = filteredUsers.filter(u =>
u.username.toLowerCase().includes(searchLower)
);
}
// Статистика
const today = new Date();
today.setHours(0, 0, 0, 0);
const stats = {
total: allUsers.length,
active: allUsers.filter(u => {
if (!u.lastLogin) return false;
const lastLogin = new Date(u.lastLogin);
return lastLogin >= today;
}).length,
banned: allUsers.filter(u => u.banned === 1).length,
admins: allUsers.filter(u => u.role === 'admin').length
};
// Логируем действие
database.logAction(req.user.userId, req.user.username, 'view_users', { count: filteredUsers.length });
res.json({
users: filteredUsers,
stats
});
} catch (error) {
console.error('Ошибка получения пользователей:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Обновить пользователя
app.post('/api/admin/user/update', authMiddleware, adminMiddleware, (req, res) => {
try {
const { username, role, banned, password } = req.body;
if (!username) {
return res.status(400).json({ error: 'Логин обязателен' });
}
// Получаем пользователя
const user = database.getUserByUsername(username);
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
// Обновляем данные
database.updateUser(username, {
role: role || user.role,
banned: banned ? 1 : 0,
password: password || undefined
});
// Логируем действие
database.logAction(req.user.userId, req.user.username, 'update_user', {
target: username,
changes: { role, banned, passwordChanged: !!password }
});
res.json({ success: true });
} catch (error) {
console.error('Ошибка обновления пользователя:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Заблокировать/разблокировать пользователя
app.post('/api/admin/user/ban', authMiddleware, adminMiddleware, (req, res) => {
try {
const { username, banned } = req.body;
if (!username) {
return res.status(400).json({ error: 'Логин обязателен' });
}
// Нельзя заблокировать себя
if (username === req.user.username) {
return res.status(403).json({ error: 'Нельзя заблокировать себя' });
}
database.updateUser(username, { banned: banned ? 1 : 0 });
// Логируем действие
database.logAction(req.user.userId, req.user.username, banned ? 'ban_user' : 'unban_user', {
target: username
});
res.json({ success: true });
} catch (error) {
console.error('Ошибка изменения статуса:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить пользователя
app.post('/api/admin/user/delete', authMiddleware, adminMiddleware, (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).json({ error: 'Логин обязателен' });
}
// Нельзя удалить себя
if (username === req.user.username) {
return res.status(403).json({ error: 'Нельзя удалить себя' });
}
database.deleteUser(username);
// Логируем действие
database.logAction(req.user.userId, req.user.username, 'delete_user', {
target: username
});
res.json({ success: true });
} catch (error) {
console.error('Ошибка удаления пользователя:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// =============================================================================
// REST API - ЧАТ
// =============================================================================
// Получить историю чата комнаты
app.get('/api/chat/:roomId', authMiddleware, (req, res) => {
const { roomId } = req.params;
const { limit } = req.query;
const messages = database.getChatHistory(roomId, parseInt(limit) || 50);
res.json({ messages });
});
// =============================================================================
// ИГРОВЫЕ СТРУКТУРЫ ДАННЫХ
// =============================================================================
/**
* Комнаты для игры
* @type {Map<string, Room>}
*/
const rooms = new Map();
/**
* Связь WebSocket с игроком
* @type {Map<WebSocket, PlayerConnection>}
*/
const connections = new Map();
// Масти и номиналы карт
const SUITS = ['hearts', 'diamonds', 'clubs', 'spades'];
const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
// =============================================================================
// КЛАССЫ
// =============================================================================
/**
* Класс карты
*/
class Card {
constructor(suit, rank) {
this.suit = suit;
this.rank = rank;
this.value = RANKS.indexOf(rank) + 2; // 2-14 (A=14)
}
toString() {
return `${this.rank}${this.suit[0].toUpperCase()}`;
}
}
/**
* Класс колоды
*/
class Deck {
constructor() {
this.reset();
}
reset() {
this.cards = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
this.cards.push(new Card(suit, rank));
}
}
this.shuffle();
}
shuffle() {
// Fisher-Yates shuffle
for (let i = this.cards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]];
}
}
deal(count = 1) {
return this.cards.splice(0, count);
}
}
/**
* Класс игрока
*/
class Player {
constructor(id, name, chips = 1000) {
this.id = id;
this.name = name;
this.chips = chips;
this.hand = [];
this.bet = 0;
this.totalBet = 0; // Общая ставка за раунд
this.folded = false;
this.allIn = false;
this.isDealer = false;
this.isSmallBlind = false;
this.isBigBlind = false;
this.isConnected = true;
this.lastAction = null;
}
reset() {
this.hand = [];
this.bet = 0;
this.totalBet = 0;
this.folded = false;
this.allIn = false;
this.isDealer = false;
this.isSmallBlind = false;
this.isBigBlind = false;
this.lastAction = null;
}
toPublicJSON() {
return {
id: this.id,
name: this.name,
chips: this.chips,
bet: this.bet,
totalBet: this.totalBet,
folded: this.folded,
allIn: this.allIn,
isDealer: this.isDealer,
isSmallBlind: this.isSmallBlind,
isBigBlind: this.isBigBlind,
isConnected: this.isConnected,
lastAction: this.lastAction,
hasCards: this.hand.length > 0
};
}
}
/**
* Класс комнаты/стола
*/
class Room {
constructor(id, name, smallBlind = 5, bigBlind = 10, maxPlayers = 6) {
this.id = id;
this.name = name;
this.smallBlind = smallBlind;
this.bigBlind = bigBlind;
this.maxPlayers = maxPlayers;
this.players = [];
this.deck = new Deck();
this.communityCards = [];
this.pot = 0;
this.sidePots = [];
this.currentBet = 0;
this.dealerIndex = 0;
this.currentPlayerIndex = 0;
this.gamePhase = 'waiting'; // waiting, preflop, flop, turn, river, showdown
this.isGameStarted = false;
this.minRaise = bigBlind;
this.lastRaiseAmount = bigBlind;
this.messages = [];
this.roundStarted = false;
}
/**
* Добавить игрока в комнату
*/
addPlayer(player) {
if (this.players.length >= this.maxPlayers) {
return false;
}
this.players.push(player);
return true;
}
/**
* Удалить игрока из комнаты
*/
removePlayer(playerId) {
const index = this.players.findIndex(p => p.id === playerId);
if (index !== -1) {
this.players.splice(index, 1);
return true;
}
return false;
}
/**
* Получить активных игроков (не фолднувших)
*/
getActivePlayers() {
return this.players.filter(p => !p.folded && p.isConnected);
}
/**
* Получить игроков с фишками
*/
getPlayersWithChips() {
return this.players.filter(p => p.chips > 0 && p.isConnected);
}
/**
* Начать новую раздачу
*/
startNewHand() {
if (this.getPlayersWithChips().length < 2) {
return false;
}
// Сброс состояния
this.deck.reset();
this.communityCards = [];
this.pot = 0;
this.sidePots = [];
this.currentBet = 0;
this.minRaise = this.bigBlind;
this.lastRaiseAmount = this.bigBlind;
this.roundStarted = true;
// Сброс игроков
for (const player of this.players) {
player.reset();
}
// Определение позиций
this.moveDealer();
this.assignBlinds();
// Раздача карт
this.dealHoleCards();
// Начало игры
this.gamePhase = 'preflop';
this.isGameStarted = true;
// Ставки блайндов
this.postBlinds();
// Первый ход - после большого блайнда (UTG)
this.setFirstPlayer();
return true;
}
/**
* Передвинуть дилера
*/
moveDealer() {
const playersWithChips = this.getPlayersWithChips();
if (playersWithChips.length < 2) return;
// Находим следующего дилера
do {
this.dealerIndex = (this.dealerIndex + 1) % this.players.length;
} while (this.players[this.dealerIndex].chips <= 0 || !this.players[this.dealerIndex].isConnected);
this.players[this.dealerIndex].isDealer = true;
}
/**
* Назначить блайнды
*/
assignBlinds() {
const playersWithChips = this.getPlayersWithChips();
if (playersWithChips.length === 2) {
// Heads-up: дилер = SB
this.players[this.dealerIndex].isSmallBlind = true;
let bbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
this.players[bbIndex].isBigBlind = true;
} else {
// 3+ игроков
let sbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
this.players[sbIndex].isSmallBlind = true;
let bbIndex = this.getNextActivePlayerIndex(sbIndex);
this.players[bbIndex].isBigBlind = true;
}
}
/**
* Поставить блайнды
*/
postBlinds() {
const sbPlayer = this.players.find(p => p.isSmallBlind);
const bbPlayer = this.players.find(p => p.isBigBlind);
if (sbPlayer) {
const sbAmount = Math.min(sbPlayer.chips, this.smallBlind);
sbPlayer.chips -= sbAmount;
sbPlayer.bet = sbAmount;
sbPlayer.totalBet = sbAmount;
this.pot += sbAmount;
}
if (bbPlayer) {
const bbAmount = Math.min(bbPlayer.chips, this.bigBlind);
bbPlayer.chips -= bbAmount;
bbPlayer.bet = bbAmount;
bbPlayer.totalBet = bbAmount;
this.pot += bbAmount;
this.currentBet = bbAmount;
}
}
/**
* Раздать карманные карты
*/
dealHoleCards() {
for (const player of this.getPlayersWithChips()) {
player.hand = this.deck.deal(2);
}
}
/**
* Установить первого игрока для хода
*/
setFirstPlayer() {
if (this.gamePhase === 'preflop') {
// UTG - после BB
const bbPlayer = this.players.find(p => p.isBigBlind);
const bbIndex = this.players.indexOf(bbPlayer);
this.currentPlayerIndex = this.getNextActivePlayerIndex(bbIndex);
} else {
// После флопа - первый активный после дилера
this.currentPlayerIndex = this.getNextActivePlayerIndex(this.dealerIndex);
}
}
/**
* Получить индекс следующего активного игрока
*/
getNextActivePlayerIndex(fromIndex) {
let index = (fromIndex + 1) % this.players.length;
let attempts = 0;
while (attempts < this.players.length) {
const player = this.players[index];
if (!player.folded && player.chips >= 0 && player.isConnected && !player.allIn) {
return index;
}
index = (index + 1) % this.players.length;
attempts++;
}
return -1;
}
/**
* Обработать действие игрока
*/
processAction(playerId, action, amount = 0) {
const player = this.players.find(p => p.id === playerId);
if (!player) return { success: false, error: 'Игрок не найден' };
const currentPlayer = this.players[this.currentPlayerIndex];
if (currentPlayer.id !== playerId) {
return { success: false, error: 'Сейчас не ваш ход' };
}
const result = { success: true };
switch (action) {
case 'fold':
player.folded = true;
player.lastAction = 'fold';
break;
case 'check':
if (player.bet < this.currentBet) {
return { success: false, error: 'Нельзя чек, нужно уравнять ставку' };
}
player.lastAction = 'check';
break;
case 'call':
const callAmount = Math.min(this.currentBet - player.bet, player.chips);
player.chips -= callAmount;
player.bet += callAmount;
player.totalBet += callAmount;
this.pot += callAmount;
player.lastAction = `call ${callAmount}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'all-in';
}
break;
case 'bet':
if (this.currentBet > 0) {
return { success: false, error: 'Уже есть ставка, используйте raise' };
}
if (amount < this.bigBlind) {
return { success: false, error: `Минимальная ставка: ${this.bigBlind}` };
}
if (amount > player.chips) {
amount = player.chips;
}
player.chips -= amount;
player.bet = amount;
player.totalBet += amount;
this.pot += amount;
this.currentBet = amount;
this.lastRaiseAmount = amount;
this.minRaise = amount;
player.lastAction = `bet ${amount}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'all-in';
}
break;
case 'raise':
const toCall = this.currentBet - player.bet;
const minRaiseTotal = this.currentBet + this.lastRaiseAmount;
if (amount < minRaiseTotal && amount < player.chips + player.bet) {
return { success: false, error: `Минимальный рейз до: ${minRaiseTotal}` };
}
const raiseTotal = Math.min(amount, player.chips + player.bet);
const raiseAmount = raiseTotal - player.bet;
player.chips -= raiseAmount;
this.pot += raiseAmount;
this.lastRaiseAmount = raiseTotal - this.currentBet;
this.currentBet = raiseTotal;
player.bet = raiseTotal;
player.totalBet += raiseAmount;
player.lastAction = `raise to ${raiseTotal}`;
if (player.chips === 0) {
player.allIn = true;
player.lastAction = 'all-in';
}
break;
case 'allin':
const allInAmount = player.chips;
this.pot += allInAmount;
player.bet += allInAmount;
player.totalBet += allInAmount;
if (player.bet > this.currentBet) {
this.lastRaiseAmount = player.bet - this.currentBet;
this.currentBet = player.bet;
}
player.chips = 0;
player.allIn = true;
player.lastAction = `all-in ${player.bet}`;
break;
default:
return { success: false, error: 'Неизвестное действие' };
}
// Проверка завершения раунда ставок
if (this.isBettingRoundComplete()) {
this.nextPhase();
} else {
this.moveToNextPlayer();
}
return result;
}
/**
* Перейти к следующему игроку
*/
moveToNextPlayer() {
const nextIndex = this.getNextActivePlayerIndex(this.currentPlayerIndex);
if (nextIndex !== -1) {
this.currentPlayerIndex = nextIndex;
}
}
/**
* Проверить завершение раунда ставок
*/
isBettingRoundComplete() {
const activePlayers = this.getActivePlayers().filter(p => !p.allIn);
// Все фолднули кроме одного
if (this.getActivePlayers().length === 1) {
return true;
}
// Все активные (не all-in) игроки уравняли ставку
if (activePlayers.length === 0) {
return true;
}
// Все поставили одинаково и сделали действие
const allEqual = activePlayers.every(p => p.bet === this.currentBet);
const allActed = activePlayers.every(p => p.lastAction !== null);
return allEqual && allActed;
}
/**
* Переход к следующей фазе
*/
nextPhase() {
// Сброс ставок для новой улицы
for (const player of this.players) {
player.bet = 0;
player.lastAction = null;
}
this.currentBet = 0;
const activePlayers = this.getActivePlayers();
// Один игрок остался - он победитель
if (activePlayers.length === 1) {
this.endHand([activePlayers[0]]);
return;
}
switch (this.gamePhase) {
case 'preflop':
this.gamePhase = 'flop';
this.communityCards = this.deck.deal(3);
break;
case 'flop':
this.gamePhase = 'turn';
this.communityCards.push(...this.deck.deal(1));
break;
case 'turn':
this.gamePhase = 'river';
this.communityCards.push(...this.deck.deal(1));
break;
case 'river':
this.gamePhase = 'showdown';
this.determineWinner();
return;
}
// Установка первого игрока для новой улицы
this.setFirstPlayer();
// Если все в all-in, автоматически переходим к следующей фазе
if (this.getActivePlayers().filter(p => !p.allIn).length <= 1) {
setTimeout(() => this.nextPhase(), 1000);
}
}
/**
* Определить победителя
*/
determineWinner() {
const activePlayers = this.getActivePlayers();
if (activePlayers.length === 1) {
this.endHand([activePlayers[0]]);
return;
}
// Оценка рук
const playerHands = activePlayers.map(player => ({
player,
handResult: evaluateHand([...player.hand, ...this.communityCards])
}));
// Сортировка по силе руки
playerHands.sort((a, b) => {
if (b.handResult.rank !== a.handResult.rank) {
return b.handResult.rank - a.handResult.rank;
}
// Сравнение кикеров
for (let i = 0; i < a.handResult.kickers.length; i++) {
if (b.handResult.kickers[i] !== a.handResult.kickers[i]) {
return b.handResult.kickers[i] - a.handResult.kickers[i];
}
}
return 0;
});
// Находим победителей (может быть сплит)
const winners = [playerHands[0]];
for (let i = 1; i < playerHands.length; i++) {
const cmp = this.compareHands(playerHands[0].handResult, playerHands[i].handResult);
if (cmp === 0) {
winners.push(playerHands[i]);
} else {
break;
}
}
// Добавляем информацию о руках
for (const w of winners) {
w.player.handName = w.handResult.name;
}
this.endHand(winners.map(w => w.player), playerHands);
}
/**
* Сравнение двух рук
*/
compareHands(h1, h2) {
if (h1.rank !== h2.rank) return h2.rank - h1.rank;
for (let i = 0; i < h1.kickers.length; i++) {
if (h1.kickers[i] !== h2.kickers[i]) {
return h2.kickers[i] - h1.kickers[i];
}
}
return 0;
}
/**
* Завершить раздачу
*/
endHand(winners, allHands = null) {
const winAmount = Math.floor(this.pot / winners.length);
for (const winner of winners) {
winner.chips += winAmount;
}
// Остаток при нечётном сплите
const remainder = this.pot % winners.length;
if (remainder > 0) {
winners[0].chips += remainder;
}
this.gamePhase = 'showdown';
this.isGameStarted = false;
this.roundStarted = false;
return {
winners: winners.map(w => ({ id: w.id, name: w.name, amount: winAmount })),
pot: this.pot,
hands: allHands
};
}
/**
* Получить состояние комнаты для отправки клиентам
*/
getState(forPlayerId = null) {
return {
id: this.id,
name: this.name,
smallBlind: this.smallBlind,
bigBlind: this.bigBlind,
pot: this.pot,
communityCards: this.communityCards.map(c => ({ suit: c.suit, rank: c.rank })),
currentBet: this.currentBet,
gamePhase: this.gamePhase,
isGameStarted: this.isGameStarted,
currentPlayerIndex: this.currentPlayerIndex,
currentPlayerId: this.players[this.currentPlayerIndex]?.id,
minRaise: this.minRaise + this.currentBet,
players: this.players.map(p => {
const publicData = p.toPublicJSON();
// Показываем карты только владельцу или при шоудауне
if (p.id === forPlayerId || this.gamePhase === 'showdown') {
publicData.hand = p.hand.map(c => ({ suit: c.suit, rank: c.rank }));
}
return publicData;
})
};
}
}
// =============================================================================
// ОЦЕНКА РУК ПОКЕРА
// =============================================================================
/**
* Оценить покерную руку из 7 карт
*/
function evaluateHand(cards) {
const allCombinations = getCombinations(cards, 5);
let bestHand = null;
for (const combo of allCombinations) {
const hand = evaluateFiveCards(combo);
if (!bestHand || compareHandResults(hand, bestHand) > 0) {
bestHand = hand;
}
}
return bestHand;
}
/**
* Получить все комбинации из n элементов
*/
function getCombinations(arr, n) {
if (n === 0) return [[]];
if (arr.length === 0) return [];
const [first, ...rest] = arr;
const withFirst = getCombinations(rest, n - 1).map(c => [first, ...c]);
const withoutFirst = getCombinations(rest, n);
return [...withFirst, ...withoutFirst];
}
/**
* Оценить 5 карт
*/
function evaluateFiveCards(cards) {
const sortedCards = [...cards].sort((a, b) => b.value - a.value);
const values = sortedCards.map(c => c.value);
const suits = sortedCards.map(c => c.suit);
const isFlush = suits.every(s => s === suits[0]);
const isStraight = checkStraight(values);
const isWheel = values.join(',') === '14,5,4,3,2'; // A-2-3-4-5
const valueCounts = {};
for (const v of values) {
valueCounts[v] = (valueCounts[v] || 0) + 1;
}
const counts = Object.values(valueCounts).sort((a, b) => b - a);
const uniqueValues = Object.keys(valueCounts)
.map(Number)
.sort((a, b) => {
if (valueCounts[b] !== valueCounts[a]) {
return valueCounts[b] - valueCounts[a];
}
return b - a;
});
// Рояль-флеш
if (isFlush && isStraight && values[0] === 14) {
return { rank: 10, name: 'Роял-флеш', kickers: values };
}
// Стрит-флеш
if (isFlush && (isStraight || isWheel)) {
return { rank: 9, name: 'Стрит-флеш', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
}
// Каре
if (counts[0] === 4) {
return { rank: 8, name: 'Каре', kickers: uniqueValues };
}
// Фулл-хаус
if (counts[0] === 3 && counts[1] === 2) {
return { rank: 7, name: 'Фулл-хаус', kickers: uniqueValues };
}
// Флеш
if (isFlush) {
return { rank: 6, name: 'Флеш', kickers: values };
}
// Стрит
if (isStraight || isWheel) {
return { rank: 5, name: 'Стрит', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
}
// Сет
if (counts[0] === 3) {
return { rank: 4, name: 'Сет', kickers: uniqueValues };
}
// Две пары
if (counts[0] === 2 && counts[1] === 2) {
return { rank: 3, name: 'Две пары', kickers: uniqueValues };
}
// Пара
if (counts[0] === 2) {
return { rank: 2, name: 'Пара', kickers: uniqueValues };
}
// Старшая карта
return { rank: 1, name: 'Старшая карта', kickers: values };
}
/**
* Проверка на стрит
*/
function checkStraight(values) {
for (let i = 0; i < values.length - 1; i++) {
if (values[i] - values[i + 1] !== 1) {
return false;
}
}
return true;
}
/**
* Сравнение результатов рук
*/
function compareHandResults(h1, h2) {
if (h1.rank !== h2.rank) return h1.rank - h2.rank;
for (let i = 0; i < h1.kickers.length; i++) {
if (h1.kickers[i] !== h2.kickers[i]) {
return h1.kickers[i] - h2.kickers[i];
}
}
return 0;
}
// =============================================================================
// WEBSOCKET ОБРАБОТЧИКИ
// =============================================================================
wss.on('connection', (ws) => {
console.log('Новое подключение');
const connection = {
ws,
playerId: null,
roomId: null,
playerName: null
};
connections.set(ws, connection);
// Отправляем список комнат
sendRoomList(ws);
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
handleMessage(ws, message);
} catch (err) {
console.error('Ошибка обработки сообщения:', err);
}
});
ws.on('close', () => {
handleDisconnect(ws);
});
});
/**
* Обработка входящих сообщений
*/
function handleMessage(ws, message) {
const connection = connections.get(ws);
switch (message.type) {
case 'create_room':
handleCreateRoom(ws, message);
break;
case 'join_room':
handleJoinRoom(ws, message);
break;
case 'leave_room':
handleLeaveRoom(ws);
break;
case 'start_game':
handleStartGame(ws);
break;
case 'action':
handlePlayerAction(ws, message);
break;
case 'chat':
handleChat(ws, message);
break;
case 'get_rooms':
sendRoomList(ws);
break;
case 'new_hand':
handleNewHand(ws);
break;
}
}
/**
* Создание комнаты
*/
function handleCreateRoom(ws, message) {
const { roomName, playerName, smallBlind, bigBlind, maxPlayers } = message;
const connection = connections.get(ws);
const roomId = 'room_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const playerId = 'player_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const room = new Room(roomId, roomName || 'Новая комната', smallBlind || 5, bigBlind || 10, maxPlayers || 6);
const player = new Player(playerId, playerName || 'Игрок', 1000);
room.addPlayer(player);
rooms.set(roomId, room);
connection.playerId = playerId;
connection.roomId = roomId;
connection.playerName = player.name;
// Логируем создание комнаты
database.logAction(
connection.userId || playerId,
player.name,
'room_created',
{ roomId, roomName, smallBlind, bigBlind, maxPlayers },
roomId
);
// Отправляем подтверждение
ws.send(JSON.stringify({
type: 'room_joined',
roomId,
playerId,
room: room.getState(playerId)
}));
// Обновляем список комнат для всех
broadcastRoomList();
}
/**
* Присоединение к комнате
*/
function handleJoinRoom(ws, message) {
const { roomId, playerName } = message;
const connection = connections.get(ws);
const room = rooms.get(roomId);
if (!room) {
ws.send(JSON.stringify({ type: 'error', message: 'Комната не найдена' }));
return;
}
if (room.players.length >= room.maxPlayers) {
ws.send(JSON.stringify({ type: 'error', message: 'Комната полна' }));
return;
}
if (room.isGameStarted) {
ws.send(JSON.stringify({ type: 'error', message: 'Игра уже началась' }));
return;
}
const playerId = 'player_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const player = new Player(playerId, playerName || 'Игрок', 1000);
room.addPlayer(player);
connection.playerId = playerId;
connection.roomId = roomId;
connection.playerName = player.name;
// Отправляем подтверждение
ws.send(JSON.stringify({
type: 'room_joined',
roomId,
playerId,
room: room.getState(playerId)
}));
// Уведомляем остальных
broadcastToRoom(roomId, {
type: 'player_joined',
player: player.toPublicJSON(),
room: room.getState()
}, playerId);
broadcastRoomList();
}
/**
* Выход из комнаты
*/
function handleLeaveRoom(ws) {
const connection = connections.get(ws);
if (!connection.roomId) return;
const room = rooms.get(connection.roomId);
if (room) {
room.removePlayer(connection.playerId);
if (room.players.length === 0) {
rooms.delete(connection.roomId);
} else {
broadcastToRoom(connection.roomId, {
type: 'player_left',
playerId: connection.playerId,
room: room.getState()
});
}
}
connection.roomId = null;
connection.playerId = null;
broadcastRoomList();
}
/**
* Начать игру
*/
function handleStartGame(ws) {
const connection = connections.get(ws);
const room = rooms.get(connection.roomId);
if (!room) return;
if (room.players.length < 2) {
ws.send(JSON.stringify({ type: 'error', message: 'Нужно минимум 2 игрока' }));
return;
}
const success = room.startNewHand();
if (success) {
// Отправляем состояние каждому игроку (с его картами)
for (const player of room.players) {
const conn = findConnectionByPlayerId(player.id);
if (conn) {
conn.ws.send(JSON.stringify({
type: 'game_started',
room: room.getState(player.id)
}));
}
}
}
}
/**
* Новая раздача
*/
function handleNewHand(ws) {
const connection = connections.get(ws);
const room = rooms.get(connection.roomId);
if (!room || room.isGameStarted) return;
const success = room.startNewHand();
if (success) {
for (const player of room.players) {
const conn = findConnectionByPlayerId(player.id);
if (conn) {
conn.ws.send(JSON.stringify({
type: 'game_started',
room: room.getState(player.id)
}));
}
}
}
}
/**
* Обработка действия игрока
*/
function handlePlayerAction(ws, message) {
const connection = connections.get(ws);
const room = rooms.get(connection.roomId);
if (!room) return;
const result = room.processAction(connection.playerId, message.action, message.amount);
if (!result.success) {
ws.send(JSON.stringify({ type: 'error', message: result.error }));
return;
}
// Отправляем обновлённое состояние всем
for (const player of room.players) {
const conn = findConnectionByPlayerId(player.id);
if (conn) {
conn.ws.send(JSON.stringify({
type: 'game_update',
room: room.getState(player.id),
lastAction: {
playerId: connection.playerId,
action: message.action,
amount: message.amount
}
}));
}
}
}
/**
* Обработка чата
*/
function handleChat(ws, message) {
const connection = connections.get(ws);
const room = rooms.get(connection.roomId);
if (!room) return;
const chatMessage = {
type: 'chat',
playerId: connection.playerId,
playerName: connection.playerName,
message: message.message,
timestamp: Date.now()
};
room.messages.push(chatMessage);
// Сохраняем сообщение в БД
database.saveChatMessage(
connection.roomId,
'multiplayer',
connection.userId || connection.playerId,
connection.playerName,
message.message
);
// Логируем действие
database.logAction(
connection.userId || connection.playerId,
connection.playerName,
'chat_message',
{ message: message.message },
connection.roomId
);
broadcastToRoom(connection.roomId, chatMessage);
}
/**
* Обработка отключения
*/
function handleDisconnect(ws) {
const connection = connections.get(ws);
if (connection && connection.roomId) {
const room = rooms.get(connection.roomId);
if (room) {
const player = room.players.find(p => p.id === connection.playerId);
if (player) {
player.isConnected = false;
// Если игра идёт и это текущий игрок - автофолд
if (room.isGameStarted && room.players[room.currentPlayerIndex]?.id === connection.playerId) {
room.processAction(connection.playerId, 'fold');
}
broadcastToRoom(connection.roomId, {
type: 'player_disconnected',
playerId: connection.playerId,
room: room.getState()
});
}
// Удаляем игрока если игра не идёт
if (!room.isGameStarted) {
room.removePlayer(connection.playerId);
if (room.players.length === 0) {
rooms.delete(connection.roomId);
}
}
}
}
connections.delete(ws);
broadcastRoomList();
}
// =============================================================================
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// =============================================================================
/**
* Найти соединение по ID игрока
*/
function findConnectionByPlayerId(playerId) {
for (const [ws, conn] of connections) {
if (conn.playerId === playerId) {
return conn;
}
}
return null;
}
/**
* Отправить сообщение всем в комнате
*/
function broadcastToRoom(roomId, message, excludePlayerId = null) {
for (const [ws, conn] of connections) {
if (conn.roomId === roomId && conn.playerId !== excludePlayerId) {
ws.send(JSON.stringify(message));
}
}
}
/**
* Отправить список комнат
*/
function sendRoomList(ws) {
const roomList = Array.from(rooms.values()).map(room => ({
id: room.id,
name: room.name,
players: room.players.length,
maxPlayers: room.maxPlayers,
smallBlind: room.smallBlind,
bigBlind: room.bigBlind,
isGameStarted: room.isGameStarted
}));
ws.send(JSON.stringify({
type: 'room_list',
rooms: roomList
}));
}
/**
* Обновить список комнат для всех
*/
function broadcastRoomList() {
const roomList = Array.from(rooms.values()).map(room => ({
id: room.id,
name: room.name,
players: room.players.length,
maxPlayers: room.maxPlayers,
smallBlind: room.smallBlind,
bigBlind: room.bigBlind,
isGameStarted: room.isGameStarted
}));
for (const [ws, conn] of connections) {
if (!conn.roomId) {
ws.send(JSON.stringify({
type: 'room_list',
rooms: roomList
}));
}
}
}
// =============================================================================
// ЗАПУСК СЕРВЕРА
// =============================================================================
const PORT = process.env.PORT || 3000;
// Асинхронный запуск с инициализацией БД
(async () => {
try {
// Инициализируем базу данных
await database.initDatabase();
server.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════════════════════════════╗
║ 🃏 Texas Hold'em Poker Server 🃏 ║
║ ║
║ Сервер запущен на http://localhost:${PORT}
║ WebSocket: ws://localhost:${PORT}
║ База данных: SQLite (poker.db) ║
╚════════════════════════════════════════════════════════════════╝
`);
});
} catch (error) {
console.error('Ошибка запуска сервера:', error);
process.exit(1);
}
})();