1431 lines
43 KiB
JavaScript
1431 lines
43 KiB
JavaScript
/**
|
||
* =============================================================================
|
||
* 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: 'Введите логин и пароль' });
|
||
}
|
||
|
||
const result = database.loginUser(username, password);
|
||
|
||
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.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);
|
||
}
|
||
})();
|