bc/server/bc.js

393 lines
25 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.

// /server/bc.js - Главный файл сервера Battle Club
require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') });
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const APP_BASE_PATH = process.env.APP_BASE_PATH || "";
const jwt = require('jsonwebtoken');
const cors = require('cors');
// const cookieParser = require('cookie-parser'); // Раскомментируйте, если решите использовать куки для токена напрямую
const authService = require('./auth/authService');
const GameManager = require('./game/GameManager');
const db = require('./core/db'); // Используется для auth, не для игр в этом варианте
const GAME_CONFIG = require('./core/config');
const app = express();
const server = http.createServer(app);
// --- НАСТРОЙКА EXPRESS ---
console.log(`[BC.JS CONFIG] Reading environment variables for Express CORS...`);
console.log(`[BC.JS CONFIG] NODE_ENV: ${process.env.NODE_ENV}`);
console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_CLIENT: ${process.env.CORS_ORIGIN_CLIENT}`);
const clientOrigin = process.env.CORS_ORIGIN_CLIENT || (process.env.NODE_ENV === 'development' ? '*' : undefined);
console.log(`[BC.JS CONFIG] Effective clientOrigin for HTTP CORS: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET (CORS will likely fail if not development)'}`);
if (!clientOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) {
console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_CLIENT is not set for a non-development and non-undefined NODE_ENV. HTTP API requests from browsers might be blocked by CORS.");
}
app.use(cors({
origin: clientOrigin,
methods: ["GET", "POST"],
credentials: true // Важно для работы с куками, если они используются для токенов
}));
app.use(express.json());
// app.use(cookieParser()); // Раскомментируйте, если JWT будет передаваться через httpOnly cookie
const publicPath = path.join(__dirname, '..', 'public');
console.log(`[BC.JS CONFIG] Serving static files from: ${publicPath}`);
app.use(express.static(publicPath));
// --- НАСТРОЙКА EJS ---
app.set('view engine', 'ejs');
// Указываем, где лежат шаблоны. Папка 'views' рядом с bc.js (т.е. server/views)
app.set('views', path.join(__dirname, 'views'));
console.log(`[BC.JS CONFIG] EJS view engine configured. Views directory: ${app.get('views')}`);
// --- HTTP МАРШРУТЫ ---
// Главная страница, рендеринг EJS шаблона
app.get('/', (req, res) => {
// Попытка извлечь токен из localStorage (недоступно на сервере напрямую)
// или из cookie (если настроено).
// Для EJS на сервере нам нужно определить состояние пользователя ДО рендеринга.
// Это обычно делается через проверку сессии или токена в cookie.
// Так как ваш клиент хранит токен в localStorage, при первом GET запросе
// на сервер токен не будет доступен в заголовках или куках автоматически.
//
// Вариант 1: Клиент делает AJAX-запрос для проверки токена после загрузки,
// а сервер отдает базовый HTML, который потом обновляется. (Текущий подход с main.js)
//
// Вариант 2: Сервер отдает базовый HTML, и клиент сам решает, что показывать,
// основываясь на токене в localStorage. (Текущий подход с main.js)
//
// Вариант 3: Передавать токен в cookie (httpOnly для безопасности),
// тогда сервер сможет его читать при GET запросе.
//
// Для простоты демонстрации EJS, предположим, что мы хотим передать
// некоторую базовую информацию, а клиентская логика main.js все равно отработает.
// Мы не можем здесь напрямую прочитать localStorage клиента.
res.render('index', { // Рендерим server/views/index.ejs
title: 'Battle Club RPG', // Передаем заголовок страницы
base_path:APP_BASE_PATH,
// Можно передать базовую структуру HTML, а main.js заполнит остальное
// Либо, если бы токен был в куках, мы могли бы здесь сделать:
// const userData = authService.verifyTokenFromCookie(req.cookies.jwtToken);
// isLoggedIn: !!userData, loggedInUsername: userData ? userData.username : '', ...
});
});
// --- HTTP МАРШРУТЫ АУТЕНТИФИКАЦИИ ---
app.post('/auth/register', async (req, res) => {
const { username, password } = req.body;
console.log(`[BC HTTP /auth/register] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`);
if (!username || !password) {
console.warn('[BC HTTP /auth/register] Bad request: Username or password missing.');
return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' });
}
const result = await authService.registerUser(username, password);
if (result.success) {
console.log(`[BC HTTP /auth/register] Success for "${username}".`);
// Если вы используете куки для токена:
// res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' });
res.status(201).json(result);
} else {
console.warn(`[BC HTTP /auth/register] Failed for "${username}": ${result.message}`);
res.status(400).json(result);
}
});
app.post('/auth/login', async (req, res) => {
const { username, password } = req.body;
console.log(`[BC HTTP /auth/login] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`);
if (!username || !password) {
console.warn('[BC HTTP /auth/login] Bad request: Username or password missing.');
return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' });
}
const result = await authService.loginUser(username, password);
if (result.success) {
console.log(`[BC HTTP /auth/login] Success for "${username}".`);
// Если вы используете куки для токена:
// res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' });
res.json(result);
} else {
console.warn(`[BC HTTP /auth/login] Failed for "${username}": ${result.message}`);
res.status(401).json(result);
}
});
// --- НАСТРОЙКА SOCKET.IO ---
console.log(`[BC.JS CONFIG] Reading environment variables for Socket.IO CORS...`);
console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_SOCKET: ${process.env.CORS_ORIGIN_SOCKET}`);
const socketCorsOrigin = process.env.CORS_ORIGIN_SOCKET || (process.env.NODE_ENV === 'development' ? '*' : undefined);
console.log(`[BC.JS CONFIG] Effective socketCorsOrigin for Socket.IO CORS: ${socketCorsOrigin === '*' ? "'*'" : socketCorsOrigin || 'NOT SET (Socket.IO CORS will likely fail if not development)'}`);
if (!socketCorsOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) {
console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_SOCKET is not set for a non-development and non-undefined NODE_ENV. Socket.IO connections from browsers might be blocked by CORS.");
}
const io = new Server(server, {
path: '/socket.io/',
cors: {
origin: socketCorsOrigin,
methods: ["GET", "POST"],
credentials: true
},
});
console.log(`[BC.JS CONFIG] Socket.IO server configured with path: ${io.path()} and effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`);
const gameManager = new GameManager(io);
const loggedInUsersBySocketId = {}; // Этот объект используется только для логирования в bc.js, основная логика в GameManager
// --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO ---
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address;
const originHeader = socket.handshake.headers.origin;
const socketPath = socket.nsp.name;
console.log(`[BC Socket.IO Middleware] Auth attempt for socket ${socket.id} from IP ${clientIp}. Token ${token ? 'present' : 'absent'}. Origin: ${originHeader}. Path: ${socketPath}`);
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.userData = { userId: decoded.userId, username: decoded.username };
console.log(`[BC Socket.IO Middleware] Socket ${socket.id} authenticated for user ${decoded.username} (ID: ${decoded.userId}).`);
return next();
} catch (err) {
console.warn(`[BC Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}. Proceeding as unauthenticated.`);
// Не вызываем next(new Error(...)) чтобы не отключать сокет сразу,
// а позволить клиенту обработать это (например, показать экран логина).
// Однако, если бы мы хотели строго запретить неаутентифицированные сокеты:
// return next(new Error('Authentication error: Invalid token'));
}
} else {
console.log(`[BC Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`);
}
// Если токена нет или он невалиден, все равно вызываем next() без ошибки,
// чтобы соединение установилось, но socket.userData не будет установлен.
// Логика на стороне сервера должна будет проверять наличие socket.userData.
next();
});
// --- ОБРАБОТЧИКИ СОБЫТИЙ SOCKET.IO ---
io.on('connection', (socket) => {
const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address;
const originHeader = socket.handshake.headers.origin;
const socketPath = socket.nsp.name;
if (socket.userData && socket.userData.userId) {
console.log(`[BC Socket.IO Connection] Authenticated user ${socket.userData.username} (ID: ${socket.userData.userId}) connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}`);
loggedInUsersBySocketId[socket.id] = socket.userData; // Для логирования здесь
if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') {
console.log(`[BC Socket.IO Connection] Sending initial available PvP games list to authenticated user ${socket.userData.username} (Socket: ${socket.id})`);
const availableGames = gameManager.getAvailablePvPGamesListForClient();
socket.emit('availablePvPGamesList', availableGames);
} else {
console.error("[BC Socket.IO Connection] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found for sending initial list!");
}
if (gameManager && typeof gameManager.handleRequestGameState === 'function') {
gameManager.handleRequestGameState(socket, socket.userData.userId);
} else {
console.error("[BC Socket.IO Connection] CRITICAL: gameManager or handleRequestGameState not found for authenticated user!");
}
} else {
console.log(`[BC Socket.IO Connection] Unauthenticated user connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}.`);
// Неаутентифицированные пользователи не должны иметь доступа к игровым функциям,
// но могут получать базовую информацию, если это предусмотрено.
// Например, список игр, если он публичный (в данном проекте он для залогиненных).
// socket.emit('authRequired', { message: 'Please login to access game features.' }); // Можно отправить такое сообщение
}
socket.on('logout', () => { // Это событие инициируется клиентом, когда он нажимает "Выйти"
const username = socket.userData?.username || 'UnknownUserOnLogout';
const userId = socket.userData?.userId;
console.log(`[BC Socket.IO 'logout' event] User: ${username} (ID: ${userId || 'N/A'}, Socket: ${socket.id}). Performing server-side cleanup for logout.`);
// Здесь важно не просто удалить данные из loggedInUsersBySocketId (это локальный объект для логов),
// а также убедиться, что GameManager корректно обрабатывает выход игрока из игры, если он там был.
// GameManager.handleDisconnect должен вызываться автоматически при socket.disconnect() со стороны клиента.
// Дополнительно, если logout это не просто disconnect, а явное действие:
if (userId && gameManager) {
// Если игрок был в игре, GameManager.handleDisconnect должен был отработать при последующем socket.disconnect().
// Если нужно специфическое действие для logout перед disconnect:
// gameManager.handleExplicitLogout(userId, socket.id); // (потребовало бы добавить такой метод в GameManager)
}
if (loggedInUsersBySocketId[socket.id]) {
delete loggedInUsersBySocketId[socket.id];
}
socket.userData = null; // Очищаем данные пользователя на сокете
// Клиент сам вызовет socket.disconnect() и socket.connect() с новым (null) токеном.
console.log(`[BC Socket.IO 'logout' event] Session data for socket ${socket.id} cleared on server. Client is expected to disconnect and reconnect.`);
});
socket.on('playerSurrender', () => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'playerSurrender'] Denied for unauthenticated socket ${socket.id}.`);
socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы сдаться в игре.' });
return;
}
const identifier = socket.userData.userId;
const username = socket.userData.username;
console.log(`[BC Socket.IO 'playerSurrender'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`);
if (gameManager && typeof gameManager.handlePlayerSurrender === 'function') {
gameManager.handlePlayerSurrender(identifier);
} else {
console.error("[BC Socket.IO 'playerSurrender'] CRITICAL: gameManager or handlePlayerSurrender method not found!");
socket.emit('gameError', { message: 'Ошибка сервера при обработке сдачи игры.' });
}
});
socket.on('leaveAiGame', () => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'leaveAiGame'] Denied for unauthenticated socket ${socket.id}.`);
socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы покинуть AI игру.' });
return;
}
const identifier = socket.userData.userId;
const username = socket.userData.username;
console.log(`[BC Socket.IO 'leaveAiGame'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`);
if (gameManager && typeof gameManager.handleLeaveAiGame === 'function') {
gameManager.handleLeaveAiGame(identifier, socket); // Передаем сокет, если он нужен для ответа
} else {
console.error("[BC Socket.IO 'leaveAiGame'] CRITICAL: gameManager or handleLeaveAiGame method not found!");
socket.emit('gameError', { message: 'Ошибка сервера при выходе из AI игры.' });
}
});
socket.on('createGame', (data) => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'createGame'] Denied for unauthenticated socket ${socket.id}.`);
socket.emit('gameError', { message: 'Необходимо войти в систему для создания игры.' });
return;
}
const identifier = socket.userData.userId;
const mode = data?.mode || 'ai';
const charKey = data?.characterKey;
console.log(`[BC Socket.IO 'createGame'] Request from ${socket.userData.username} (ID: ${identifier}). Mode: ${mode}, Char: ${charKey}`);
gameManager.createGame(socket, mode, charKey, identifier);
});
socket.on('joinGame', (data) => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'joinGame'] Denied for unauthenticated socket ${socket.id}.`);
socket.emit('gameError', { message: 'Необходимо войти для присоединения к PvP игре.' });
return;
}
const gameId = data?.gameId;
const userId = socket.userData.userId; // Используем userId из socket.userData
const charKey = data?.characterKey; // Клиент может предлагать персонажа при присоединении
console.log(`[BC Socket.IO 'joinGame'] Request from ${socket.userData.username} (ID: ${userId}). GameID: ${gameId}, Char: ${charKey}`);
gameManager.joinGame(socket, gameId, userId, charKey);
});
socket.on('findRandomGame', (data) => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'findRandomGame'] Denied for unauthenticated socket ${socket.id}.`);
socket.emit('gameError', { message: 'Необходимо войти для поиска случайной PvP игры.' });
return;
}
const userId = socket.userData.userId;
const charKey = data?.characterKey;
console.log(`[BC Socket.IO 'findRandomGame'] Request from ${socket.userData.username} (ID: ${userId}). PrefChar: ${charKey}`);
gameManager.findAndJoinRandomPvPGame(socket, charKey, userId);
});
socket.on('requestPvPGameList', () => {
// Этот запрос может приходить и от неаутентифицированных, если дизайн это позволяет.
// В текущей логике GameManager, список игр формируется для залогиненных.
// Если не залогинен, можно отправить пустой список или специальное сообщение.
console.log(`[BC Socket.IO 'requestPvPGameList'] Request from socket ${socket.id} (User: ${socket.userData?.username || 'Unauth'}).`);
if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') {
const availableGames = gameManager.getAvailablePvPGamesListForClient(); // GameManager сам решит, что вернуть
socket.emit('availablePvPGamesList', availableGames);
} else {
console.error("[BC Socket.IO 'requestPvPGameList'] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found!");
socket.emit('availablePvPGamesList', []);
}
});
socket.on('requestGameState', () => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'requestGameState'] Denied for unauthenticated socket ${socket.id}.`);
// Важно! Клиент main.js ожидает gameNotFound, чтобы показать экран логина
socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' });
return;
}
const userId = socket.userData.userId;
console.log(`[BC Socket.IO 'requestGameState'] Request from ${socket.userData.username} (ID: ${userId}, Socket: ${socket.id})`);
gameManager.handleRequestGameState(socket, userId);
});
socket.on('playerAction', (actionData) => {
if (!socket.userData?.userId) {
console.warn(`[BC Socket.IO 'playerAction'] Denied for unauthenticated socket ${socket.id}. Action: ${actionData?.actionType}`);
socket.emit('gameError', { message: 'Действие не разрешено: пользователь не аутентифицирован.' });
return;
}
const identifier = socket.userData.userId;
console.log(`[BC Socket.IO 'playerAction'] Action from ${socket.userData.username} (ID: ${identifier}). Type: ${actionData?.actionType}, Details: ${JSON.stringify(actionData)}`);
gameManager.handlePlayerAction(identifier, actionData);
});
socket.on('disconnect', (reason) => {
const identifier = socket.userData?.userId; // Получаем из socket.userData, если был аутентифицирован
const username = socket.userData?.username || loggedInUsersBySocketId[socket.id]?.username || 'UnauthenticatedOrUnknown';
console.log(`[BC Socket.IO Disconnect] User ${username} (ID: ${identifier || 'N/A'}, Socket: ${socket.id}) disconnected. Reason: ${reason}.`);
if (identifier && gameManager) { // Если пользователь был аутентифицирован
gameManager.handleDisconnect(socket.id, identifier);
}
if (loggedInUsersBySocketId[socket.id]) { // Очистка из локального объекта для логов
delete loggedInUsersBySocketId[socket.id];
}
// socket.userData автоматически очищается при дисконнекте самого объекта сокета
});
});
// --- ЗАПУСК СЕРВЕРА ---
const PORT = parseInt(process.env.BC_APP_PORT || '3200', 10);
const HOSTNAME = process.env.BC_APP_HOSTNAME || '127.0.0.1';
if (isNaN(PORT)) {
console.error(`[BC Server FATAL] Invalid BC_APP_PORT: "${process.env.BC_APP_PORT}". Expected a number.`);
process.exit(1);
}
server.listen(PORT, HOSTNAME, () => {
console.log(`[BC Server Startup] Battle Club HTTP Application Server running at http://${HOSTNAME}:${PORT}`);
if (HOSTNAME === '127.0.0.1') {
console.log(`[BC Server Startup] Server is listening on localhost only.`);
} else if (HOSTNAME === '0.0.0.0') {
console.log(`[BC Server Startup] Server is listening on all available network interfaces.`);
} else {
console.log(`[BC Server Startup] Server is listening on a specific interface: ${HOSTNAME}.`);
}
console.log(`[BC Server Startup] Static files served from: ${publicPath}`);
console.log(`[BC.JS Startup] EJS views directory: ${app.get('views')}`);
console.log(`[BC.JS Startup] Socket.IO server effective path: ${io.path()}`);
console.log(`[BC.JS Startup] HTTP API effective CORS origin: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET'}`);
console.log(`[BC.JS Startup] Socket.IO effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[BC Server FATAL UnhandledRejection] Reason:', reason, 'Promise:', promise);
// process.exit(1); // Можно раскомментировать для падения сервера при неперехваченных промисах
});
process.on('uncaughtException', (err) => {
console.error('[BC Server FATAL UncaughtException] Error:', err);
process.exit(1); // Критические ошибки должны приводить к перезапуску через process manager
});