// /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 });