diff --git a/public/js/auth.js b/public/js/auth.js index 5be2017..61f9cca 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -5,9 +5,13 @@ export function initAuth(dependencies) { const { socket, clientState, ui } = dependencies; const { loginForm, registerForm, logoutButton } = ui.elements; // Получаем нужные DOM элементы - // URL вашего API сервера. Лучше вынести в конфигурацию или передавать. - // Для примера захардкодим, но в main.js можно будет это улучшить. - const API_BASE_URL = dependencies.API_BASE_URL || 'http://127.0.0.1:3200'; // Убедитесь, что это ваш URL + // URL вашего API сервера. В данной версии main.js не передает API_BASE_URL, + // предполагая, что fetch будет использовать относительные пути к текущему домену. + // Если ваш main.js снова будет передавать API_BASE_URL, раскомментируйте и используйте его. + // const API_BASE_URL = dependencies.API_BASE_URL || ''; // Пустая строка заставит fetch использовать относительные пути + // Если API на другом домене, API_BASE_URL обязателен. Для относительных путей: + const getApiUrl = (path) => `${window.location.origin}${path}`; + // Название ключа для хранения JWT в localStorage const JWT_TOKEN_KEY = 'jwtToken'; @@ -32,11 +36,12 @@ export function initAuth(dependencies) { // Важно: переподключить сокет с новым токеном if (socket.connected) { - socket.disconnect(); + socket.disconnect(); // Отключаемся, чтобы при следующем connect отправился новый токен } // Обновляем auth объект сокета перед подключением - // В main.js при создании сокета, он должен уже брать токен из localStorage - // Но если сокет уже существует, нужно обновить его auth данные + // socket.io клиент автоматически подхватит новый токен из localStorage при следующем .connect(), + // если он был инициализирован с auth: () => { token: localStorage.getItem(...) } + // или мы можем явно установить его здесь: socket.auth = { token: data.token }; socket.connect(); // Это вызовет 'connect' и 'requestGameState' в main.js @@ -60,7 +65,7 @@ export function initAuth(dependencies) { // Разблокируем кнопки в любом случае if (regButton) regButton.disabled = false; if (loginButton) loginButton.disabled = false; - if (logoutButton && clientState.isLoggedIn) logoutButton.disabled = false; + // Кнопка logout управляется состоянием isLoggedIn и видимостью экрана } } @@ -77,23 +82,27 @@ export function initAuth(dependencies) { const password = passwordInput.value; const regButton = registerForm.querySelector('button'); - const loginButton = loginForm ? loginForm.querySelector('button') : null; + const loginButton = loginForm ? loginForm.querySelector('button') : null; // Может быть null, если форма логина на другой странице if (regButton) regButton.disabled = true; if (loginButton) loginButton.disabled = true; ui.setAuthMessage('Регистрация...'); try { - const response = await fetch(`${API_BASE_URL}/auth/register`, { + // Используем относительный путь, если API_BASE_URL не задан или пуст + const response = await fetch(getApiUrl('/auth/register'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); await handleAuthResponse(response, 'register'); - if (response.ok && registerForm) registerForm.reset(); // Очищаем форму при успехе + if (response.ok && clientState.isLoggedIn && registerForm) { // Проверяем clientState.isLoggedIn для очистки + registerForm.reset(); // Очищаем форму при успехе + } } catch (error) { console.error('[Auth] Network error during registration:', error); ui.setAuthMessage('Ошибка сети при регистрации. Пожалуйста, проверьте ваше подключение.', true); + // Разблокируем кнопки при ошибке сети, т.к. finally в handleAuthResponse может не сработать if (regButton) regButton.disabled = false; if (loginButton) loginButton.disabled = false; } @@ -118,12 +127,14 @@ export function initAuth(dependencies) { ui.setAuthMessage('Вход...'); try { - const response = await fetch(`${API_BASE_URL}/auth/login`, { + // Используем относительный путь + const response = await fetch(getApiUrl('/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); await handleAuthResponse(response, 'login'); + // Форма логина обычно не сбрасывается или перенаправляется } catch (error) { console.error('[Auth] Network error during login:', error); ui.setAuthMessage('Ошибка сети при входе. Пожалуйста, проверьте ваше подключение.', true); @@ -135,76 +146,67 @@ export function initAuth(dependencies) { if (logoutButton) { logoutButton.addEventListener('click', () => { - logoutButton.disabled = true; + logoutButton.disabled = true; // Блокируем кнопку сразу - // --- НАЧАЛО ИЗМЕНЕНИЯ --- - // Если игрок в активной PvP игре, отправляем сигнал о сдаче - if (clientState.isLoggedIn && - clientState.isInGame && - clientState.currentGameId && - clientState.currentGameState && // Убедимся, что gameState существует - clientState.currentGameState.gameMode === 'pvp' && // Проверяем режим игры - !clientState.currentGameState.isGameOver) { // Только если игра еще не закончена - - console.log('[Auth] Player is in an active PvP game. Emitting playerSurrender.'); - socket.emit('playerSurrender'); - // Не ждем ответа от сервера здесь, так как logout - это безусловное действие на клиенте. - // Сервер обработает 'playerSurrender' и соответствующим образом завершит игру. + // Проверяем, находится ли игрок в активной игре + if (clientState.isLoggedIn && clientState.isInGame && clientState.currentGameId) { + // Если это PvP игра и она не закончена + if (clientState.currentGameState && + clientState.currentGameState.gameMode === 'pvp' && + !clientState.currentGameState.isGameOver) { + console.log('[Auth] Player is in an active PvP game. Emitting playerSurrender.'); + socket.emit('playerSurrender'); + // Не ждем ответа от сервера здесь, так как logout - это безусловное действие на клиенте. + } + // --- НАЧАЛО ИЗМЕНЕНИЯ ДЛЯ ВАРИАНТА А --- + else if (clientState.currentGameState && + clientState.currentGameState.gameMode === 'ai' && + !clientState.currentGameState.isGameOver) { + console.log('[Auth] Player is in an active AI game. Emitting leaveAiGame.'); + socket.emit('leaveAiGame'); + // Сервер должен обработать это и завершить AI игру. + } + // --- КОНЕЦ ИЗМЕНЕНИЯ ДЛЯ ВАРИАНТА А --- } - // --- КОНЕЦ ИЗМЕНЕНИЯ --- - // Серверный эндпоинт для логаута не обязателен для JWT, // если нет необходимости аннулировать токен на сервере (что сложно с JWT). // Основное действие - удаление токена на клиенте. - // socket.emit('logout'); // Можно оставить, если на сервере есть логика для этого (например, GameManager.handleDisconnect) + // socket.emit('logout'); // Клиент сам инициирует разрыв и новое подключение без токена. + // Это событие из bc.js скорее для очистки серверной сессии сокета. localStorage.removeItem(JWT_TOKEN_KEY); // Удаляем токен + // Сбрасываем состояние клиента clientState.isLoggedIn = false; clientState.loggedInUsername = ''; clientState.myUserId = null; - // isInGame и другие игровые переменные сбросятся в ui.showAuthScreen() - // ui.disableGameControls() также будет вызван опосредованно + // clientState.isInGame и другие игровые переменные будут сброшены в ui.showAuthScreen() + // или ui.resetGameVariables() если вызывается напрямую. - ui.showAuthScreen(); // Показываем экран логина - ui.setGameStatusMessage("Вы вышли из системы."); // Можно заменить на ui.setAuthMessage, если хотим видеть сообщение на экране логина + ui.showAuthScreen(); // Показываем экран логина (это вызовет resetGameVariables) + ui.setAuthMessage("Вы успешно вышли из системы."); // Сообщение на экране логина - // Переподключаем сокет без токена + // Переподключаем сокет без токена (или он сам переподключится при следующем действии) if (socket.connected) { - socket.disconnect(); + socket.disconnect(); // Принудительно отключаемся } socket.auth = { token: null }; // Очищаем токен в auth объекте сокета - socket.connect(); // Сокет подключится как неаутентифицированный + socket.connect(); // Сокет подключится как неаутентифицированный (или main.js инициирует) + // Фактически, connect() будет вызван из main.js при переходе на authScreen + // и проверке состояния. Здесь главное - очистить токен. + // Но явный connect() после disconnect() более предсказуем. - // Кнопка logout будет активирована, когда пользователь снова войдет - // или если она видна только залогиненным пользователям, то исчезнет. - // (В showAuthScreen logoutButton.disabled устанавливается в true) + // Кнопка logoutButton.disabled = true; уже была установлена в showAuthScreen() }); } // --- Обработчики событий Socket.IO --- - // Старые 'registerResponse' и 'loginResponse' больше не нужны, + // Старые 'registerResponse' и 'loginResponse' (если были через сокет) больше не нужны, // так как эти ответы приходят через HTTP. - // Можно добавить обработчик для принудительного разлогинивания от сервера, если такой будет - // socket.on('forceLogout', (data) => { - // console.log('[Auth] Forced logout by server:', data.message); - // localStorage.removeItem(JWT_TOKEN_KEY); - // clientState.isLoggedIn = false; - // clientState.loggedInUsername = ''; - // clientState.myUserId = null; - // ui.showAuthScreen(); - // ui.setAuthMessage(data.message || "Вы были разлогинены сервером."); - // if (socket.connected) socket.disconnect(); - // socket.auth = { token: null }; - // socket.connect(); - // }); - - // При загрузке модуля auth.js, проверяем, нет ли уже токена в localStorage - // Эта логика лучше всего будет работать в main.js при инициализации сокета, - // но здесь можно было бы сделать предварительную проверку и обновление clientState, - // если бы это было необходимо до создания сокета. - // Однако, поскольку сокет создается в main.js и сразу использует токен из localStorage, - // отдельная логика здесь не так критична. + // Логика проверки токена при загрузке страницы (если токен есть в localStorage) + // обычно выполняется в main.js до инициализации сокета. + // Здесь мы предполагаем, что main.js уже подготовил clientState + // на основе существующего токена или оставил его пустым. } \ No newline at end of file diff --git a/server/bc.js b/server/bc.js index 725586c..f5143ba 100644 --- a/server/bc.js +++ b/server/bc.js @@ -18,22 +18,19 @@ const app = express(); const server = http.createServer(app); // --- НАСТРОЙКА EXPRESS --- -console.log(`[BC.JS CONFIG] Reading environment variables...`); +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); -// Этот лог покажет, какое значение clientOrigin будет использовано для HTTP CORS 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) { // Добавил проверку на undefined NODE_ENV +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 // Важно, если клиент шлет куки или заголовок Authorization + credentials: true })); app.use(express.json()); @@ -41,12 +38,10 @@ const publicPath = path.join(__dirname, '..', 'public'); console.log(`[BC.JS CONFIG] Serving static files from: ${publicPath}`); app.use(express.static(publicPath)); - // --- HTTP МАРШРУТЫ АУТЕНТИФИКАЦИИ --- app.post('/auth/register', async (req, res) => { const { username, password } = req.body; - // Логируем входящий Origin для этого запроса - console.log(`[BC HTTP /auth/register] Attempt for username: "${username}" from IP: ${req.ip}. Origin header: ${req.headers.origin}`); + 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: 'Имя пользователя и пароль обязательны.' }); @@ -63,8 +58,7 @@ app.post('/auth/register', async (req, res) => { app.post('/auth/login', async (req, res) => { const { username, password } = req.body; - // Логируем входящий Origin для этого запроса - console.log(`[BC HTTP /auth/login] Attempt for username: "${username}" from IP: ${req.ip}. Origin header: ${req.headers.origin}`); + 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: 'Имя пользователя и пароль обязательны.' }); @@ -80,17 +74,17 @@ app.post('/auth/login', async (req, res) => { }); // --- НАСТРОЙКА 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); -// Этот лог покажет, какое значение socketCorsOrigin будет использовано для Socket.IO CORS 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) { // Добавил проверку на undefined NODE_ENV +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/', // Убедитесь, что это соответствует настройкам клиента и прокси + path: '/socket.io/', // Убедитесь, что это соответствует клиенту и прокси (stripPrefix: false для /socket.io) cors: { origin: socketCorsOrigin, methods: ["GET", "POST"], @@ -99,14 +93,12 @@ const io = new Server(server, { }); 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 loggedInUsers = {}; +const loggedInUsersBySocketId = {}; // Хранилище для данных пользователя по ID сокета // --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO --- io.use(async (socket, next) => { const token = socket.handshake.auth.token; - // Пытаемся получить IP клиента, учитывая возможные заголовки от прокси 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; @@ -116,16 +108,17 @@ io.use(async (socket, next) => { if (token) { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); - socket.userData = { userId: decoded.userId, username: decoded.username }; + 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}`); + console.warn(`[BC Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}. Proceeding as unauthenticated.`); + // Не прерываем соединение, но userData не будет установлен } } else { console.log(`[BC Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`); } - next(); + next(); // Разрешаем подключение даже неаутентифицированным }); // --- ОБРАБОТЧИКИ СОБЫТИЙ SOCKET.IO --- @@ -136,26 +129,29 @@ io.on('connection', (socket) => { 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}`); - loggedInUsers[socket.id] = socket.userData; + loggedInUsersBySocketId[socket.id] = socket.userData; // Сохраняем данные пользователя, связанные с этим сокетом + if (gameManager && typeof gameManager.handleRequestGameState === 'function') { gameManager.handleRequestGameState(socket, socket.userData.userId); } else { - console.error("[BC Socket.IO Connection] CRITICAL: gameManager or handleRequestGameState not available for authenticated user!"); + 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}. No game state will be restored.`); + console.log(`[BC Socket.IO Connection] Unauthenticated user connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}.`); } - // ... (остальные обработчики событий: logout, playerSurrender, createGame, и т.д. как в предыдущей версии) ... - socket.on('logout', () => { - const username = socket.userData?.username || 'UnknownUser'; + socket.on('logout', () => { // Инициируется клиентом ПЕРЕД разрывом соединения и удалением токена + const username = socket.userData?.username || 'UnknownUserOnLogout'; const userId = socket.userData?.userId; - console.log(`[BC Socket.IO 'logout'] Event from user ${username} (ID: ${userId}, Socket: ${socket.id})`); - if (loggedInUsers[socket.id]) { - delete loggedInUsers[socket.id]; + console.log(`[BC Socket.IO 'logout' event] User: ${username} (ID: ${userId || 'N/A'}, Socket: ${socket.id}).`); + // Логика GameManager.handleDisconnect будет вызвана при фактическом 'disconnect' событии. + // Здесь мы просто очищаем данные, связанные с этим сокетом на сервере, + // так как клиент собирается разорвать соединение или переподключиться без токена. + if (loggedInUsersBySocketId[socket.id]) { + delete loggedInUsersBySocketId[socket.id]; } - socket.userData = null; - console.log(`[BC Socket.IO 'logout'] User ${username} (Socket: ${socket.id}) session data cleared.`); + socket.userData = null; // Очищаем данные на самом объекте сокета + console.log(`[BC Socket.IO 'logout' event] Session data for socket ${socket.id} cleared on server.`); }); socket.on('playerSurrender', () => { @@ -175,6 +171,28 @@ io.on('connection', (socket) => { } }); + // --- НАЧАЛО ИЗМЕНЕНИЯ: ОБРАБОТЧИК ДЛЯ ВЫХОДА ИЗ AI-ИГРЫ --- + 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); + // Ответ клиенту не требуется, т.к. он и так выходит и переходит на другой экран. + // GameManager._cleanupGame будет вызван изнутри handleLeaveAiGame через GameInstance. + } 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}.`); @@ -189,6 +207,7 @@ io.on('connection', (socket) => { }); 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 игре.' }); @@ -201,6 +220,7 @@ io.on('connection', (socket) => { }); 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 игры.' }); @@ -213,12 +233,14 @@ io.on('connection', (socket) => { }); socket.on('requestPvPGameList', () => { + // ... (код без изменений) console.log(`[BC Socket.IO 'requestPvPGameList'] Request from socket ${socket.id} (User: ${socket.userData?.username || 'Unauth'}).`); const availableGames = gameManager.getAvailablePvPGamesListForClient(); socket.emit('availablePvPGamesList', availableGames); }); socket.on('requestGameState', () => { + // ... (код без изменений) if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'requestGameState'] Denied for unauthenticated socket ${socket.id}.`); socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' }); @@ -230,6 +252,7 @@ io.on('connection', (socket) => { }); 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: 'Действие не разрешено: пользователь не аутентифицирован.' }); @@ -241,15 +264,17 @@ io.on('connection', (socket) => { }); socket.on('disconnect', (reason) => { - const identifier = socket.userData?.userId; - const username = socket.userData?.username || 'UnauthenticatedUser'; + const identifier = socket.userData?.userId; // Берем из 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.handleDisconnect(socket.id, identifier); } - if (loggedInUsers[socket.id]) { - delete loggedInUsers[socket.id]; + if (loggedInUsersBySocketId[socket.id]) { + delete loggedInUsersBySocketId[socket.id]; } + // socket.userData автоматически очистится при уничтожении объекта сокета }); }); @@ -273,7 +298,7 @@ server.listen(PORT, HOSTNAME, () => { } console.log(`[BC Server Startup] Static files served from: ${publicPath}`); console.log(`[BC.JS Startup] Socket.IO server effective path: ${io.path()}`); - console.log(`[BC.JS Startup] HTTP API effective CORS origin: ${app.get('env') === 'development' && !process.env.CORS_ORIGIN_CLIENT ? "'*'" : clientOrigin || 'NOT SET'}`); // Это немного упрощенный вывод, точнее в логах CONFIG + 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'}`); }); @@ -283,5 +308,6 @@ process.on('unhandledRejection', (reason, promise) => { process.on('uncaughtException', (err) => { console.error('[BC Server FATAL UncaughtException] Error:', err); + // В продакшене PM2 или другой менеджер процессов должен перезапустить приложение process.exit(1); }); \ No newline at end of file diff --git a/server/game/GameManager.js b/server/game/GameManager.js index 31402b5..e066e3d 100644 --- a/server/game/GameManager.js +++ b/server/game/GameManager.js @@ -7,444 +7,370 @@ const GAME_CONFIG = require('../core/config'); class GameManager { constructor(io) { this.io = io; - this.games = {}; // Активные инстансы игр { gameId: GameInstance } + this.games = {}; // { gameId: GameInstance } this.userIdentifierToGameId = {}; // { userId: gameId } - this.pendingPvPGames = []; // Массив gameId игр, ожидающих второго игрока + this.pendingPvPGames = []; // Массив gameId ожидающих PvP игр console.log("[GameManager] Initialized."); } _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { - console.log(`[GameManager._removePreviousPendingGames] Called for user: ${identifier}, currentSocket: ${currentSocketId}, excludeGameId: ${excludeGameId}`); + console.log(`[GameManager._removePreviousPendingGames] User: ${identifier}, Socket: ${currentSocketId}, Exclude: ${excludeGameId}`); const oldPendingGameId = this.userIdentifierToGameId[identifier]; + if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) { const gameToRemove = this.games[oldPendingGameId]; - if (gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { - const playerInfo = Object.values(gameToRemove.players).find(p => p.identifier === identifier); - if (playerInfo && playerInfo.id === GAME_CONFIG.PLAYER_ID) { - console.log(`[GameManager._removePreviousPendingGames] User ${identifier} (socket: ${currentSocketId}) created/joined new game. Removing their previous owned pending PvP game: ${oldPendingGameId}`); - this._cleanupGame(oldPendingGameId, 'replaced_by_new_game_creation_or_join'); - } else { - console.log(`[GameManager._removePreviousPendingGames] User ${identifier} had pending game ${oldPendingGameId}, but was not the primary player. Not removing.`); - } + // Используем game.playerCount (или аналогичный метод GameInstance, если он инкапсулирует это) + if (gameToRemove.mode === 'pvp' && + gameToRemove.playerCount === 1 && // Предполагаем, GameInstance.playerCount - это активные игроки + gameToRemove.ownerIdentifier === identifier && + this.pendingPvPGames.includes(oldPendingGameId)) { + console.log(`[GameManager._removePreviousPendingGames] User ${identifier} creating/joining new. Removing previous pending PvP game: ${oldPendingGameId}`); + this._cleanupGame(oldPendingGameId, 'owner_action_removed_pending_game'); } - } else { - console.log(`[GameManager._removePreviousPendingGames] No old pending game found for user ${identifier} or conditions not met.`); } } - createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) { - console.log(`[GameManager.createGame] User: ${identifier} (Socket: ${socket.id}), Mode: ${mode}, Char: ${chosenCharacterKey}`); - if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { - const existingGame = this.games[this.userIdentifierToGameId[identifier]]; - console.warn(`[GameManager.createGame] User ${identifier} already in game ${this.userIdentifierToGameId[identifier]}. Mode: ${existingGame.mode}, Players: ${existingGame.playerCount}, Owner: ${existingGame.ownerIdentifier}, GameOver: ${existingGame.gameState?.isGameOver}`); + createGame(socket, mode = 'ai', chosenCharacterKey = null, identifier) { + console.log(`[GameManager.createGame] User: ${identifier} (Socket: ${socket.id}), Mode: ${mode}, Char: ${chosenCharacterKey || 'Default'}`); - // Если игра существует и НЕ завершена - if (!existingGame.gameState?.isGameOver) { + const existingGameId = this.userIdentifierToGameId[identifier]; + if (existingGameId && this.games[existingGameId]) { + const existingGame = this.games[existingGameId]; + // Используем game.playerCount + console.warn(`[GameManager.createGame] User ${identifier} already in game ${existingGameId}. Mode: ${existingGame.mode}, Players: ${existingGame.playerCount}, Owner: ${existingGame.ownerIdentifier}, GameOver: ${existingGame.gameState?.isGameOver}`); + + if (existingGame.gameState && !existingGame.gameState.isGameOver) { if (existingGame.mode === 'pvp' && existingGame.playerCount === 1 && existingGame.ownerIdentifier === identifier) { socket.emit('gameError', { message: 'Вы уже создали PvP игру и ожидаете оппонента.' }); } else { socket.emit('gameError', { message: 'Вы уже находитесь в активной игре.' }); } - this.handleRequestGameState(socket, identifier); // Попытка восстановить сессию в существующей игре + this.handleRequestGameState(socket, identifier); return; } else { - // Игра существует, но завершена. GameManager должен был ее очистить. - // Если мы здесь, значит, что-то пошло не так с очисткой. - console.warn(`[GameManager.createGame] User ${identifier} was mapped to an already finished game ${this.userIdentifierToGameId[identifier]}. Cleaning up stale entry before creating new game.`); - this._cleanupGame(this.userIdentifierToGameId[identifier], `stale_finished_game_on_create_for_${identifier}`); - // this.userIdentifierToGameId[identifier] будет удален в _cleanupGame + this._cleanupGame(existingGameId, `stale_finished_on_create_${identifier}`); } } this._removePreviousPendingGames(socket.id, identifier); const gameId = uuidv4(); - console.log(`[GameManager.createGame] Generated new GameID: ${gameId}`); + console.log(`[GameManager.createGame] New GameID: ${gameId}`); const game = new GameInstance(gameId, this.io, mode, this); this.games[gameId] = game; - const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; - if (game.addPlayer(socket, charKeyForInstance, identifier)) { + const charKeyForPlayer = mode === 'ai' ? (chosenCharacterKey || 'elena') : (chosenCharacterKey || 'elena'); + + // addPlayer в GameInstance теперь bool, а не объект с результатом + if (game.addPlayer(socket, charKeyForPlayer, identifier)) { this.userIdentifierToGameId[identifier] = gameId; - console.log(`[GameManager.createGame] Player ${identifier} added to game ${gameId}. User map updated: userIdentifierToGameId[${identifier}] = ${this.userIdentifierToGameId[identifier]}`); - const assignedPlayerId = Object.values(game.players).find(p=>p.identifier === identifier)?.id; + // Получаем роль и актуальный ключ из GameInstance после добавления + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + const assignedPlayerId = playerInfo?.id; + const actualCharacterKey = playerInfo?.chosenCharacterKey; - if (!assignedPlayerId) { - console.error(`[GameManager.createGame] CRITICAL: Failed to assign player role for user ${identifier} in game ${gameId}.`); - this._cleanupGame(gameId, 'player_add_failed_no_role_assigned'); - socket.emit('gameError', { message: 'Ошибка сервера при создании игры (не удалось присвоить роль).' }); + if (!assignedPlayerId || !actualCharacterKey) { + console.error(`[GameManager.createGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} in game ${gameId}. Cleaning up.`); + this._cleanupGame(gameId, 'player_info_missing_after_add'); + socket.emit('gameError', { message: 'Ошибка сервера при создании роли в игре.' }); return; } - socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); - console.log(`[GameManager.createGame] Emitted 'gameCreated' to ${identifier}. gameId: ${gameId}, yourPlayerId: ${assignedPlayerId}`); + + console.log(`[GameManager.createGame] Player ${identifier} added to game ${gameId} as ${assignedPlayerId}. User map updated.`); + socket.emit('gameCreated', { + gameId: gameId, + mode: mode, + yourPlayerId: assignedPlayerId, + chosenCharacterKey: actualCharacterKey + }); if (mode === 'ai') { - const isInitialized = game.initializeGame(); - if (isInitialized) { - console.log(`[GameManager.createGame] AI game ${gameId} initialized, starting game...`); + if (game.initializeGame()) { game.startGame(); } else { - console.error(`[GameManager.createGame] AI game ${gameId} initialization failed. Cleaning up.`); - this._cleanupGame(gameId, 'initialization_failed_on_ai_create'); + this._cleanupGame(gameId, 'init_fail_ai_create_gm'); } } else if (mode === 'pvp') { + game.initializeGame(); // Инициализирует первого игрока if (!this.pendingPvPGames.includes(gameId)) { this.pendingPvPGames.push(gameId); - console.log(`[GameManager.createGame] PvP game ${gameId} added to pending list. Current pending: ${this.pendingPvPGames.join(', ')}`); } - game.initializeGame(); - console.log(`[GameManager.createGame] PvP game ${gameId} initialized (or placeholder). Emitting 'waitingForOpponent'.`); socket.emit('waitingForOpponent'); this.broadcastAvailablePvPGames(); } } else { - console.error(`[GameManager.createGame] game.addPlayer failed for user ${identifier} in game ${gameId}. Cleaning up.`); - this._cleanupGame(gameId, 'player_add_failed_in_instance'); + console.error(`[GameManager.createGame] game.addPlayer (instance method) failed for ${identifier} in ${gameId}. Cleaning up.`); + this._cleanupGame(gameId, 'player_add_failed_in_instance_gm'); } } - joinGame(socket, gameIdToJoin, identifier) { - console.log(`[GameManager.joinGame] User: ${identifier} (Socket: ${socket.id}) attempts to join GameID: ${gameIdToJoin}`); + joinGame(socket, gameIdToJoin, identifier, chosenCharacterKey = null) { + console.log(`[GameManager.joinGame] User: ${identifier} (Socket: ${socket.id}) attempts to join ${gameIdToJoin} with char ${chosenCharacterKey || 'Default'}`); const game = this.games[gameIdToJoin]; - if (!game) { - console.warn(`[GameManager.joinGame] Game ${gameIdToJoin} not found for user ${identifier}.`); - socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; - } - if (game.gameState?.isGameOver) { - console.warn(`[GameManager.joinGame] User ${identifier} tried to join game ${gameIdToJoin} which is already over.`); - socket.emit('gameError', { message: 'Эта игра уже завершена.' }); - this._cleanupGame(gameIdToJoin, `attempt_to_join_finished_game_${identifier}`); - return; - } - if (game.mode !== 'pvp') { - console.warn(`[GameManager.joinGame] User ${identifier} tried to join non-PvP game ${gameIdToJoin}. Mode: ${game.mode}`); - socket.emit('gameError', { message: 'К этой игре нельзя присоединиться (не PvP режим).' }); return; - } - if (game.playerCount >= 2 && !Object.values(game.players).some(p => p.identifier === identifier && p.isTemporarilyDisconnected)) { - console.warn(`[GameManager.joinGame] User ${identifier} tried to join full PvP game ${gameIdToJoin}. Players: ${game.playerCount}`); + + if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } + if (game.gameState?.isGameOver) { socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this._cleanupGame(gameIdToJoin, `attempt_join_finished_${identifier}`); return; } + if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться (не PvP).' }); return; } + + const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); + if (game.playerCount >= 2 && !playerInfoInGame?.isTemporarilyDisconnected) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } - if (game.ownerIdentifier === identifier && !Object.values(game.players).some(p => p.identifier === identifier && p.isTemporarilyDisconnected)) { - console.warn(`[GameManager.joinGame] User ${identifier} (owner) tried to join their own waiting game ${gameIdToJoin} as a new player.`); - socket.emit('gameError', { message: 'Вы не можете присоединиться к игре, которую сами создали и ожидаете, как новый игрок.' }); - this.handleRequestGameState(socket, identifier); return; + if (game.ownerIdentifier === identifier && !playerInfoInGame?.isTemporarilyDisconnected) { + socket.emit('gameError', { message: 'Вы не можете присоединиться к своей же ожидающей игре как новый игрок.' }); this.handleRequestGameState(socket, identifier); return; } - if (this.userIdentifierToGameId[identifier] && this.userIdentifierToGameId[identifier] !== gameIdToJoin) { - const otherGame = this.games[this.userIdentifierToGameId[identifier]]; - if (otherGame && !otherGame.gameState?.isGameOver) { - console.warn(`[GameManager.joinGame] User ${identifier} already in another active game: ${this.userIdentifierToGameId[identifier]}. Cannot join ${gameIdToJoin}.`); - socket.emit('gameError', { message: 'Вы уже находитесь в другой игре. Сначала завершите или покиньте её.' }); - this.handleRequestGameState(socket, identifier); return; - } else if (otherGame && otherGame.gameState?.isGameOver) { - console.warn(`[GameManager.joinGame] User ${identifier} was mapped to a finished game ${this.userIdentifierToGameId[identifier]}. Cleaning up before join.`); - this._cleanupGame(this.userIdentifierToGameId[identifier], `stale_finished_game_on_join_${identifier}`); - } + + const existingGameIdOfUser = this.userIdentifierToGameId[identifier]; + if (existingGameIdOfUser && existingGameIdOfUser !== gameIdToJoin) { + const otherGame = this.games[existingGameIdOfUser]; + if (otherGame && !otherGame.gameState?.isGameOver) { socket.emit('gameError', { message: 'Вы уже в другой активной игре.' }); this.handleRequestGameState(socket, identifier); return; } + else if (otherGame?.gameState?.isGameOver) this._cleanupGame(existingGameIdOfUser, `stale_finished_on_join_${identifier}`); } this._removePreviousPendingGames(socket.id, identifier, gameIdToJoin); - if (game.addPlayer(socket, null, identifier)) { + const charKeyForJoin = chosenCharacterKey || 'elena'; + if (game.addPlayer(socket, charKeyForJoin, identifier)) { this.userIdentifierToGameId[identifier] = gameIdToJoin; - console.log(`[GameManager.joinGame] Player ${identifier} successfully added/reconnected to PvP game ${gameIdToJoin}. User map updated: userIdentifierToGameId[${identifier}] = ${this.userIdentifierToGameId[identifier]}`); - if (game.playerCount === 2) { - console.log(`[GameManager.joinGame] Game ${gameIdToJoin} is now full with 2 active players. Initializing and starting.`); - const isInitialized = game.initializeGame(); - if (isInitialized) { + const joinedPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier); // Получаем инфо после добавления + + if (!joinedPlayerInfo || !joinedPlayerInfo.id || !joinedPlayerInfo.chosenCharacterKey) { + console.error(`[GameManager.joinGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} joining ${gameIdToJoin}. Cleaning up.`); + // Не вызываем _cleanupGame здесь, т.к. игра могла быть уже с одним игроком. + // GameInstance.addPlayer должен был бы вернуть false и не изменить playerCount. + socket.emit('gameError', { message: 'Ошибка сервера при назначении роли в игре.' }); + return; + } + console.log(`[GameManager.joinGame] Player ${identifier} added/reconnected to ${gameIdToJoin} as ${joinedPlayerInfo.id}.`); + socket.emit('gameCreated', { + gameId: gameIdToJoin, + mode: game.mode, + yourPlayerId: joinedPlayerInfo.id, + chosenCharacterKey: joinedPlayerInfo.chosenCharacterKey + }); + + if (game.playerCount === 2) { // Используем game.playerCount из GameInstance + console.log(`[GameManager.joinGame] Game ${gameIdToJoin} is now full. Initializing and starting.`); + if (game.initializeGame()) { game.startGame(); } else { - console.error(`[GameManager.joinGame] PvP game ${gameIdToJoin} initialization failed after 2nd player join. Cleaning up.`); - this._cleanupGame(gameIdToJoin, 'initialization_failed_on_pvp_join'); return; + this._cleanupGame(gameIdToJoin, 'full_init_fail_pvp_join_gm'); return; } const idx = this.pendingPvPGames.indexOf(gameIdToJoin); - if (idx > -1) { - this.pendingPvPGames.splice(idx, 1); - console.log(`[GameManager.joinGame] Game ${gameIdToJoin} removed from pending list. Current pending: ${this.pendingPvPGames.join(', ')}`); - } + if (idx > -1) this.pendingPvPGames.splice(idx, 1); this.broadcastAvailablePvPGames(); - } else { - console.log(`[GameManager.joinGame] Game ${gameIdToJoin} has ${game.playerCount} active players after join/reconnect. Waiting for more or game was already running.`); } - } else { - console.error(`[GameManager.joinGame] game.addPlayer failed for user ${identifier} trying to join ${gameIdToJoin}.`); } } findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { - console.log(`[GameManager.findAndJoinRandomPvPGame] User: ${identifier} (Socket: ${socket.id}), CharForCreation: ${chosenCharacterKeyForCreation}`); - if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { - const existingGame = this.games[this.userIdentifierToGameId[identifier]]; - if (existingGame && !existingGame.gameState?.isGameOver) { - console.warn(`[GameManager.findAndJoinRandomPvPGame] User ${identifier} already in active game: ${this.userIdentifierToGameId[identifier]}.`); - socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + // ... (Логика findAndJoinRandomPvPGame без изменений, использует game.playerCount) + console.log(`[GameManager.findRandomPvPGame] User: ${identifier} (Socket: ${socket.id}), CharForCreation: ${chosenCharacterKeyForCreation}`); + const existingGameId = this.userIdentifierToGameId[identifier]; + if (existingGameId && this.games[existingGameId]) { + const existingGame = this.games[existingGameId]; + if (!existingGame.gameState?.isGameOver) { + socket.emit('gameError', { message: 'Вы уже в активной или ожидающей игре.' }); this.handleRequestGameState(socket, identifier); return; - } else if (existingGame && existingGame.gameState?.isGameOver) { - console.warn(`[GameManager.findAndJoinRandomPvPGame] User ${identifier} mapped to finished game ${this.userIdentifierToGameId[identifier]}. Cleaning up.`); - this._cleanupGame(this.userIdentifierToGameId[identifier], `stale_finished_game_on_find_random_${identifier}`); + } else { + this._cleanupGame(existingGameId, `stale_finished_on_find_random_${identifier}`); } } this._removePreviousPendingGames(socket.id, identifier); let gameIdToJoin = null; - console.log(`[GameManager.findAndJoinRandomPvPGame] Searching pending games for ${identifier}. Current pending: ${this.pendingPvPGames.join(', ')}`); - for (const id of [...this.pendingPvPGames]) { // Iterate over a copy in case of modification + for (const id of [...this.pendingPvPGames]) { const pendingGame = this.games[id]; - if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) { - if (!pendingGame.gameState || !pendingGame.gameState.isGameOver) { - gameIdToJoin = id; - console.log(`[GameManager.findAndJoinRandomPvPGame] Found suitable pending game: ${gameIdToJoin} for user ${identifier}.`); - break; - } else { - console.log(`[GameManager.findAndJoinRandomPvPGame] Pending game ${id} is already over. Skipping and cleaning.`); - this._cleanupGame(id, `stale_finished_pending_game_during_find_random`); // Clean up stale finished game - } + if (pendingGame && pendingGame.mode === 'pvp' && + pendingGame.playerCount === 1 && + pendingGame.ownerIdentifier !== identifier && + !pendingGame.gameState?.isGameOver) { + gameIdToJoin = id; break; + } else if (pendingGame?.gameState?.isGameOver) { + this._cleanupGame(id, `stale_finished_pending_on_find_random`); } } + if (gameIdToJoin) { - this.joinGame(socket, gameIdToJoin, identifier); + console.log(`[GameManager.findRandomPvPGame] Found pending game ${gameIdToJoin} for ${identifier}. Joining...`); + const randomJoinCharKey = ['elena', 'almagest', 'balard'][Math.floor(Math.random() * 3)]; + this.joinGame(socket, gameIdToJoin, identifier, randomJoinCharKey); } else { - console.log(`[GameManager.findAndJoinRandomPvPGame] No suitable pending game found for ${identifier}. Creating a new PvP game.`); + console.log(`[GameManager.findRandomPvPGame] No suitable pending game. Creating new PvP game for ${identifier}.`); this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); } } handlePlayerAction(identifier, actionData) { const gameId = this.userIdentifierToGameId[identifier]; - console.log(`[GameManager.handlePlayerAction] User: ${identifier}, Action: ${actionData?.actionType}, AbilityID: ${actionData?.abilityId}, GameID from map: ${gameId}`); + console.log(`[GameManager.handlePlayerAction] User: ${identifier}, Action: ${actionData?.actionType}, GameID: ${gameId}`); const game = this.games[gameId]; if (game) { if (game.gameState?.isGameOver) { - console.warn(`[GameManager.handlePlayerAction] User ${identifier} in game ${gameId} attempted action, but game is ALREADY OVER. Action ignored.`); - game.playerSockets[identifier]?.socket.emit('gameError', {message: "Действие невозможно: игра уже завершена."}); - // Potentially send gameNotFound or re-send gameOver if client missed it - this.handleRequestGameState(game.playerSockets[identifier]?.socket || this._findClientSocketByIdentifier(identifier), identifier); + const playerSocket = Object.values(game.players).find(p => p.identifier === identifier)?.socket; // Находим сокет по identifier + if (playerSocket) this.handleRequestGameState(playerSocket, identifier); return; } - const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); - if (playerInfo && playerInfo.socket && playerInfo.socket.connected && !playerInfo.isTemporarilyDisconnected) { - console.log(`[GameManager.handlePlayerAction] Forwarding action from user ${identifier} (Socket: ${playerInfo.socket.id}) to game ${gameId}.`); - game.processPlayerAction(playerInfo.socket.id, actionData); - } else if (playerInfo && playerInfo.isTemporarilyDisconnected) { - console.warn(`[GameManager.handlePlayerAction] User ${identifier} (Socket: ${playerInfo.socket?.id}) in game ${gameId} attempted action, but is temporarily disconnected. Action ignored.`); - playerInfo.socket?.emit('gameError', {message: "Действие невозможно: вы временно отключены."}); - } else if (playerInfo && playerInfo.socket && !playerInfo.socket.connected) { - console.warn(`[GameManager.handlePlayerAction] User ${identifier} (Socket: ${playerInfo.socket.id}) in game ${gameId} attempted action, but socket is reported as disconnected by server. Action ignored. Potential state mismatch.`); - if (typeof game.handlePlayerPotentiallyLeft === 'function') { - game.handlePlayerPotentiallyLeft(playerInfo.id, playerInfo.identifier, playerInfo.chosenCharacterKey); - } - } else { - console.warn(`[GameManager.handlePlayerAction] User ${identifier} attempted action for game ${gameId}, but active player info or socket not found in game instance. Removing from user map.`); - delete this.userIdentifierToGameId[identifier]; - const clientSocket = this._findClientSocketByIdentifier(identifier); - if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия потеряна (ошибка игрока при действии).' }); - } + game.processPlayerAction(identifier, actionData); // Передаем identifier } else { - console.warn(`[GameManager.handlePlayerAction] User ${identifier} attempted action, but game ${gameId} (from map) not found in this.games. Removing from user map.`); delete this.userIdentifierToGameId[identifier]; const clientSocket = this._findClientSocketByIdentifier(identifier); - if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена (игра отсутствует).' }); + if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена при действии.' }); } } handlePlayerSurrender(identifier) { + // ... (Логика handlePlayerSurrender без изменений) const gameId = this.userIdentifierToGameId[identifier]; console.log(`[GameManager.handlePlayerSurrender] User: ${identifier} surrendered. GameID from map: ${gameId}`); - const game = this.games[gameId]; - if (game) { if (game.gameState?.isGameOver) { - console.warn(`[GameManager.handlePlayerSurrender] User ${identifier} tried to surrender in game ${gameId} which is ALREADY OVER. Ignoring.`); - // _cleanupGame should have already run or will run. - // Ensure map is clear if it's somehow stale. - if (this.userIdentifierToGameId[identifier] === gameId) { - console.warn(`[GameManager.handlePlayerSurrender] Stale map entry for ${identifier} to finished game ${gameId}. Cleaning.`); - delete this.userIdentifierToGameId[identifier]; // Direct cleanup if game is confirmed over. - } + console.warn(`[GameManager.handlePlayerSurrender] User ${identifier} in game ${gameId} surrender, but game ALREADY OVER.`); + if (this.userIdentifierToGameId[identifier] === gameId) delete this.userIdentifierToGameId[identifier]; return; } + if (typeof game.playerDidSurrender === 'function') game.playerDidSurrender(identifier); + else { console.error(`[GameManager.handlePlayerSurrender] CRITICAL: GameInstance ${gameId} missing playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method"); } + } else { + if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; + } + } - if (typeof game.playerDidSurrender === 'function') { - console.log(`[GameManager.handlePlayerSurrender] Forwarding surrender from user ${identifier} to game ${gameId}.`); - game.playerDidSurrender(identifier); // This method will call _cleanupGame internally - } else { - console.error(`[GameManager.handlePlayerSurrender] CRITICAL: GameInstance ${gameId} is missing playerDidSurrender method! Attempting fallback cleanup for PvP.`); - if (game.mode === 'pvp' && game.gameState && !game.gameState.isGameOver) { - const surrenderedPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier); - if (surrenderedPlayerInfo) { - const opponentInfo = Object.values(game.players).find(p => p.identifier !== identifier && !p.isTemporarilyDisconnected); - const winnerRole = opponentInfo ? opponentInfo.id : null; - if (typeof game.endGameDueToDisconnect === 'function') { - game.endGameDueToDisconnect(surrenderedPlayerInfo.id, surrenderedPlayerInfo.chosenCharacterKey, "opponent_surrendered_fallback", winnerRole); - } else { - this._cleanupGame(gameId, "surrender_fallback_cleanup_missing_end_method"); - } - } else { - this._cleanupGame(gameId, "surrender_player_not_found_in_game_instance_fallback"); - } - } else if (game.mode === 'ai') { - console.log(`[GameManager.handlePlayerSurrender] User ${identifier} in AI game ${gameId} surrendered. No playerDidSurrender. Disconnect will handle.`); + handleLeaveAiGame(identifier) { + const gameId = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handleLeaveAiGame] User: ${identifier} leaving AI game. GameID from map: ${gameId}`); + const game = this.games[gameId]; + if (game) { + if (game.gameState?.isGameOver) { + console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} game ${gameId} leaving, but game ALREADY OVER.`); + return; + } + if (game.mode === 'ai') { + if (typeof game.playerExplicitlyLeftAiGame === 'function') { + game.playerExplicitlyLeftAiGame(identifier); + } else { + console.error(`[GameManager.handleLeaveAiGame] CRITICAL: GameInstance ${gameId} missing playerExplicitlyLeftAiGame! Cleaning up directly.`); + this._cleanupGame(gameId, "leave_ai_missing_method"); } + } else { + console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} sent leaveAiGame, but game ${gameId} is not AI mode (${game.mode}).`); } } else { - console.warn(`[GameManager.handlePlayerSurrender] User ${identifier} surrendered, but game ${gameId} (from map) not found in this.games. User map might be stale or already cleaned up.`); - if (this.userIdentifierToGameId[identifier] === gameId || this.userIdentifierToGameId[identifier]) { - console.log(`[GameManager.handlePlayerSurrender] Clearing map entry for ${identifier} which pointed to ${this.userIdentifierToGameId[identifier]}.`); - delete this.userIdentifierToGameId[identifier]; - } + if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; } } _findClientSocketByIdentifier(identifier) { + // ... (код без изменений) for (const sid of this.io.sockets.sockets.keys()) { const s = this.io.sockets.sockets.get(sid); - if (s && s.userData && s.userData.userId === identifier && s.connected) { - // console.log(`[GameManager._findClientSocketByIdentifier] Found active socket ${s.id} for identifier ${identifier}.`); - return s; - } + if (s && s.userData && s.userData.userId === identifier && s.connected) return s; } - // console.log(`[GameManager._findClientSocketByIdentifier] No active socket found for identifier ${identifier}.`); return null; } handleDisconnect(socketId, identifier) { const gameIdFromMap = this.userIdentifierToGameId[identifier]; console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, User: ${identifier}, GameID from map: ${gameIdFromMap}`); - const game = gameIdFromMap ? this.games[gameIdFromMap] : null; if (game) { - if (game.gameState && game.gameState.isGameOver) { - console.log(`[GameManager.handleDisconnect] Game ${gameIdFromMap} for user ${identifier} (socket: ${socketId}) is ALREADY OVER. Disconnect processing skipped for game logic.`); - // _cleanupGame (called by playerDidSurrender or other end game methods) is responsible for clearing userIdentifierToGameId. - // If the map entry still exists, it implies _cleanupGame might not have completed or there's a race condition. - // However, we shouldn't initiate new game logic like handlePlayerPotentiallyLeft. + if (game.gameState?.isGameOver) { + console.log(`[GameManager.handleDisconnect] Game ${gameIdFromMap} for user ${identifier} (socket ${socketId}) ALREADY OVER.`); return; } - const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); - console.log(`[GameManager.handleDisconnect] Game ${gameIdFromMap} found (and not game over). PlayerInfo for user ${identifier}: ${playerInfo ? `Role: ${playerInfo.id}, CurrentSocketInGame: ${playerInfo.socket?.id}, IsTempDisconnected: ${playerInfo.isTemporarilyDisconnected}` : 'Not found in game instance'}`); + // Находим информацию об игроке в инстансе игры по identifier + const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); - if (playerInfo && playerInfo.socket && playerInfo.socket.id === socketId && !playerInfo.isTemporarilyDisconnected) { - console.log(`[GameManager.handleDisconnect] Disconnecting socket ${socketId} matches active socket for user ${identifier} (Role: ${playerInfo.id}) in game ${gameIdFromMap}. Notifying GameInstance.`); + if (playerInfoInGame && playerInfoInGame.socket?.id === socketId && !playerInfoInGame.isTemporarilyDisconnected) { + console.log(`[GameManager.handleDisconnect] Disconnecting socket ${socketId} matches active player ${identifier} (Role: ${playerInfoInGame.id}) in game ${gameIdFromMap}. Notifying GameInstance.`); + // Передаем роль, идентификатор и ключ персонажа в GameInstance if (typeof game.handlePlayerPotentiallyLeft === 'function') { - game.handlePlayerPotentiallyLeft(playerInfo.id, playerInfo.identifier, playerInfo.chosenCharacterKey); + game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey); } else { - console.error(`[GameManager.handleDisconnect] CRITICAL: GameInstance ${gameIdFromMap} is missing handlePlayerPotentiallyLeft method!`); - this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_in_instance_on_disconnect"); - } - } else if (playerInfo && playerInfo.socket && playerInfo.socket.id !== socketId) { - console.log(`[GameManager.handleDisconnect] Disconnected socket ${socketId} is STALE for user ${identifier} (active socket in game ${gameIdFromMap} is ${playerInfo.socket.id}). Ignoring this disconnect for game logic.`); - } else if (playerInfo && playerInfo.isTemporarilyDisconnected && playerInfo.socket?.id === socketId) { - console.log(`[GameManager.handleDisconnect] User ${identifier} (socket ${socketId}) disconnected while already being temporarily disconnected. Reconnect timer should handle final cleanup.`); - } else if (!playerInfo && gameIdFromMap) { - console.log(`[GameManager.handleDisconnect] User ${identifier} was mapped to game ${gameIdFromMap}, but not found in game.players.`); - if (this.userIdentifierToGameId[identifier] === gameIdFromMap) { - console.warn(`[GameManager.handleDisconnect] Removing stale map entry for ${identifier} to game ${gameIdFromMap} where player was not found in instance.`); - delete this.userIdentifierToGameId[identifier]; + console.error(`[GameManager.handleDisconnect] CRITICAL: GameInstance ${gameIdFromMap} missing handlePlayerPotentiallyLeft!`); + this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_on_disconnect_gm"); } + } else if (playerInfoInGame && playerInfoInGame.socket?.id !== socketId) { + console.log(`[GameManager.handleDisconnect] Disconnected socket ${socketId} is STALE for user ${identifier}. Active socket in game: ${playerInfoInGame.socket?.id}.`); + } else if (playerInfoInGame && playerInfoInGame.isTemporarilyDisconnected) { + console.log(`[GameManager.handleDisconnect] User ${identifier} (socket ${socketId}) disconnected while already temp disconnected. Reconnect timer handles final cleanup.`); + } else if (!playerInfoInGame) { + console.warn(`[GameManager.handleDisconnect] User ${identifier} mapped to game ${gameIdFromMap}, but not found in game.players. Clearing map.`); + if (this.userIdentifierToGameId[identifier] === gameIdFromMap) delete this.userIdentifierToGameId[identifier]; } } else { - console.log(`[GameManager.handleDisconnect] User ${identifier} (Socket: ${socketId}) disconnected, but no active game instance found for gameId ${gameIdFromMap} (or gameId was undefined).`); - if (this.userIdentifierToGameId[identifier]) { // If a mapping exists, even if gameIdFromMap was undefined or game not in this.games - console.warn(`[GameManager.handleDisconnect] Removing map entry for ${identifier} which pointed to ${this.userIdentifierToGameId[identifier]}.`); + if (this.userIdentifierToGameId[identifier]) { delete this.userIdentifierToGameId[identifier]; } } } _cleanupGame(gameId, reason = 'unknown') { - console.log(`[GameManager._cleanupGame] Attempting to cleanup GameID: ${gameId}, Reason: ${reason}`); + // ... (Код _cleanupGame почти без изменений, но использует game.playerCount и game.ownerIdentifier) + console.log(`[GameManager._cleanupGame] Attempting cleanup for GameID: ${gameId}, Reason: ${reason}`); const game = this.games[gameId]; + if (!game) { - console.warn(`[GameManager._cleanupGame] Game ${gameId} not found in this.games. Checking pending list and user map.`); const pendingIdx = this.pendingPvPGames.indexOf(gameId); - if (pendingIdx > -1) { - this.pendingPvPGames.splice(pendingIdx, 1); - console.log(`[GameManager._cleanupGame] Removed ${gameId} from pending list (instance was already gone). Reason: ${reason}. Current pending: ${this.pendingPvPGames.join(', ')}`); - this.broadcastAvailablePvPGames(); - } - // Ensure any lingering user map entries for this non-existent game are cleared. - let mapCleaned = false; - for (const idKey in this.userIdentifierToGameId) { - if (this.userIdentifierToGameId[idKey] === gameId) { - console.log(`[GameManager._cleanupGame] Clearing STALE mapping for user ${idKey} to non-existent game ${gameId}.`); - delete this.userIdentifierToGameId[idKey]; - mapCleaned = true; - } - } - if (mapCleaned) console.log(`[GameManager._cleanupGame] Stale user maps cleared for game ${gameId}.`); - return false; // Game was not found in this.games + if (pendingIdx > -1) { this.pendingPvPGames.splice(pendingIdx, 1); this.broadcastAvailablePvPGames(); } + for (const idKey in this.userIdentifierToGameId) { if (this.userIdentifierToGameId[idKey] === gameId) delete this.userIdentifierToGameId[idKey]; } + return false; } - console.log(`[GameManager._cleanupGame] Cleaning up game ${gameId}. Owner: ${game.ownerIdentifier}. Reason: ${reason}.`); + console.log(`[GameManager._cleanupGame] Cleaning up game ${game.id}. Owner: ${game.ownerIdentifier}. Reason: ${reason}. Players in game: ${game.playerCount}`); if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear(); - if (typeof game.clearAllReconnectTimers === 'function') { - game.clearAllReconnectTimers(); - console.log(`[GameManager._cleanupGame] Called clearAllReconnectTimers for game ${gameId}.`); - } + if (typeof game.clearAllReconnectTimers === 'function') game.clearAllReconnectTimers(); // Вызываем у GameInstance - // Ensure gameState is marked as over if not already if (game.gameState && !game.gameState.isGameOver) { - console.warn(`[GameManager._cleanupGame] Game ${gameId} was not marked as 'isGameOver' during cleanup. Marking now. Reason: ${reason}`); game.gameState.isGameOver = true; } - // Remove all players of this game from the global userIdentifierToGameId map - let playersInGameCleaned = 0; - Object.values(game.players).forEach(pInfo => { + let playersCleanedFromMap = 0; + Object.values(game.players).forEach(pInfo => { // Игроки теперь в game.players if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) { - console.log(`[GameManager._cleanupGame] Deleting mapping for player ${pInfo.identifier} (Role: ${pInfo.id}) from game ${gameId}.`); delete this.userIdentifierToGameId[pInfo.identifier]; - playersInGameCleaned++; + playersCleanedFromMap++; } }); - // Also check the owner, in case they weren't in game.players (e.g., game created but owner disconnected before fully joining) - if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId) { - if (!Object.values(game.players).some(p=>p.identifier === game.ownerIdentifier)) { // Only if not already cleaned - console.log(`[GameManager._cleanupGame] Deleting mapping for owner ${game.ownerIdentifier} from game ${gameId} (was not in game.players or already cleaned).`); - delete this.userIdentifierToGameId[game.ownerIdentifier]; - playersInGameCleaned++; - } + if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId && + !Object.values(game.players).some(p=>p.identifier === game.ownerIdentifier)) { // Проверка, если владелец не в списке игроков + delete this.userIdentifierToGameId[game.ownerIdentifier]; + playersCleanedFromMap++; } - if (playersInGameCleaned > 0) { - console.log(`[GameManager._cleanupGame] ${playersInGameCleaned} player mappings cleared for game ${gameId}.`); - } - const pendingIdx = this.pendingPvPGames.indexOf(gameId); - if (pendingIdx > -1) { - this.pendingPvPGames.splice(pendingIdx, 1); - console.log(`[GameManager._cleanupGame] Game ${gameId} removed from pending list. Current pending: ${this.pendingPvPGames.join(', ')}`); - } + if (pendingIdx > -1) this.pendingPvPGames.splice(pendingIdx, 1); delete this.games[gameId]; - console.log(`[GameManager._cleanupGame] Game ${gameId} instance deleted. Games left: ${Object.keys(this.games).length}. User map size: ${Object.keys(this.userIdentifierToGameId).length}`); - this.broadcastAvailablePvPGames(); // Update list for all clients + console.log(`[GameManager._cleanupGame] Game ${game.id} instance deleted. Games left: ${Object.keys(this.games).length}. Pending: ${this.pendingPvPGames.length}. User map size: ${Object.keys(this.userIdentifierToGameId).length}`); + this.broadcastAvailablePvPGames(); return true; } getAvailablePvPGamesListForClient() { - // console.log(`[GameManager.getAvailablePvPGamesListForClient] Generating list from pending: ${this.pendingPvPGames.join(', ')}`); + // ... (Код без изменений, использует game.playerCount и game.ownerIdentifier) return this.pendingPvPGames .map(gameId => { const game = this.games[gameId]; if (game && game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { - const p1Info = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); - let p1Username = 'Игрок', p1CharName = 'Неизвестный'; + // Находим первого игрока (владельца) в инстансе игры + const p1Entry = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); + let p1Username = 'Игрок'; + let p1CharName = 'Неизвестный'; const ownerId = game.ownerIdentifier; - if (p1Info && p1Info.socket && p1Info.socket.userData) { // Check for userData - p1Username = p1Info.socket.userData.username || `User#${String(p1Info.identifier).substring(0,4)}`; - const charData = dataUtils.getCharacterBaseStats(p1Info.chosenCharacterKey); - p1CharName = charData?.name || p1Info.chosenCharacterKey || 'Не выбран'; - } else if (ownerId){ - // console.warn(`[GameManager.getAvailablePvPGamesListForClient] Game ${gameId} is pending, p1Info not found or no socket/userData. Using owner info. Owner: ${ownerId}`); - // Try to find owner's socket if they are still connected to the server (even if not in this game's player list actively) + if (p1Entry && p1Entry.socket?.userData) { + p1Username = p1Entry.socket.userData.username || `User#${String(p1Entry.identifier).substring(0,4)}`; + const charData = dataUtils.getCharacterBaseStats(p1Entry.chosenCharacterKey); + p1CharName = charData?.name || p1Entry.chosenCharacterKey || 'Не выбран'; + } else if (ownerId){ // Фоллбэк на поиск по ownerId, если p1Entry не найден или без userData const ownerSocket = this._findClientSocketByIdentifier(ownerId); p1Username = ownerSocket?.userData?.username || `Owner#${String(ownerId).substring(0,4)}`; - const ownerCharKey = game.playerCharacterKey; // This should be the char key of the first player/owner + const ownerCharKey = game.playerCharacterKey; // Ключ персонажа первого игрока из GameInstance const charData = ownerCharKey ? dataUtils.getCharacterBaseStats(ownerCharKey) : null; p1CharName = charData?.name || ownerCharKey || 'Не выбран'; } - // console.log(`[GameManager.getAvailablePvPGamesListForClient] Game ${gameId} - Owner: ${ownerId}, P1 Username: ${p1Username}, Char: ${p1CharName}`); return { id: gameId, status: `Ожидает (${p1Username} за ${p1CharName})`, ownerIdentifier: ownerId }; } return null; @@ -454,147 +380,58 @@ class GameManager { broadcastAvailablePvPGames() { const list = this.getAvailablePvPGamesListForClient(); - // console.log(`[GameManager.broadcastAvailablePvPGames] Broadcasting list of ${list.length} games.`); this.io.emit('availablePvPGamesList', list); } handleRequestGameState(socket, identifier) { const gameIdFromMap = this.userIdentifierToGameId[identifier]; - console.log(`[GameManager.handleRequestGameState] User: ${identifier} (New Socket: ${socket.id}) requests state. GameID from map: ${gameIdFromMap}`); - + console.log(`[GameManager.handleRequestGameState] User: ${identifier} (Socket: ${socket.id}) requests state. GameID from map: ${gameIdFromMap}`); const game = gameIdFromMap ? this.games[gameIdFromMap] : null; if (game) { - console.log(`[GameManager.handleRequestGameState] Game ${gameIdFromMap} found for user ${identifier}. Game mode: ${game.mode}, Active players in instance: ${game.playerCount}, Total player entries: ${Object.keys(game.players).length}`); - const playerInfoInGameInstance = Object.values(game.players).find(p => p.identifier === identifier); - console.log(`[GameManager.handleRequestGameState] PlayerInfo for user ${identifier} in game ${gameIdFromMap}: ${playerInfoInGameInstance ? `Role: ${playerInfoInGameInstance.id}, OldSocketInGame: ${playerInfoInGameInstance.socket?.id}, TempDisco: ${playerInfoInGameInstance.isTemporarilyDisconnected}` : 'Not found in game.players'}`); + const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); // Ищем по identifier + console.log(`[GameManager.handleRequestGameState] Game ${gameIdFromMap} found. PlayerInfo: ${playerInfoInGame ? `Role: ${playerInfoInGame.id}, TempDisco: ${playerInfoInGame.isTemporarilyDisconnected}` : 'Not found in game.players'}`); - if (playerInfoInGameInstance) { + if (playerInfoInGame) { if (game.gameState?.isGameOver) { - console.warn(`[GameManager.handleRequestGameState] Game ${gameIdFromMap} for user ${identifier} IS ALREADY OVER. Emitting 'gameNotFound'. Cleanup should handle map.`); socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' }); - // _cleanupGame is responsible for clearing userIdentifierToGameId when a game ends. - // If the game is over, but the user is still mapped, _cleanupGame might not have run or completed fully. - // We don't call _cleanupGame here directly, as it might be called by the game ending logic itself. + if(this.userIdentifierToGameId[identifier] === gameIdFromMap) delete this.userIdentifierToGameId[identifier]; return; } - - console.log(`[GameManager.handleRequestGameState] Restoring game ${gameIdFromMap} for user ${identifier}. NewSocket: ${socket.id}. OldSocketInGame: ${playerInfoInGameInstance.socket?.id}. Player role: ${playerInfoInGameInstance.id}`); - + // Передаем РОЛЬ и НОВЫЙ СОКЕТ в GameInstance для обработки реконнекта if (typeof game.handlePlayerReconnected === 'function') { - const reconnected = game.handlePlayerReconnected(playerInfoInGameInstance.id, socket); - console.log(`[GameManager.handleRequestGameState] Called game.handlePlayerReconnected for role ${playerInfoInGameInstance.id}. Result: ${reconnected}`); - if (!reconnected && game.gameState && !game.gameState.isGameOver) { // if reconnected failed but game is active - console.warn(`[GameManager.handleRequestGameState] game.handlePlayerReconnected returned false for user ${identifier}. This might indicate an issue.`); - // It could be that the player wasn't marked as disconnected, or an error occurred. - // The client might still expect game state. - } else if (!reconnected && game.gameState?.isGameOver) { - // If reconnect failed AND game is over, ensure client gets gameNotFound. - socket.emit('gameNotFound', { message: 'Не удалось восстановить сессию: игра уже завершена.' }); - return; - } + const reconnected = game.handlePlayerReconnected(playerInfoInGame.id, socket); + // ... (обработка результата reconnected, если нужно) } else { - console.error(`[GameManager.handleRequestGameState] CRITICAL: GameInstance ${game.id} is missing handlePlayerReconnected method! Attempting fallback socket update.`); - const oldSocketId = playerInfoInGameInstance.socket?.id; - if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) { - delete game.players[oldSocketId]; - } - playerInfoInGameInstance.socket = socket; - game.players[socket.id] = playerInfoInGameInstance; - if (!game.playerSockets[playerInfoInGameInstance.id] || game.playerSockets[playerInfoInGameInstance.id].id !== socket.id) { - game.playerSockets[playerInfoInGameInstance.id] = socket; - } - } - - // If handlePlayerReconnected returned false but game is not over, we might still need to send state. - // If game became game over inside handlePlayerReconnected, it should have emitted gameOver. - - // Re-check game over state as handlePlayerReconnected might have changed it (e.g. if opponent didn't reconnect and game ended) - if (game.gameState?.isGameOver) { - console.warn(`[GameManager.handleRequestGameState] Game ${gameIdFromMap} became game over during reconnect logic for ${identifier}. Emitting 'gameNotFound'.`); - socket.emit('gameNotFound', { message: 'Игра завершилась во время попытки переподключения.' }); - return; - } - - - socket.join(game.id); - console.log(`[GameManager.handleRequestGameState] New socket ${socket.id} for user ${identifier} joined room ${game.id}.`); - - const pCharKey = playerInfoInGameInstance.chosenCharacterKey; - const pData = dataUtils.getCharacterData(pCharKey); - const opponentRole = playerInfoInGameInstance.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - const oCharKeyFromGameState = game.gameState?.[opponentRole]?.characterKey; - const oCharKeyFromInstance = playerInfoInGameInstance.id === GAME_CONFIG.PLAYER_ID ? game.opponentCharacterKey : game.playerCharacterKey; - const oCharKey = oCharKeyFromGameState || oCharKeyFromInstance; - const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; - - console.log(`[GameManager.handleRequestGameState] User's charKey: ${pCharKey}. Opponent's role: ${opponentRole}, charKey: ${oCharKey || 'None (pending/AI placeholder)'}.`); - - if (pData && (oData || (game.mode === 'pvp' && game.playerCount === 1 && !oCharKey) || game.mode === 'ai') && game.gameState) { - const gameStateToSend = game.gameState; - const logBufferToSend = game.consumeLogBuffer(); - console.log(`[GameManager.handleRequestGameState] Emitting 'gameStarted' (for restore) to ${identifier} for game ${game.id}. Game state isGameOver: ${gameStateToSend.isGameOver}. Log entries: ${logBufferToSend.length}`); - - socket.emit('gameStarted', { - gameId: game.id, - yourPlayerId: playerInfoInGameInstance.id, - initialGameState: gameStateToSend, - playerBaseStats: pData.baseStats, - opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, - playerAbilities: pData.abilities, - opponentAbilities: oData?.abilities || [], - log: logBufferToSend, - clientConfig: { ...GAME_CONFIG } - }); - - if (game.mode === 'pvp' && game.playerCount === 1 && game.ownerIdentifier === identifier && !game.gameState.isGameOver) { - console.log(`[GameManager.handleRequestGameState] PvP game ${game.id} is still pending for owner ${identifier}. Emitting 'waitingForOpponent'.`); - socket.emit('waitingForOpponent'); - } - - if (!game.gameState.isGameOver && typeof game.turnTimer?.start === 'function' && !game.isGameEffectivelyPaused()) { - const isAiTurnForTimer = game.mode === 'ai' && !game.gameState.isPlayerTurn && game.gameState.opponent?.characterKey !== null; - console.log(`[GameManager.handleRequestGameState] Restarting turn timer for game ${game.id}. isPlayerTurn: ${game.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`); - game.turnTimer.start(game.gameState.isPlayerTurn, isAiTurnForTimer); - } else if (game.gameState.isGameOver) { - console.log(`[GameManager.handleRequestGameState] Game ${game.id} is already over, no timer restart.`); - } else if (game.isGameEffectivelyPaused()){ - console.log(`[GameManager.handleRequestGameState] Game ${game.id} is effectively paused, turn timer not started.`); - } - } else { - console.error(`[GameManager.handleRequestGameState] Data load failed for game ${game.id} / user ${identifier} on reconnect. pData: ${!!pData}, oData: ${!!oData} (oCharKey: ${oCharKey}), gameState: ${!!game.gameState}`); - this._handleGameRecoveryError(socket, game.id, identifier, 'data_load_fail_reconnect_manager'); + console.error(`[GameManager.handleRequestGameState] CRITICAL: GameInstance ${game.id} missing handlePlayerReconnected!`); + this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_gm'); } } else { - console.error(`[GameManager.handleRequestGameState] User ${identifier} was mapped to game ${gameIdFromMap}, but NOT found in game.players. This indicates a serious state inconsistency.`); - this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_instance_reconnect_manager'); + this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_reconnect_gm'); } } else { - console.log(`[GameManager.handleRequestGameState] No active game session found for user ${identifier} (GameID from map was ${gameIdFromMap || 'undefined'}). Emitting 'gameNotFound'.`); socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' }); - // Ensure map is clear if it's somehow stale - if (this.userIdentifierToGameId[identifier]) { - console.warn(`[GameManager.handleRequestGameState] Clearing stale map entry for ${identifier} which pointed to ${this.userIdentifierToGameId[identifier]} but game not found.`); - delete this.userIdentifierToGameId[identifier]; - } + if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; } } _handleGameRecoveryError(socket, gameId, identifier, reasonCode) { + // ... (код без изменений) console.error(`[GameManager._handleGameRecoveryError] Error recovering game (ID: ${gameId || 'N/A'}) for user ${identifier}. Reason: ${reasonCode}.`); socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' }); - if (gameId) { - // Attempt to cleanup the problematic game + if (gameId && this.games[gameId]) { // Проверяем, что игра еще существует перед очисткой this._cleanupGame(gameId, `recovery_error_${reasonCode}_for_${identifier}`); } else if (this.userIdentifierToGameId[identifier]) { - // If gameId was null, but user was still mapped, cleanup the mapped game + // Если игра уже удалена, но пользователь все еще к ней привязан const problematicGameId = this.userIdentifierToGameId[identifier]; - console.warn(`[GameManager._handleGameRecoveryError] GameId was null/undefined for user ${identifier}, but they were in map to game ${problematicGameId}. Attempting cleanup of ${problematicGameId}.`); - this._cleanupGame(problematicGameId, `recovery_error_null_gameid_for_${identifier}_reason_${reasonCode}`); + if (this.games[problematicGameId]) { // Если она все же есть + this._cleanupGame(problematicGameId, `recovery_error_stale_map_${identifier}_reason_${reasonCode}`); + } else { // Если ее нет, просто чистим карту + delete this.userIdentifierToGameId[identifier]; + } } - // This will also clear userIdentifierToGameId[identifier] if _cleanupGame didn't. + // Если после _cleanupGame пользователь все еще привязан (маловероятно, но для гарантии) if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; - socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки.' }); } } diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js index 09d9606..b925e9a 100644 --- a/server/game/instance/GameInstance.js +++ b/server/game/instance/GameInstance.js @@ -10,27 +10,35 @@ class GameInstance { this.id = gameId; this.io = io; this.mode = mode; - this.players = {}; - this.playerSockets = {}; - this.playerCount = 0; + this.gameManager = gameManager; + + this.players = {}; // { socketId: { id (role), socket, chosenCharacterKey, identifier, isTemporarilyDisconnected }} + this.playerSockets = {}; // { roleId: socket } -> для быстрого доступа к сокету по роли + this.playerCount = 0; // Только активные, не isTemporarilyDisconnected + this.gameState = null; this.aiOpponent = (mode === 'ai'); this.logBuffer = []; + this.playerCharacterKey = null; this.opponentCharacterKey = null; this.ownerIdentifier = null; - this.gameManager = gameManager; - this.reconnectTimers = {}; + + this.reconnectTimers = {}; // { roleId: { timerId, updateIntervalId, startTimeMs, durationMs } } + this.pausedTurnState = null; // { remainingTime: number, forPlayerRoleIsPlayer: boolean, isAiCurrentlyMoving: boolean } this.turnTimer = new TurnTimer( GAME_CONFIG.TURN_DURATION_MS, GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, () => this.handleTurnTimeout(), - (remainingTime, isPlayerTurnForTimer) => { - if (!this.isGameEffectivelyPaused()) { - this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, isPlayerTurn: isPlayerTurnForTimer }); - } - } + (remainingTime, isPlayerTurnForTimer, isPaused) => { + this.io.to(this.id).emit('turnTimerUpdate', { + remainingTime, + isPlayerTurn: isPlayerTurnForTimer, + isPaused: isPaused + }); + }, + this.id ); if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { @@ -39,60 +47,118 @@ class GameInstance { console.log(`[GameInstance ${this.id}] Created. Mode: ${mode}.`); } + _sayTaunt(characterState, opponentCharacterKey, triggerType, subTriggerOrContext = null, contextOverrides = {}) { + if (!characterState || !characterState.characterKey) { + // console.warn(`[Taunt ${this.id}] _sayTaunt: Caller character or characterKey is missing. Speaker: ${characterState?.name}, Trigger: ${triggerType}`); + return; + } + if (!opponentCharacterKey) { + // console.warn(`[Taunt ${this.id}] _sayTaunt: Opponent characterKey is missing for ${characterState.name}. Trigger: ${triggerType}`); + return; + } + if (!gameLogic.getRandomTaunt) { + console.error(`[Taunt ${this.id}] _sayTaunt: gameLogic.getRandomTaunt is not available!`); + return; + } + if (!this.gameState) { + // console.warn(`[Taunt ${this.id}] _sayTaunt: this.gameState is null. Speaker: ${characterState.name}, Trigger: ${triggerType}`); + return; + } + + let context = {}; + let subTrigger = null; + + if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') { + subTrigger = subTriggerOrContext; + } else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) { + context = { ...subTriggerOrContext }; + } + context = { ...context, ...contextOverrides }; + + if ((triggerType === 'selfCastAbility' || triggerType === 'onOpponentAction') && + (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number')) { + context.abilityId = subTriggerOrContext; + subTrigger = subTriggerOrContext; + } else if (triggerType === 'onBattleState' && typeof subTriggerOrContext === 'string') { + subTrigger = subTriggerOrContext; + } else if (triggerType === 'basicAttack' && typeof subTriggerOrContext === 'string') { + subTrigger = subTriggerOrContext; + } + + const opponentFullData = dataUtils.getCharacterData(opponentCharacterKey); + if (!opponentFullData) { + // console.warn(`[Taunt ${this.id}] _sayTaunt: Could not get full data for opponent ${opponentCharacterKey} when ${characterState.name} tries to taunt.`); + return; + } + + const tauntText = gameLogic.getRandomTaunt( + characterState.characterKey, + triggerType, + subTrigger || context, + GAME_CONFIG, + opponentFullData, + this.gameState + ); + + if (tauntText && tauntText !== "(Молчание)") { + this.addToLog(`${characterState.name}: "${tauntText}"`, GAME_CONFIG.LOG_TYPE_INFO); + } + } + addPlayer(socket, chosenCharacterKey = 'elena', identifier) { console.log(`[GameInstance ${this.id}] addPlayer attempt. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`); const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); + if (existingPlayerByIdentifier) { console.warn(`[GameInstance ${this.id}] Identifier ${identifier} already associated with player role ${existingPlayerByIdentifier.id} (socket ${existingPlayerByIdentifier.socket?.id}). Handling as potential reconnect.`); + if (this.gameState && this.gameState.isGameOver) { + console.warn(`[GameInstance ${this.id}] Player ${identifier} trying to (re)join an already finished game. Emitting gameError.`); + socket.emit('gameError', { message: 'Эта игра уже завершена.' }); + this.gameManager._cleanupGame(this.id, `rejoin_attempt_to_finished_game_${identifier}`); + return false; // Изменили возврат на boolean, как ожидает GameManager + } if (existingPlayerByIdentifier.isTemporarilyDisconnected) { return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); } - // Если игра уже завершена, и игрок пытается "добавиться" (что маловероятно, если GameManager.handleRequestGameState работает корректно), - // то addPlayer не должен успешно завершаться. - if (this.gameState && this.gameState.isGameOver) { - socket.emit('gameError', { message: 'Эта игра уже завершена.' }); - return false; - } socket.emit('gameError', { message: 'Вы уже находитесь в этой игре. Попробуйте обновить страницу.' }); - return false; + return false; // Изменили возврат } - if (this.playerCount >= 2) { + if (Object.keys(this.players).length >= 2 && this.playerCount >=2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); - return false; + return false; // Изменили возврат } let assignedPlayerId; - let actualCharacterKey; + let actualCharacterKey = chosenCharacterKey || 'elena'; if (this.mode === 'ai') { - if (this.playerCount > 0) { + if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Проверяем, занят ли слот игрока-человека socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); - return false; + return false; // Изменили возврат } assignedPlayerId = GAME_CONFIG.PLAYER_ID; - actualCharacterKey = 'elena'; - this.ownerIdentifier = identifier; - } else { // PvP - if (!Object.values(this.players).some(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected)) { + // this.ownerIdentifier устанавливается в GameManager + } else { + if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { assignedPlayerId = GAME_CONFIG.PLAYER_ID; - actualCharacterKey = (chosenCharacterKey === 'almagest' || chosenCharacterKey === 'balard') ? chosenCharacterKey : 'elena'; - this.ownerIdentifier = identifier; - } else if (!Object.values(this.players).some(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected)) { + } else if (!this.playerSockets[GAME_CONFIG.OPPONENT_ID]) { assignedPlayerId = GAME_CONFIG.OPPONENT_ID; const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - if (firstPlayerInfo?.chosenCharacterKey === 'elena') actualCharacterKey = 'almagest'; - else if (firstPlayerInfo?.chosenCharacterKey === 'almagest') actualCharacterKey = 'elena'; - else actualCharacterKey = 'balard'; // Default if first player is Balard or something else + if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) { + if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest'; + else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena'; + } } else { socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' }); - return false; + return false; // Изменили возврат } } - const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId); + const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id); if (oldPlayerSocketIdForRole) { - console.log(`[GameInstance ${this.id}] Role ${assignedPlayerId} was previously occupied by socket ${oldPlayerSocketIdForRole}. Removing old entry.`); + const oldPlayerInfo = this.players[oldPlayerSocketIdForRole]; + if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.id); } catch(e){} } delete this.players[oldPlayerSocketIdForRole]; } @@ -107,19 +173,30 @@ class GameInstance { this.playerCount++; socket.join(this.id); - const characterBaseStats = dataUtils.getCharacterBaseStats(actualCharacterKey); - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterBaseStats?.name || 'N/A'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Активных игроков: ${this.playerCount}.`); - return true; + if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.playerCharacterKey = actualCharacterKey; + else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.opponentCharacterKey = actualCharacterKey; + + if (!this.ownerIdentifier && (this.mode === 'ai' || (this.mode === 'pvp' && assignedPlayerId === GAME_CONFIG.PLAYER_ID))) { + this.ownerIdentifier = identifier; + } + + const charData = dataUtils.getCharacterData(actualCharacterKey); + console.log(`[GameInstance ${this.id}] Player ${identifier} (Socket: ${socket.id}) added as ${assignedPlayerId} with char ${charData?.baseStats?.name || actualCharacterKey}. Active players: ${this.playerCount}. Owner: ${this.ownerIdentifier}`); + return true; // Успешное добавление } removePlayer(socketId, reason = "unknown_reason_for_removal") { const playerInfo = this.players[socketId]; if (playerInfo) { const playerRole = playerInfo.id; - console.log(`[GameInstance ${this.id}] Окончательное удаление игрока ${playerInfo.identifier} (сокет: ${socketId}, роль: ${playerRole}). Причина: ${reason}.`); - if (playerInfo.socket) { try { playerInfo.socket.leave(this.id); } catch (e) { /* ignore */ } } + const playerIdentifier = playerInfo.identifier; + console.log(`[GameInstance ${this.id}] Final removal of player ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Reason: ${reason}.`); - if (!playerInfo.isTemporarilyDisconnected) { // Только если он не был уже помечен как "временно отключен" + if (playerInfo.socket) { + try { playerInfo.socket.leave(this.id); } catch (e) { /* ignore */ } + } + + if (!playerInfo.isTemporarilyDisconnected) { this.playerCount--; } @@ -127,92 +204,89 @@ class GameInstance { if (this.playerSockets[playerRole]?.id === socketId) { delete this.playerSockets[playerRole]; } + this.clearReconnectTimer(playerRole); - console.log(`[GameInstance ${this.id}] Игрок ${playerInfo.identifier} удален. Активных игроков: ${this.playerCount}.`); + console.log(`[GameInstance ${this.id}] Player ${playerIdentifier} removed. Active players now: ${this.playerCount}.`); - // Завершаем игру, если она была активна и стала неиграбельной if (this.gameState && !this.gameState.isGameOver) { - const isTurnOfDisconnected = (this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.PLAYER_ID) || - (!this.gameState.isPlayerTurn && playerRole === GAME_CONFIG.OPPONENT_ID); - if (isTurnOfDisconnected) this.turnTimer.clear(); - - if (this.mode === 'ai' && this.playerCount === 0) { - console.log(`[GameInstance ${this.id}] AI игра стала пустой после удаления игрока. Завершение игры.`); - this.endGameDueToDisconnect(playerRole, playerInfo.chosenCharacterKey, "player_left_empty_ai_game"); - } else if (this.mode === 'pvp' && this.playerCount < 2) { - // Убедимся, что остался хотя бы один игрок, чтобы ему присудить победу. - // Если playerCount стал 0, то победителя нет. - const remainingPlayer = Object.values(this.players).find(p => !p.isTemporarilyDisconnected); - const winnerRoleIfAny = remainingPlayer ? remainingPlayer.id : null; - - console.log(`[GameInstance ${this.id}] PvP игра стала неполной (${this.playerCount} игроков) после удаления игрока ${playerInfo.identifier} (роль ${playerRole}).`); - this.endGameDueToDisconnect(playerRole, playerInfo.chosenCharacterKey, "opponent_left_pvp_game", winnerRoleIfAny); + if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { + this.endGameDueToDisconnect(playerRole, playerInfo.chosenCharacterKey, "player_left_ai_game"); + } else if (this.mode === 'pvp') { + const remainingActivePlayer = Object.values(this.players).find(p => p.socket && p.socket.connected && !p.isTemporarilyDisconnected); + if (this.playerCount < 2) { + this.endGameDueToDisconnect(playerRole, playerInfo.chosenCharacterKey, "opponent_left_pvp_game", remainingActivePlayer?.id); + } } + } else if (!this.gameState && Object.keys(this.players).length === 0) { + this.gameManager._cleanupGame(this.id, "all_players_left_before_start_gi"); } - } else { - console.log(`[GameInstance ${this.id}] Попытка удалить игрока по socketId ${socketId}, но он не найден.`); } } handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) { - console.log(`[GameInstance ${this.id}] handlePlayerPotentiallyLeft CALLED for role ${playerIdRole}, identifier ${identifier}, charKey ${characterKey}`); + console.log(`[GameInstance ${this.id}] handlePlayerPotentiallyLeft for role ${playerIdRole}, id ${identifier}, char ${characterKey}`); const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); - if (!playerEntry || !playerEntry.socket) { - console.warn(`[GameInstance ${this.id}] Не найден активный игрок ${identifier} (роль: ${playerIdRole}) для пометки как отключенного.`); - return; - } - if (this.gameState && this.gameState.isGameOver) { - console.log(`[GameInstance ${this.id}] Игра уже завершена, игнорируем 'potentiallyLeft' для ${identifier}.`); - return; - } - if (playerEntry.isTemporarilyDisconnected) { - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (роль ${playerIdRole}) уже помечен как временно отключенный. Таймер должен быть активен.`); - return; - } + if (!playerEntry || !playerEntry.socket) { return; } + if (this.gameState && this.gameState.isGameOver) { return; } + if (playerEntry.isTemporarilyDisconnected) { return; } playerEntry.isTemporarilyDisconnected = true; - this.playerCount--; // Уменьшаем счетчик АКТИВНЫХ игроков - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) помечен как временно отключенный. Активных игроков: ${this.playerCount}. Запуск таймера реконнекта.`); + this.playerCount--; + console.log(`[GameInstance ${this.id}] Player ${identifier} (role ${playerIdRole}) temp disconnected. Active: ${this.playerCount}. Starting reconnect timer.`); - const disconnectedPlayerName = this.gameState?.[playerIdRole]?.name || characterKey || `Игрок (роль ${playerIdRole})`; - const disconnectLogMessage = `🔌 Игрок ${disconnectedPlayerName} отключился. Ожидание переподключения...`; - this.addToLog(disconnectLogMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + const disconnectedName = this.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`; + this.addToLog(`🔌 Игрок ${disconnectedName} отключился. Ожидание переподключения...`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.broadcastLogUpdate(); - this.io.to(this.id).emit('opponentDisconnected', { - disconnectedPlayerId: playerIdRole, - disconnectedCharacterName: disconnectedPlayerName, - }); - this.broadcastLogUpdate(); // Отправляем лог об отключении + const otherPlayerRole = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const otherSocket = this.playerSockets[otherPlayerRole]; + const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole); // Получаем запись другого игрока + if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { // Уведомляем только если другой активен + otherSocket.emit('opponentDisconnected', { + disconnectedPlayerId: playerIdRole, + disconnectedCharacterName: disconnectedName, + }); + } - const currentTurnPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; - if (currentTurnPlayerRole === playerIdRole) { - this.turnTimer.clear(); - console.log(`[GameInstance ${this.id}] Ход был за отключившимся игроком ${playerIdRole}, таймер хода остановлен.`); + if (this.turnTimer.isActive() || (this.mode === 'ai' && this.turnTimer.isAiCurrentlyMakingMove) ) { + this.pausedTurnState = this.turnTimer.pause(); + console.log(`[GameInstance ${this.id}] Turn timer paused due to disconnect. State:`, JSON.stringify(this.pausedTurnState)); + } else { + this.pausedTurnState = null; } this.clearReconnectTimer(playerIdRole); - this.reconnectTimers[playerIdRole] = setTimeout(() => { - console.log(`[GameInstance ${this.id}] Таймер реконнекта для игрока ${identifier} (роль: ${playerIdRole}) истек.`); - delete this.reconnectTimers[playerIdRole]; - const stillDisconnectedPlayerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier && p.isTemporarilyDisconnected); - if (stillDisconnectedPlayerEntry) { - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) не переподключился. Удаляем окончательно.`); - this.removePlayer(stillDisconnectedPlayerEntry.socket.id, "reconnect_timeout"); - } else { - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) уже переподключился или был удален ранее. Таймер истек без действия.`); + const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000; + const reconnectStartTime = Date.now(); + + const updateInterval = setInterval(() => { + const remaining = reconnectDuration - (Date.now() - reconnectStartTime); + if (remaining <= 0) { + if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); + this.io.to(this.id).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); + return; } - }, GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000); + this.io.to(this.id).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: Math.ceil(remaining) }); + }, 1000); + + const timeoutId = setTimeout(() => { + this.clearReconnectTimer(playerIdRole); + const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); + if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) { + this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout"); + } + }, reconnectDuration); + this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; } handlePlayerReconnected(playerIdRole, newSocket) { const identifier = newSocket.userData?.userId; - console.log(`[GameInstance ${this.id}] handlePlayerReconnected CALLED for role ${playerIdRole}, identifier ${identifier}, newSocket ${newSocket.id}`); + console.log(`[GameInstance ${this.id}] handlePlayerReconnected for role ${playerIdRole}, id ${identifier}, newSocket ${newSocket.id}`); if (this.gameState && this.gameState.isGameOver) { - console.warn(`[GameInstance ${this.id}] Игрок ${identifier} (роль ${playerIdRole}) пытается переподключиться к уже ЗАВЕРШЕННОЙ игре. Отправка gameError.`); - newSocket.emit('gameError', { message: 'Не удалось восстановить сессию: игра уже завершена.' }); - // GameManager.handleRequestGameState должен был это перехватить, но на всякий случай. + newSocket.emit('gameError', { message: 'Игра уже завершена.' }); + this.gameManager._cleanupGame(this.id, `reconnect_to_finished_game_gi_${identifier}`); return false; } @@ -220,183 +294,172 @@ class GameInstance { if (playerEntry && playerEntry.isTemporarilyDisconnected) { this.clearReconnectTimer(playerIdRole); - const oldSocketId = playerEntry.socket.id; + this.io.to(this.id).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); - if (this.players[oldSocketId]) { - delete this.players[oldSocketId]; - } + const oldSocketId = playerEntry.socket.id; + if (this.players[oldSocketId] && oldSocketId !== newSocket.id) delete this.players[oldSocketId]; playerEntry.socket = newSocket; playerEntry.isTemporarilyDisconnected = false; this.players[newSocket.id] = playerEntry; - this.playerSockets[playerIdRole] = newSocket; - this.playerCount++; // Восстанавливаем счетчик активных + this.playerCount++; newSocket.join(this.id); - const reconnectedPlayerName = this.gameState?.[playerIdRole]?.name || playerEntry.chosenCharacterKey || `Игрок (роль ${playerIdRole})`; - console.log(`[GameInstance ${this.id}] Игрок ${identifier} (${reconnectedPlayerName}) успешно переподключен с новым сокетом ${newSocket.id}. Старый сокет: ${oldSocketId}. Активных игроков: ${this.playerCount}.`); - - const reconnectLogMessage = `🔌 Игрок ${reconnectedPlayerName} снова в игре!`; - this.addToLog(reconnectLogMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + const reconnectedName = this.gameState?.[playerIdRole]?.name || playerEntry.chosenCharacterKey; + console.log(`[GameInstance ${this.id}] Player ${identifier} (${reconnectedName}) reconnected. Active: ${this.playerCount}.`); + this.addToLog(`🔌 Игрок ${reconnectedName} снова в игре!`, GAME_CONFIG.LOG_TYPE_SYSTEM); const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); - const opponentRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - const oCharKey = this.gameState?.[opponentRoleKey]?.characterKey; + const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + let oCharKey = this.gameState?.[oppRoleKey]?.characterKey || (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.opponentCharacterKey : this.playerCharacterKey); const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; - const logForReconnectedPlayer = this.consumeLogBuffer(); - newSocket.emit('gameStarted', { // Используем 'gameStarted' для восстановления, как ожидает клиент - gameId: this.id, - yourPlayerId: playerIdRole, - initialGameState: this.gameState, - playerBaseStats: pData.baseStats, + if (!this.gameState) { + if (!this.initializeGame()) { this._handleCriticalError('reconnect_no_gs_after_init_v2', 'GS null after re-init.'); return false; } + } + + newSocket.emit('gameStarted', { + gameId: this.id, yourPlayerId: playerIdRole, initialGameState: this.gameState, + playerBaseStats: pData?.baseStats, opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, - playerAbilities: pData.abilities, - opponentAbilities: oData?.abilities || [], - log: logForReconnectedPlayer, - clientConfig: { ...GAME_CONFIG } + playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities || [], + log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } }); - const otherPlayerSocket = Object.values(this.players).find(p => - p.id !== playerIdRole && - p.socket && p.socket.connected && - !p.isTemporarilyDisconnected - )?.socket; - - if (otherPlayerSocket) { - otherPlayerSocket.emit('playerReconnected', { - playerId: playerIdRole, - playerName: reconnectedPlayerName - }); - // Логи, которые могли накопиться для другого игрока (например, сообщение о реконнекте), уйдут со следующим broadcastGameStateUpdate + const otherSocket = this.playerSockets[oppRoleKey]; + const otherPlayerEntry = Object.values(this.players).find(p=> p.id === oppRoleKey); + if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { + otherSocket.emit('playerReconnected', { reconnectedPlayerId: playerIdRole, reconnectedPlayerName: reconnectedName }); + if (this.logBuffer.length > 0) otherSocket.emit('logUpdate', { log: this.consumeLogBuffer() }); } if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver) { - console.log(`[GameInstance ${this.id}] Игра возобновлена после переподключения ${identifier} (роль: ${playerIdRole}). Отправка gameStateUpdate всем и перезапуск таймера.`); - this.broadcastGameStateUpdate(); // Отправит оставшиеся логи - - const currentTurnPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; - const otherPlayerEntry = Object.values(this.players).find(p => p.id !== playerIdRole); // Проверяем другого игрока в целом - - // Таймер запускаем, если сейчас ход реконнектнувшегося ИЛИ если другой игрок активен (не isTemporarilyDisconnected) - if (currentTurnPlayerRole === playerIdRole || (otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected)) { - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); + this.broadcastGameStateUpdate(); + if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') { + this.turnTimer.resume( + this.pausedTurnState.remainingTime, + this.pausedTurnState.forPlayerRoleIsPlayer, + this.pausedTurnState.isAiCurrentlyMoving + ); + this.pausedTurnState = null; } else { - console.log(`[GameInstance ${this.id}] Другой игрок (${otherPlayerEntry?.id}) отключен, таймер хода не запускается после реконнекта ${playerIdRole}.`); + const currentTurnIsForPlayer = this.gameState.isPlayerTurn; + const isCurrentTurnAi = this.mode === 'ai' && !currentTurnIsForPlayer; + this.turnTimer.start(currentTurnIsForPlayer, isCurrentTurnAi); } } return true; } else if (playerEntry && !playerEntry.isTemporarilyDisconnected) { - console.warn(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) пытается переподключиться, но не был помечен как отключенный.`); if (playerEntry.socket.id !== newSocket.id) { - newSocket.emit('gameError', {message: "Вы уже активно подключены к этой игре."}); + newSocket.emit('gameError', {message: "Вы уже активно подключены с другой сессии."}); return false; } - return false; + if (!this.gameState) { if (!this.initializeGame()) {this._handleCriticalError('reconnect_same_socket_no_gs','GS null on same socket'); return false;} } + const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); /* ... как выше ... */ + const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + let oCharKey = this.gameState?.[oppRoleKey]?.characterKey || (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.opponentCharacterKey : this.playerCharacterKey); + const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; + newSocket.emit('gameStarted', { /* ... как выше ... */ + gameId: this.id, yourPlayerId: playerIdRole, initialGameState: this.gameState, + playerBaseStats: pData?.baseStats, opponentBaseStats: oData?.baseStats, + playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities, + log: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + }); + return true; } else { - console.warn(`[GameInstance ${this.id}] Не удалось найти игрока ${identifier} (роль: ${playerIdRole}) для переподключения, или он не был помечен как отключенный.`); - newSocket.emit('gameError', { message: 'Не удалось восстановить сессию в этой игре.'}); + newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена).' }); return false; } } - clearReconnectTimer(playerIdRole) { + clearReconnectTimer(playerIdRole) { /* ... Код без изменений ... */ if (this.reconnectTimers[playerIdRole]) { - clearTimeout(this.reconnectTimers[playerIdRole]); + clearTimeout(this.reconnectTimers[playerIdRole].timerId); + if (this.reconnectTimers[playerIdRole].updateIntervalId) { + clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); + } delete this.reconnectTimers[playerIdRole]; - console.log(`[GameInstance ${this.id}] Таймер реконнекта для роли ${playerIdRole} очищен.`); + // console.log(`[GameInstance ${this.id}] Reconnect timer & interval for role ${playerIdRole} cleared.`); } } - - clearAllReconnectTimers() { - console.log(`[GameInstance ${this.id}] Очистка ВСЕХ таймеров реконнекта.`); + clearAllReconnectTimers() { /* ... Код без изменений ... */ + // console.log(`[GameInstance ${this.id}] Clearing ALL reconnect timers.`); for (const roleId in this.reconnectTimers) { this.clearReconnectTimer(roleId); } } - - isGameEffectivelyPaused() { + isGameEffectivelyPaused() { /* ... Код без изменений ... */ if (this.mode === 'pvp') { - // Игра на паузе, если хотя бы один из ДВУХ ожидаемых игроков временно отключен - const player1 = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - const player2 = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); - return (player1?.isTemporarilyDisconnected || false) || (player2?.isTemporarilyDisconnected || false); + if (this.playerCount < 2 && Object.keys(this.players).length > 0) { // Если игроков меньше двух, но хотя бы один есть + const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); + if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) { + return true; + } + } } else if (this.mode === 'ai') { const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - return humanPlayer?.isTemporarilyDisconnected ?? (this.playerCount === 0 && Object.keys(this.players).length > 0); + return humanPlayer?.isTemporarilyDisconnected ?? false; } - return false; // По умолчанию не на паузе + return false; } initializeGame() { - console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Активных игроков: ${this.playerCount}. Всего записей в players: ${Object.keys(this.players).length}`); - if (this.mode === 'ai' && this.playerCount === 1) { - const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); - this.playerCharacterKey = p1Info?.chosenCharacterKey || 'elena'; + console.log(`[GameInstance ${this.id}] Initializing game state. Mode: ${this.mode}. Active players: ${this.playerCount}. Total entries: ${Object.keys(this.players).length}`); + + const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); + const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); + + if (this.mode === 'ai') { + if (!p1Entry) { this._handleCriticalError('init_ai_no_active_player_v2', 'AI game init: Human player not found or not active.'); return false; } + this.playerCharacterKey = p1Entry.chosenCharacterKey; this.opponentCharacterKey = 'balard'; - } else if (this.mode === 'pvp') { - const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); - const p2Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); - - if (p1Info) this.playerCharacterKey = p1Info.chosenCharacterKey; - else this.playerCharacterKey = null; - - if (p2Info) this.opponentCharacterKey = p2Info.chosenCharacterKey; - else this.opponentCharacterKey = null; - - if (this.playerCount === 1 && p1Info && !p2Info) { - // this.opponentCharacterKey остается null - } else if (this.playerCount === 2 && (!p1Info || !p2Info)) { - console.error(`[GameInstance ${this.id}] Ошибка инициализации PvP: playerCount=2, но один из игроков не найден как активный.`); - return false; - } else if (this.playerCount < 2 && !p1Info) { - console.log(`[GameInstance ${this.id}] Инициализация PvP без активного первого игрока. playerCharacterKey будет ${this.playerCharacterKey}.`); - } } else { - console.error(`[GameInstance ${this.id}] Некорректное состояние для инициализации! Активных: ${this.playerCount}`); return false; + this.playerCharacterKey = p1Entry ? p1Entry.chosenCharacterKey : null; + this.opponentCharacterKey = p2Entry ? p2Entry.chosenCharacterKey : null; + + if (this.playerCount === 2 && (!this.playerCharacterKey || !this.opponentCharacterKey)) { + console.error(`[GameInstance ${this.id}] PvP init error: playerCount is 2, but keys not set. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}. P1Info: ${!!p1Entry}, P2Info: ${!!p2Entry}`); + this._handleCriticalError('init_pvp_char_key_missing_v2', `PvP init: playerCount is 2, but a charKey is missing.`); + return false; + } } const playerData = this.playerCharacterKey ? dataUtils.getCharacterData(this.playerCharacterKey) : null; - let opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null; - const isOpponentDefined = !!this.opponentCharacterKey; + const opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null; - if (!playerData && (this.mode === 'ai' || (this.mode === 'pvp' && this.playerCount > 0))) { - this._handleCriticalError('init_player_data_fail', 'Ошибка загрузки данных основного игрока при инициализации.'); - return false; - } - if (isOpponentDefined && !opponentData) { - this._handleCriticalError('init_opponent_data_fail', 'Ошибка загрузки данных оппонента при инициализации.'); - return false; + const isPlayerSlotFilledAndActive = !!playerData; + const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2Entry)); + + if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !isOpponentSlotFilledAndActive) ) { + this._handleCriticalError('init_ai_data_fail_gs_v2', 'AI game init: Failed to load player or AI data for gameState (active check).'); return false; } + this.logBuffer = []; + this.gameState = { - player: playerData ? + player: isPlayerSlotFilledAndActive ? this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities) : - this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: 'Ожидание игрока 1...', maxHp: 1, maxResource: 0, resourceName: 'Ресурс', attackPower: 0, characterKey: null }, []), - opponent: isOpponentDefined && opponentData ? + this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: 'Ожидание Игрока 1...', maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, []), + opponent: isOpponentSlotFilledAndActive ? this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities) : - this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: 'Ожидание игрока 2...', maxHp: 1, maxResource: 0, resourceName: 'Ресурс', attackPower: 0, characterKey: null }, []), - isPlayerTurn: isOpponentDefined ? Math.random() < 0.5 : true, - isGameOver: false, turnNumber: 1, gameMode: this.mode + this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: 'Ожидание Игрока 2...', maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, []), + isPlayerTurn: isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive ? Math.random() < 0.5 : true, + isGameOver: false, + turnNumber: 1, + gameMode: this.mode }; - if (isOpponentDefined) { - this.logBuffer = []; + if (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) { this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); - const pCharKey = this.gameState.player.characterKey; - const oCharKey = this.gameState.opponent.characterKey; - if ((pCharKey === 'elena' || pCharKey === 'almagest') && oCharKey) { - const opponentFullDataForTaunt = dataUtils.getCharacterData(oCharKey); - const startTaunt = gameLogic.getRandomTaunt(pCharKey, 'battleStart', {}, GAME_CONFIG, opponentFullDataForTaunt, this.gameState); - if (startTaunt !== "(Молчание)") this.addToLog(`${this.gameState.player.name}: "${startTaunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - } } - console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Готовность к старту (isOpponentDefined): ${isOpponentDefined}`); - return isOpponentDefined; + + console.log(`[GameInstance ${this.id}] Game state initialized. Player: ${this.gameState.player.name} (Key: ${this.playerCharacterKey}). Opponent: ${this.gameState.opponent.name} (Key: ${this.opponentCharacterKey}). Ready for start: ${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`); + return isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive; } - _createFighterState(roleId, baseStats, abilities) { + _createFighterState(roleId, baseStats, abilities) { /* ... Код без изменений ... */ const fighterState = { id: roleId, characterKey: baseStats.characterKey, name: baseStats.name, currentHp: baseStats.maxHp, maxHp: baseStats.maxHp, @@ -418,406 +481,393 @@ class GameInstance { startGame() { if (this.isGameEffectivelyPaused()) { - console.log(`[GameInstance ${this.id}] Попытка старта игры, но она на паузе из-за дисконнекта. Старт отложен.`); + console.log(`[GameInstance ${this.id}] Start game deferred: game effectively paused.`); return; } - if (!this.gameState || !this.gameState.opponent?.characterKey) { - this._handleCriticalError('start_game_not_ready', 'Попытка старта не полностью готовой игры (нет оппонента).'); - return; + if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) { + console.warn(`[GameInstance ${this.id}] startGame: GS or char keys not fully initialized. PKey: ${this.gameState?.player?.characterKey}, OKey: ${this.gameState?.opponent?.characterKey}. Attempting re-init.`); + if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { + this._handleCriticalError('start_game_reinit_failed_sg_v3', 'Re-init before start failed or keys still missing.'); + return; + } } - console.log(`[GameInstance ${this.id}] Запуск игры.`); + console.log(`[GameInstance ${this.id}] Starting game. Player in GS: ${this.gameState.player.name}, Opponent in GS: ${this.gameState.opponent.name}`); const pData = dataUtils.getCharacterData(this.playerCharacterKey); const oData = dataUtils.getCharacterData(this.opponentCharacterKey); - if (!pData || !oData) { this._handleCriticalError('start_char_data_fail', 'Ошибка данных персонажей при старте.'); return; } + + if (!pData || !oData) { + this._handleCriticalError('start_char_data_fail_sg_v4', `Failed to load char data at game start. P: ${!!pData}, O: ${!!oData}`); + return; + } + + // --- Начальные насмешки --- + if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { + // Убедимся, что gameState.player и .opponent существуют для передачи в _sayTaunt + if (this.gameState.player && this.gameState.opponent) { + this._sayTaunt(this.gameState.player, this.gameState.opponent.characterKey, 'onBattleState', 'start'); + this._sayTaunt(this.gameState.opponent, this.gameState.player.characterKey, 'onBattleState', 'start'); + } + } const initialLog = this.consumeLogBuffer(); Object.values(this.players).forEach(playerInfo => { if (playerInfo.socket?.connected && !playerInfo.isTemporarilyDisconnected) { - const dataForClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ? + const dataForThisClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ? { playerBaseStats: pData.baseStats, opponentBaseStats: oData.baseStats, playerAbilities: pData.abilities, opponentAbilities: oData.abilities } : { playerBaseStats: oData.baseStats, opponentBaseStats: pData.baseStats, playerAbilities: oData.abilities, opponentAbilities: pData.abilities }; + playerInfo.socket.emit('gameStarted', { gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, - ...dataForClient, log: [...initialLog], - clientConfig: { ...GAME_CONFIG } + ...dataForThisClient, log: [...initialLog], clientConfig: { ...GAME_CONFIG } }); } }); const firstTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; - this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${firstTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${firstTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); this.broadcastLogUpdate(); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); - if (!this.gameState.isPlayerTurn && this.aiOpponent) { + const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; + this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi); + + if (isFirstTurnAi) { setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); } } - processPlayerAction(requestingSocketId, actionData) { + processPlayerAction(identifier, actionData) { + const actingPlayerInfo = Object.values(this.players).find(p => p.identifier === identifier); + if (!actingPlayerInfo || !actingPlayerInfo.socket) { + console.error(`[GameInstance ${this.id}] Action from unknown or socketless identifier ${identifier}.`); return; + } + // const requestingSocketId = actingPlayerInfo.socket.id; + if (this.isGameEffectivelyPaused()) { - console.warn(`[GameInstance ${this.id}] Действие от сокета ${requestingSocketId}, но игра на паузе. Действие отклонено.`); - const playerInfo = this.players[requestingSocketId]; - if (playerInfo?.socket) playerInfo.socket.emit('gameError', {message: "Действие невозможно: другой игрок отключен."}); + actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."}); return; } - if (!this.gameState || this.gameState.isGameOver) return; - const actingPlayerInfo = this.players[requestingSocketId]; - if (!actingPlayerInfo) { console.error(`[GameInstance ${this.id}] Действие от неизвестного сокета ${requestingSocketId}`); return; } + if (!this.gameState || this.gameState.isGameOver) { return; } const actingPlayerRole = actingPlayerInfo.id; const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); - if (!isCorrectTurn) { console.warn(`[GameInstance ${this.id}] Действие от ${actingPlayerInfo.identifier}: не его ход.`); return; } + if (!isCorrectTurn) { actingPlayerInfo.socket.emit('gameError', { message: "Не ваш ход." }); return; } - this.turnTimer.clear(); + if(this.turnTimer.isActive()) this.turnTimer.clear(); const attackerState = this.gameState[actingPlayerRole]; const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const defenderState = this.gameState[defenderRole]; + + if (!attackerState || !attackerState.characterKey || !defenderState || !defenderState.characterKey) { + this._handleCriticalError('action_actor_state_invalid_v3', `Attacker or Defender state/key invalid. Attacker: ${attackerState?.characterKey}, Defender: ${defenderState?.characterKey}`); return; + } const attackerData = dataUtils.getCharacterData(attackerState.characterKey); const defenderData = dataUtils.getCharacterData(defenderState.characterKey); + if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail_process_v3', 'Ошибка данных персонажа при действии.'); return; } - if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail', 'Ошибка данных персонажа при действии.'); return; } - - let actionValid = true; - let tauntContextTargetData = defenderData; + let actionIsValidAndPerformed = false; if (actionData.actionType === 'attack') { - const taunt = gameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, tauntContextTargetData, this.gameState); - if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - gameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); - const delayedBuff = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); - if (delayedBuff && !delayedBuff.justCast) { - const regen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); + this._sayTaunt(attackerState, defenderState.characterKey, 'basicAttack'); + gameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt); + actionIsValidAndPerformed = true; + const delayedBuff = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && !eff.justCast); + if (delayedBuff) { + const manaRegenConfig = GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN || 0; + const regen = Math.min(manaRegenConfig, attackerData.baseStats.maxResource - attackerState.currentResource); if (regen > 0) { attackerState.currentResource = Math.round(attackerState.currentResource + regen); this.addToLog(`🌿 ${attackerState.name} восстанавливает ${regen} ${attackerState.resourceName} от "${delayedBuff.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); } + delayedBuff.turnsLeft = 0; } } else if (actionData.actionType === 'ability' && actionData.abilityId) { const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); if (!ability) { - actionValid = false; actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); - return; - } - const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG); - if (!validityCheck.isValid) { - this.addToLog(validityCheck.reason, GAME_CONFIG.LOG_TYPE_INFO); - actionValid = false; - } - - if (actionValid) { - attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); - const taunt = gameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, tauntContextTargetData, this.gameState); - if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - gameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); - gameLogic.setAbilityCooldown(ability, attackerState, GAME_CONFIG); + } else { + const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG); + if (validityCheck.isValid) { + console.log(`[GameInstance Taunt Pre-Call] SelfCast: ${attackerState.name} uses ${ability.name} (${ability.id}) against ${defenderState.name} (${defenderState.characterKey})`); + this._sayTaunt(attackerState, defenderState.characterKey, 'selfCastAbility', ability.id); + attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); + gameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null); + gameLogic.setAbilityCooldown(ability, attackerState, GAME_CONFIG); + actionIsValidAndPerformed = true; + } else { + this.addToLog(validityCheck.reason || `${attackerState.name} не может использовать "${ability.name}".`, GAME_CONFIG.LOG_TYPE_INFO); + actionIsValidAndPerformed = false; + } } } else { - console.warn(`[GameInstance ${this.id}] Неизвестный тип действия: ${actionData?.actionType}`); - actionValid = false; + actionIsValidAndPerformed = false; } - if (this.checkGameOver()) { - return; - } - if (actionValid) { - this.broadcastLogUpdate(); + if (this.checkGameOver()) return; + this.broadcastLogUpdate(); + if (actionIsValidAndPerformed) { setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } else { - this.broadcastLogUpdate(); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); + const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn; + this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer); } } - switchTurn() { - if (this.isGameEffectivelyPaused()) { - console.log(`[GameInstance ${this.id}] Попытка сменить ход, но игра на паузе. Смена хода отложена.`); - return; - } - if (!this.gameState || this.gameState.isGameOver) return; - this.turnTimer.clear(); + switchTurn() { /* ... Код без изменений ... */ + if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Switch turn deferred: game paused.`); return; } + if (!this.gameState || this.gameState.isGameOver) { return; } + if(this.turnTimer.isActive()) this.turnTimer.clear(); const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; - const endingTurnActor = this.gameState[endingTurnActorRole]; - const endingTurnData = dataUtils.getCharacterData(endingTurnActor.characterKey); + const endingTurnActorState = this.gameState[endingTurnActorRole]; + if (!endingTurnActorState || !endingTurnActorState.characterKey) { this._handleCriticalError('switch_turn_ending_actor_invalid', `Ending turn actor state or key invalid for role ${endingTurnActorRole}.`); return; } + const endingTurnActorData = dataUtils.getCharacterData(endingTurnActorState.characterKey); + if (!endingTurnActorData) { this._handleCriticalError('switch_turn_char_data_fail', `Char data missing for ${endingTurnActorState.characterKey}.`); return; } - if (!endingTurnData) { this._handleCriticalError('switch_turn_data_fail', 'Ошибка данных при смене хода.'); return; } + gameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnActorData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils); + gameLogic.updateBlockingStatus(endingTurnActorState); + if (endingTurnActorState.abilityCooldowns && endingTurnActorData.abilities) gameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG); + if (endingTurnActorState.characterKey === 'balard') gameLogic.processBalardSpecialCooldowns(endingTurnActorState); + if (endingTurnActorState.disabledAbilities?.length > 0 && endingTurnActorData.abilities) gameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG); - gameLogic.processEffects(endingTurnActor.activeEffects, endingTurnActor, endingTurnData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils); - gameLogic.updateBlockingStatus(this.gameState.player); - gameLogic.updateBlockingStatus(this.gameState.opponent); - if (endingTurnActor.abilityCooldowns && endingTurnData.abilities) gameLogic.processPlayerAbilityCooldowns(endingTurnActor.abilityCooldowns, endingTurnData.abilities, endingTurnActor.name, this.addToLog.bind(this), GAME_CONFIG); - if (endingTurnActor.characterKey === 'balard') gameLogic.processBalardSpecialCooldowns(endingTurnActor); - if (endingTurnActor.disabledAbilities?.length > 0 && endingTurnData.abilities) gameLogic.processDisabledAbilities(endingTurnActor.disabledAbilities, endingTurnData.abilities, endingTurnActor.name, this.addToLog.bind(this), GAME_CONFIG); - - if (this.checkGameOver()) { - return; - } + if (this.checkGameOver()) return; this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; - const currentTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; - this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); - this.broadcastGameStateUpdate(); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); + const currentTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + const currentTurnActorState = this.gameState[currentTurnActorRole]; + if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid', `Current turn actor state or name invalid for role ${currentTurnActorRole}.`); return; } + const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole); - if (!this.gameState.isPlayerTurn && this.aiOpponent) { - setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); + this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastGameStateUpdate(); + + if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) { + console.log(`[GameInstance ${this.id}] Turn switched to ${currentTurnActorRole}, but player disconnected. Timer not started.`); + } else { + const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; + this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi); + if (isNextTurnAi) setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); } } + processAiTurn() { /* ... Код без изменений ... */ + if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] AI turn deferred: game paused.`); return; } + if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; } + if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) { console.error(`[GameInstance ${this.id}] AI is not Balard!`); this.switchTurn(); return; } + if(this.turnTimer.isActive()) this.turnTimer.clear(); - processAiTurn() { - if (this.isGameEffectivelyPaused()) { - console.log(`[GameInstance ${this.id}] Попытка хода AI, но игра на паузе. Ход AI отложен.`); - return; - } - if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.gameState.opponent?.characterKey !== 'balard') { - if (this.gameState && !this.gameState.isGameOver) this.switchTurn(); - return; - } - - this.turnTimer.clear(); - const attacker = this.gameState.opponent; - const defender = this.gameState.player; - const attackerData = dataUtils.getCharacterData('balard'); - const defenderData = dataUtils.getCharacterData(defender.characterKey); - - if (!attackerData || !defenderData) { this._handleCriticalError('ai_char_data_fail', 'Ошибка данных AI.'); this.switchTurn(); return; } - - if (gameLogic.isCharacterFullySilenced(attacker, GAME_CONFIG)) { - this.addToLog(`😵 ${attacker.name} под действием Безмолвия! Атакует в смятении.`, GAME_CONFIG.LOG_TYPE_EFFECT); - gameLogic.performAttack(attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, defenderData); - if (this.checkGameOver()) { return; } - this.broadcastLogUpdate(); - setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); - return; - } + const aiState = this.gameState.opponent; + const playerState = this.gameState.player; + if (!playerState || !playerState.characterKey) { this._handleCriticalError('ai_turn_player_state_invalid', 'Player state invalid for AI taunt.'); return; } const aiDecision = gameLogic.decideAiAction(this.gameState, dataUtils, GAME_CONFIG, this.addToLog.bind(this)); - let tauntContextTargetData = defenderData; + let actionIsValidAndPerformedForAI = false; if (aiDecision.actionType === 'attack') { - gameLogic.performAttack(attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); + this._sayTaunt(aiState, playerState.characterKey, 'basicAttack'); + gameLogic.performAttack(aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt); + actionIsValidAndPerformedForAI = true; } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { - attacker.currentResource = Math.round(attacker.currentResource - aiDecision.ability.cost); - gameLogic.applyAbilityEffect(aiDecision.ability, attacker, defender, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, tauntContextTargetData); - gameLogic.setAbilityCooldown(aiDecision.ability, attacker, GAME_CONFIG); + this._sayTaunt(aiState, playerState.characterKey, 'selfCastAbility', aiDecision.ability.id); + aiState.currentResource = Math.round(aiState.currentResource - aiDecision.ability.cost); + gameLogic.applyAbilityEffect(aiDecision.ability, aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null); + gameLogic.setAbilityCooldown(aiDecision.ability, aiState, GAME_CONFIG); + actionIsValidAndPerformedForAI = true; + } else if (aiDecision.actionType === 'pass') { + if (aiDecision.logMessage && this.addToLog) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type); + else if (this.addToLog) this.addToLog(`${aiState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); + actionIsValidAndPerformedForAI = true; } - if (this.checkGameOver()) { - return; - } + if (this.checkGameOver()) return; this.broadcastLogUpdate(); - setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + if (actionIsValidAndPerformedForAI) setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + else { console.error(`[GameInstance ${this.id}] AI failed action. Forcing switch.`); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } } - checkGameOver() { + checkGameOver() { /* ... Код без изменений ... */ if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true; - if (this.mode === 'pvp' && (!this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey)) { - return false; + if (!this.gameState.isGameOver && this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { + const player = this.gameState.player; const opponent = this.gameState.opponent; + const pData = dataUtils.getCharacterData(player.characterKey); const oData = dataUtils.getCharacterData(opponent.characterKey); + if (pData && oData) { + const nearDefeatThreshold = GAME_CONFIG.OPPONENT_NEAR_DEFEAT_THRESHOLD_PERCENT || 0.2; + if (opponent.currentHp > 0 && (opponent.currentHp / oData.baseStats.maxHp) <= nearDefeatThreshold) this._sayTaunt(player, opponent.characterKey, 'onBattleState', 'opponentNearDefeat'); + if (player.currentHp > 0 && (player.currentHp / pData.baseStats.maxHp) <= nearDefeatThreshold) this._sayTaunt(opponent, player.characterKey, 'onBattleState', 'opponentNearDefeat'); + } } - if (this.mode === 'ai' && !this.gameState.player?.characterKey) return false; const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode); if (gameOverResult.isOver) { this.gameState.isGameOver = true; - this.turnTimer.clear(); + if(this.turnTimer.isActive()) this.turnTimer.clear(); this.clearAllReconnectTimers(); this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); - - const winnerState = this.gameState[gameOverResult.winnerRole]; - const loserState = this.gameState[gameOverResult.loserRole]; - if (winnerState && (winnerState.characterKey === 'elena' || winnerState.characterKey === 'almagest') && loserState) { - const loserFullData = dataUtils.getCharacterData(loserState.characterKey); - if (loserFullData) { - const taunt = gameLogic.getRandomTaunt(winnerState.characterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, loserFullData, this.gameState); - if (taunt !== "(Молчание)") this.addToLog(`${winnerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - } - } - if (loserState) { - if (loserState.characterKey === 'balard') this.addToLog(`Елена исполнила свой тяжкий долг. ${loserState.name} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); - else if (loserState.characterKey === 'almagest') this.addToLog(`Над полем битвы воцаряется тишина. ${loserState.name} побежден(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); - else if (loserState.characterKey === 'elena') this.addToLog(`Свет погас. ${loserState.name} повержен(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); - } - - console.log(`[GameInstance ${this.id}] Игра окончена. Победитель: ${gameOverResult.winnerRole || 'Нет'}. Причина: ${gameOverResult.reason}.`); - this.io.to(this.id).emit('gameOver', { - winnerId: gameOverResult.winnerRole, reason: gameOverResult.reason, - finalGameState: this.gameState, log: this.consumeLogBuffer(), - loserCharacterKey: loserState?.characterKey || 'unknown' - }); - this.gameManager._cleanupGame(this.id, gameOverResult.reason); + const winnerState = this.gameState[gameOverResult.winnerRole]; const loserState = this.gameState[gameOverResult.loserRole]; + if (winnerState?.characterKey && loserState?.characterKey) this._sayTaunt(winnerState, loserState.characterKey, 'onBattleState', 'opponentNearDefeat'); + if (loserState?.characterKey) { /* ... сюжетные логи ... */ } + console.log(`[GameInstance ${this.id}] Game over. Winner: ${gameOverResult.winnerRole || 'None'}. Reason: ${gameOverResult.reason}.`); + this.io.to(this.id).emit('gameOver', { winnerId: gameOverResult.winnerRole, reason: gameOverResult.reason, finalGameState: this.gameState, log: this.consumeLogBuffer(), loserCharacterKey: loserState?.characterKey || 'unknown'}); + this.gameManager._cleanupGame(this.id, `game_ended_${gameOverResult.reason}`); return true; } return false; } - - endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { + endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { /* ... Код без изменений ... */ if (this.gameState && !this.gameState.isGameOver) { this.gameState.isGameOver = true; - this.turnTimer.clear(); + if(this.turnTimer.isActive()) this.turnTimer.clear(); this.clearAllReconnectTimers(); - - const actualWinnerRole = winnerIfAny !== null ? winnerIfAny : - (disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID); - - const winnerExists = Object.values(this.players).some(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected) || - (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID && this.gameState.opponent?.characterKey); - - const result = gameLogic.getGameOverResult( - this.gameState, GAME_CONFIG, this.mode, reason, - winnerExists ? actualWinnerRole : null, - disconnectedPlayerRole - ); - + const actualWinnerRole = winnerIfAny !== null ? winnerIfAny : (disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID); + let winnerActuallyExists = false; + if (actualWinnerRole) { + const winnerPlayerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole); + if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) winnerActuallyExists = !!this.gameState.opponent?.characterKey; + else if (winnerPlayerEntry && !winnerPlayerEntry.isTemporarilyDisconnected) winnerActuallyExists = true; + } + const finalWinnerRole = winnerActuallyExists ? actualWinnerRole : null; + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, reason, finalWinnerRole, disconnectedPlayerRole); this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); - - console.log(`[GameInstance ${this.id}] Игра завершена из-за дисконнекта/ухода. Причина: ${reason}. Победитель: ${result.winnerRole || 'Нет'}. Отключился/ушел: ${disconnectedPlayerRole}.`); - this.io.to(this.id).emit('gameOver', { - winnerId: result.winnerRole, reason: result.reason, - finalGameState: this.gameState, log: this.consumeLogBuffer(), - loserCharacterKey: disconnectedCharacterKey - }); - this.gameManager._cleanupGame(this.id, result.reason); - } else if (this.gameState && this.gameState.isGameOver) { - console.log(`[GameInstance ${this.id}] Попытка завершить игру из-за дисконнекта, но она уже завершена.`); - } + console.log(`[GameInstance ${this.id}] Game ended: ${reason}. Winner: ${result.winnerRole || 'Нет'}.`); + this.io.to(this.id).emit('gameOver', { winnerId: result.winnerRole, reason: result.reason, finalGameState: this.gameState, log: this.consumeLogBuffer(), loserCharacterKey: disconnectedCharacterKey, disconnectedCharacterName: reason === 'opponent_disconnected' || reason === 'player_left_ai_game' ? (this.gameState[disconnectedPlayerRole]?.name || disconnectedCharacterKey) : undefined }); + this.gameManager._cleanupGame(this.id, `disconnect_game_ended_${result.reason}`); + } else if (this.gameState?.isGameOver) { console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: already over.`); this.gameManager._cleanupGame(this.id, `already_over_on_disconnect_cleanup`); } + else { console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: no gameState.`); this.gameManager._cleanupGame(this.id, `no_gamestate_on_disconnect_cleanup`); } + } + + playerExplicitlyLeftAiGame(identifier) { + if (this.mode !== 'ai' || (this.gameState && this.gameState.isGameOver)) { + console.log(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame called, but not AI mode or game over. Identifier: ${identifier}`); + if (this.gameState?.isGameOver) this.gameManager._cleanupGame(this.id, `player_left_ai_already_over`); + return; + } + const playerEntry = Object.values(this.players).find(p => p.identifier === identifier); + if (!playerEntry || playerEntry.id !== GAME_CONFIG.PLAYER_ID) { + console.warn(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame: Identifier ${identifier} is not the human player or not found.`); + return; + } + + console.log(`[GameInstance ${this.id}] Player ${identifier} explicitly left AI game.`); + if (this.gameState) { + this.gameState.isGameOver = true; + this.addToLog(`Игрок покинул битву с ${this.gameState.opponent?.name || 'AI'}.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else { + this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } + + if (this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + + this.gameManager._cleanupGame(this.id, 'player_left_ai_explicitly'); } - // --- НАЧАЛО ИЗМЕНЕНИЯ --- playerDidSurrender(surrenderingPlayerIdentifier) { console.log(`[GameInstance ${this.id}] playerDidSurrender called for identifier: ${surrenderingPlayerIdentifier}`); if (!this.gameState || this.gameState.isGameOver) { - console.warn(`[GameInstance ${this.id}] Игрок ${surrenderingPlayerIdentifier} попытался сдаться, но игра неактивна или уже завершена.`); - return; - } - - if (this.mode !== 'pvp') { - console.log(`[GameInstance ${this.id}] Игрок ${surrenderingPlayerIdentifier} сдался в не-PvP игре. Просто завершаем игру, если это AI режим и игрок один.`); - if (this.mode === 'ai') { - const playerInfo = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier); - if (playerInfo) { - this.endGameDueToDisconnect(playerInfo.id, playerInfo.chosenCharacterKey, "player_left_ai_game"); - } else { - this.gameManager._cleanupGame(this.id, "surrender_ai_player_not_found"); - } - } + if (this.gameState?.isGameOver) { this.gameManager._cleanupGame(this.id, `surrender_on_finished`); } + console.warn(`[GameInstance ${this.id}] Surrender attempt on inactive/finished game by ${surrenderingPlayerIdentifier}.`); return; } const surrenderedPlayerEntry = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier); if (!surrenderedPlayerEntry) { - console.error(`[GameInstance ${this.id}] Не найден игрок с identifier ${surrenderingPlayerIdentifier} для обработки сдачи.`); + console.error(`[GameInstance ${this.id}] Surrendering player ${surrenderingPlayerIdentifier} not found in this.players.`); return; } - const surrenderedPlayerRole = surrenderedPlayerEntry.id; - const surrenderedPlayerName = this.gameState[surrenderedPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey || `Игрок (ID: ${surrenderingPlayerIdentifier})`; - const surrenderedPlayerCharKey = this.gameState[surrenderedPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey; + const surrenderingPlayerRole = surrenderedPlayerEntry.id; // ОПРЕДЕЛЯЕМ ЗДЕСЬ - const winnerRole = surrenderedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - const winnerName = this.gameState[winnerRole]?.name || `Оппонент (Роль: ${winnerRole})`; + if (this.mode === 'ai') { + if (surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID) { + console.log(`[GameInstance ${this.id}] Player ${surrenderingPlayerIdentifier} "surrendered" (left) AI game.`); + this.playerExplicitlyLeftAiGame(surrenderingPlayerIdentifier); + } else { + console.warn(`[GameInstance ${this.id}] Surrender in AI mode from non-player (role: ${surrenderingPlayerRole}) or unexpected: ${surrenderingPlayerIdentifier}`); + } + return; + } + + if (this.mode !== 'pvp') { + console.warn(`[GameInstance ${this.id}] Surrender called in non-PvP, non-AI mode: ${this.mode}. Ignoring.`); + return; + } + + const surrenderedPlayerName = this.gameState[surrenderingPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey; + const surrenderedPlayerCharKey = this.gameState[surrenderingPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey; + + const winnerRole = surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const winnerName = this.gameState[winnerRole]?.name || `Оппонент`; + const winnerCharKey = this.gameState[winnerRole]?.characterKey; this.gameState.isGameOver = true; - this.turnTimer.clear(); - this.clearAllReconnectTimers(); // Также очищаем таймеры реконнекта + if(this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); - const surrenderMessage = `🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`; - this.addToLog(surrenderMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); - console.log(`[GameInstance ${this.id}] ${surrenderMessage}`); + this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM); + console.log(`[GameInstance ${this.id}] Player ${surrenderedPlayerName} (Role: ${surrenderingPlayerRole}) surrendered. Winner: ${winnerName} (Role: ${winnerRole}).`); - const reasonForGameOver = "player_surrendered"; + if (winnerCharKey && surrenderedPlayerCharKey && this.gameState[winnerRole]) { + this._sayTaunt(this.gameState[winnerRole], surrenderedPlayerCharKey, 'onBattleState', 'opponentNearDefeat'); + } - console.log(`[GameInstance ${this.id}] Игра ${this.id} завершена из-за сдачи игрока ${surrenderedPlayerName} (роль: ${surrenderedPlayerRole}). Победитель: ${winnerName} (роль: ${winnerRole}).`); this.io.to(this.id).emit('gameOver', { - winnerId: winnerRole, - reason: reasonForGameOver, - finalGameState: this.gameState, - log: this.consumeLogBuffer(), + winnerId: winnerRole, reason: "player_surrendered", + finalGameState: this.gameState, log: this.consumeLogBuffer(), loserCharacterKey: surrenderedPlayerCharKey }); - - // Вызываем cleanup в GameManager, чтобы удалить игру из активных списков - if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { - this.gameManager._cleanupGame(this.id, reasonForGameOver); - } else { - console.error(`[GameInstance ${this.id}] CRITICAL: gameManager or _cleanupGame method not found after surrender!`); - } + this.gameManager._cleanupGame(this.id, "player_surrendered"); } - // --- КОНЕЦ ИЗМЕНЕНИЯ --- - handleTurnTimeout() { + handleTurnTimeout() { /* ... Код без изменений, с вызовом _sayTaunt ... */ if (!this.gameState || this.gameState.isGameOver) return; - + console.log(`[GameInstance ${this.id}] Turn timeout occurred.`); const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const winnerPlayerRole = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - - const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerPlayerRole, timedOutPlayerRole); - - if (!this.gameState[winnerPlayerRole]?.characterKey) { - this._handleCriticalError('timeout_winner_undefined', `Таймаут, но победитель (${winnerPlayerRole}) не определен.`); - return; - } + let winnerActuallyExists = false; + if (this.mode === 'ai' && winnerPlayerRole === GAME_CONFIG.OPPONENT_ID) winnerActuallyExists = !!this.gameState.opponent?.characterKey; + else { const winnerEntry = Object.values(this.players).find(p => p.id === winnerPlayerRole && !p.isTemporarilyDisconnected); winnerActuallyExists = !!winnerEntry; } + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRole : null, timedOutPlayerRole); this.gameState.isGameOver = true; this.clearAllReconnectTimers(); this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); - console.log(`[GameInstance ${this.id}] Таймаут хода для ${this.gameState[timedOutPlayerRole]?.name}. Победитель: ${this.gameState[winnerPlayerRole]?.name}.`); - this.io.to(this.id).emit('gameOver', { - winnerId: result.winnerRole, reason: result.reason, - finalGameState: this.gameState, log: this.consumeLogBuffer(), - loserCharacterKey: this.gameState[timedOutPlayerRole]?.characterKey || 'unknown' - }); - this.gameManager._cleanupGame(this.id, result.reason); + if (result.winnerRole && this.gameState[result.winnerRole]?.characterKey && this.gameState[result.loserRole]?.characterKey) this._sayTaunt(this.gameState[result.winnerRole], this.gameState[result.loserRole].characterKey, 'onBattleState', 'opponentNearDefeat'); + console.log(`[GameInstance ${this.id}] Turn timed out for ${this.gameState[timedOutPlayerRole]?.name || timedOutPlayerRole}. Winner: ${result.winnerRole ? (this.gameState[result.winnerRole]?.name || result.winnerRole) : 'Нет'}.`); + this.io.to(this.id).emit('gameOver', { winnerId: result.winnerRole, reason: result.reason, finalGameState: this.gameState, log: this.consumeLogBuffer(), loserCharacterKey: this.gameState[timedOutPlayerRole]?.characterKey || 'unknown' }); + this.gameManager._cleanupGame(this.id, `timeout_${result.reason}`); } - - _handleCriticalError(reasonCode, logMessage) { - console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`); + _handleCriticalError(reasonCode, logMessage) { /* ... Код без изменений, с переводом логов ... */ + console.error(`[GameInstance ${this.id}] CRITICAL ERROR: ${logMessage} (Code: ${reasonCode})`); if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true; - this.turnTimer.clear(); + else if (!this.gameState) this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode }; + if(this.turnTimer.isActive()) this.turnTimer.clear(); this.clearAllReconnectTimers(); - this.addToLog(`Критическая ошибка сервера: ${logMessage}`, GAME_CONFIG.LOG_TYPE_SYSTEM); - this.io.to(this.id).emit('gameOver', { - winnerId: null, reason: `server_error_${reasonCode}`, - finalGameState: this.gameState, log: this.consumeLogBuffer(), - loserCharacterKey: 'unknown' - }); - if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { - this.gameManager._cleanupGame(this.id, `critical_error_${reasonCode}`); - } + this.addToLog(`Критическая ошибка сервера: ${logMessage}. Игра будет завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.io.to(this.id).emit('gameOver', { winnerId: null, reason: `server_error_${reasonCode}`, finalGameState: this.gameState, log: this.consumeLogBuffer(), loserCharacterKey: 'unknown'}); + this.gameManager._cleanupGame(this.id, `critical_error_${reasonCode}`); } - addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { - if (!message) return; - const logEntry = { message, type, timestamp: Date.now() }; - this.logBuffer.push(logEntry); + addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { /* ... Код без изменений ... */ + if (!message) return; this.logBuffer.push({ message, type, timestamp: Date.now() }); } - consumeLogBuffer() { - const logs = [...this.logBuffer]; - this.logBuffer = []; - return logs; + consumeLogBuffer() { /* ... Код без изменений ... */ + const logs = [...this.logBuffer]; this.logBuffer = []; return logs; } - broadcastGameStateUpdate() { - if (this.isGameEffectivelyPaused()) { - console.log(`[GameInstance ${this.id}] Попытка broadcastGameStateUpdate, но игра на паузе. Обновление не отправлено.`); - return; - } - if (!this.gameState) return; - const logsToSend = this.consumeLogBuffer(); - this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: logsToSend }); - } - broadcastLogUpdate() { - if (this.logBuffer.length > 0) { - const logsToSend = this.consumeLogBuffer(); - this.io.to(this.id).emit('logUpdate', { log: logsToSend }); + broadcastGameStateUpdate() { /* ... Код без изменений ... */ + if (this.isGameEffectivelyPaused()) { return; } if (!this.gameState) return; + this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); + } + broadcastLogUpdate() { /* ... Код без изменений ... */ + if (this.isGameEffectivelyPaused() && this.logBuffer.some(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM)) { + const systemLogs = this.logBuffer.filter(log => log.type === GAME_CONFIG.LOG_TYPE_SYSTEM); + if (systemLogs.length > 0) this.io.to(this.id).emit('logUpdate', { log: systemLogs }); + this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); return; } + if (this.logBuffer.length > 0) this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); } } diff --git a/server/game/instance/TurnTimer.js b/server/game/instance/TurnTimer.js index e8b3c87..2da77dc 100644 --- a/server/game/instance/TurnTimer.js +++ b/server/game/instance/TurnTimer.js @@ -3,118 +3,237 @@ class TurnTimer { /** * Конструктор таймера хода. - * @param {number} turnDurationMs - Длительность хода в миллисекундах. + * @param {number} turnDurationMs - Изначальная длительность хода в миллисекундах. * @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс). * @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода. - * @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTime, isPlayerTurnForTimer). + * @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTime, isPlayerTurnForTimer, isPaused). + * @param {string} [gameIdForLogs=''] - (Опционально) ID игры для более понятных логов таймера. */ - constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback) { - this.turnDurationMs = turnDurationMs; + constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') { + this.initialTurnDurationMs = turnDurationMs; // Сохраняем начальную полную длительность хода + this.currentEffectiveDurationMs = turnDurationMs; // Длительность, с которой стартует текущий отсчет (может быть меньше initial при resume) + this.updateIntervalMs = updateIntervalMs; this.onTimeoutCallback = onTimeoutCallback; this.onTickCallback = onTickCallback; + this.gameId = gameIdForLogs; // Для логов - this.timerId = null; // ID для setTimeout (обработка таймаута) - this.updateIntervalId = null; // ID для setInterval (обновление клиента) - this.startTime = 0; // Время начала текущего отсчета (Date.now()) - this.isRunning = false; - this.isCurrentPlayerActualTurnForTick = false; // Храним, для чьего хода запущен таймер (для onTickCallback) - this.isAiCurrentlyMakingMove = false; // Флаг, что сейчас ход AI (таймер не тикает для игрока) + this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода) + this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента) - // console.log(`[TurnTimer] Initialized with duration: ${turnDurationMs}ms, update interval: ${updateIntervalMs}ms`); + this.startTimeMs = 0; // Время (Date.now()) начала текущего отсчета таймера + this.isRunning = false; // Активен ли таймер в данный момент (идет отсчет) + + // Состояние, для которого был запущен/приостановлен таймер + this.isForPlayerTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player') + this.isAiCurrentlyMoving = false; // true, если это ход AI, и таймер для реального игрока не должен "тикать" + + this.isManuallyPaused = false; // Флаг, что таймер был приостановлен вызовом pause() + // console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); } /** * Запускает или перезапускает таймер хода. - * @param {boolean} isPlayerTurn - true, если сейчас ход слота 'player', false - если ход слота 'opponent'. - * @param {boolean} isAiTurn - true, если текущий ход делает AI (в этом случае таймер для реального игрока не тикает). + * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player', false - если ход слота 'opponent'. + * @param {boolean} isAiMakingMove - true, если текущий ход делает AI (таймер для реального игрока не тикает). + * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого оставшегося времени. */ - start(isPlayerTurn, isAiTurn = false) { - this.clear(); // Сначала очищаем предыдущие таймеры + start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { + this.clear(true); // Очищаем предыдущие таймеры, сохраняя флаг isManuallyPaused если это resume + + this.isForPlayerTurn = isPlayerSlotTurn; + this.isAiCurrentlyMakingMove = isAiMakingMove; + // При явном старте (не resume) сбрасываем флаг ручной паузы + if (customRemainingTimeMs === null) { + this.isManuallyPaused = false; + } - this.isCurrentPlayerActualTurnForTick = isPlayerTurn; // Сохраняем, чей ход для onTick - this.isAiCurrentlyMakingMove = isAiTurn; - // Таймер и отсчет времени запускаются только если это НЕ ход AI if (this.isAiCurrentlyMakingMove) { - this.isRunning = false; - // console.log(`[TurnTimer] Start called, but it's AI's turn. Timer not started for player.`); - // Уведомляем один раз, что таймер неактивен (ход AI) + this.isRunning = false; // Для хода AI основной таймер не "бежит" для игрока + // console.log(`[TurnTimer ${this.gameId}] Start: AI's turn. Player timer not ticking.`); if (this.onTickCallback) { - this.onTickCallback(null, this.isCurrentPlayerActualTurnForTick); + // Уведомляем один раз, что таймер неактивен (ход AI), передаем isPaused = false (т.к. это не ручная пауза) + // Время может быть полным или оставшимся, если AI "думает" + this.onTickCallback(this.initialTurnDurationMs, this.isForPlayerTurn, false); } return; } - this.startTime = Date.now(); - this.isRunning = true; - // console.log(`[TurnTimer] Started for ${isPlayerTurn ? 'Player' : 'Opponent'} at ${new Date(this.startTime).toLocaleTimeString()}. AI turn: ${isAiTurn}`); + // Устанавливаем длительность для текущего запуска + this.currentEffectiveDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0) + ? customRemainingTimeMs + : this.initialTurnDurationMs; - // Таймер на истечение общего времени хода - this.timerId = setTimeout(() => { - // console.log(`[TurnTimer] Timeout occurred! Was running: ${this.isRunning}`); - if (this.isRunning) { // Дополнительная проверка, что таймер все еще должен был работать - this.isRunning = false; // Помечаем, что таймер больше не работает + this.startTimeMs = Date.now(); + this.isRunning = true; + // console.log(`[TurnTimer ${this.gameId}] Started. Effective Duration: ${this.currentEffectiveDurationMs}ms. For ${this.isForPlayerTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${this.isAiCurrentlyMakingMove}`); + + // Основной таймер на истечение времени хода + this.timeoutId = setTimeout(() => { + // console.log(`[TurnTimer ${this.gameId}] Timeout occurred! Was running: ${this.isRunning}`); + if (this.isRunning) { // Доп. проверка, что таймер все еще должен был работать + this.isRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); } this.clear(); // Очищаем и интервал обновления после таймаута } - }, this.turnDurationMs); + }, this.currentEffectiveDurationMs); // Интервал для отправки обновлений клиентам - this.updateIntervalId = setInterval(() => { - if (!this.isRunning) { // Если таймер был остановлен (например, ход сделан или игра окончена) - this.clear(); // Убедимся, что интервал тоже очищен + this.tickIntervalId = setInterval(() => { + if (!this.isRunning) { + // Если таймер был остановлен (например, ход сделан, игра окончена, или pause вызван), + // но интервал еще не очищен - очищаем. + this.clear(this.isManuallyPaused); // Сохраняем флаг, если это была ручная пауза return; } - const elapsedTime = Date.now() - this.startTime; - const remainingTime = Math.max(0, this.turnDurationMs - elapsedTime); + const elapsedTime = Date.now() - this.startTimeMs; + const remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime); if (this.onTickCallback) { - // Передаем isCurrentPlayerActualTurnForTick, чтобы клиент знал, для чьего хода это время - this.onTickCallback(remainingTime, this.isCurrentPlayerActualTurnForTick); + // isManuallyPaused здесь всегда false, т.к. если бы была пауза, isRunning был бы false + this.onTickCallback(remainingTime, this.isForPlayerTurn, false); } - if (remainingTime <= 0 && this.isRunning) { // Если время вышло по интервалу (на всякий случай, setTimeout должен сработать) - // console.log(`[TurnTimer] Remaining time reached 0 in interval. Forcing timeout logic.`); - // Не вызываем onTimeoutCallback здесь напрямую, чтобы избежать двойного вызова, - // setTimeout должен это обработать. Просто очищаем интервал. - this.clear(); // Очищаем интервал, setTimeout сработает для onTimeoutCallback + if (remainingTime <= 0 && this.isRunning) { + // Время вышло по интервалу (на всякий случай, setTimeout должен сработать) + // Не вызываем onTimeoutCallback здесь напрямую, чтобы избежать двойного вызова. + this.clear(this.isManuallyPaused); // Очищаем интервал, setTimeout сработает для onTimeoutCallback } }, this.updateIntervalMs); // Отправляем начальное значение немедленно if (this.onTickCallback) { - this.onTickCallback(this.turnDurationMs, this.isCurrentPlayerActualTurnForTick); + this.onTickCallback(this.currentEffectiveDurationMs, this.isForPlayerTurn, false); } } + /** + * Приостанавливает таймер и возвращает его текущее состояние. + * @returns {{remainingTime: number, forPlayerRoleIsPlayer: boolean, isAiCurrentlyMoving: boolean}} + * - remainingTime: Оставшееся время в мс. + * - forPlayerRoleIsPlayer: true, если таймер был для хода игрока (слот 'player'). + * - isAiCurrentlyMoving: true, если это был ход AI. + */ + pause() { + // console.log(`[TurnTimer ${this.gameId}] Pause called. isRunning: ${this.isRunning}, isAiCurrentlyMoving: ${this.isAiCurrentlyMoving}`); + + let remainingTime = 0; + const wasForPlayerTurn = this.isForPlayerTurn; + const wasAiMoving = this.isAiCurrentlyMoving; + + if (this.isAiCurrentlyMakingMove) { + // Если это был ход AI, таймер для игрока не тикал, считаем, что у него полное время. + // Однако, если AI "думал" и мы хотим сохранить это, логика должна быть сложнее. + // Для простоты, если AI ход, то время "не шло" для игрока. + remainingTime = this.initialTurnDurationMs; + // console.log(`[TurnTimer ${this.gameId}] Paused during AI move. Effective remaining time for player turn: ${remainingTime}ms.`); + } else if (this.isRunning) { + const elapsedTime = Date.now() - this.startTimeMs; + remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime); + // console.log(`[TurnTimer ${this.gameId}] Paused while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTime}ms.`); + } else { + // Если таймер не был запущен (например, уже истек или был очищен), + // или был уже на паузе, возвращаем 0 или последнее известное значение. + // Если isManuallyPaused уже true, то просто возвращаем то, что было. + remainingTime = this.isManuallyPaused ? this.currentEffectiveDurationMs : 0; // currentEffectiveDurationMs тут может быть уже оставшимся временем + // console.log(`[TurnTimer ${this.gameId}] Pause called, but timer not actively running or already paused. Returning current/zero remaining time: ${remainingTime}ms.`); + } + + this.isManuallyPaused = true; // Устанавливаем флаг ручной паузы + this.clear(true); // Очищаем внутренние таймеры, сохраняя флаг isManuallyPaused + this.isRunning = false; // Явно указываем, что отсчет остановлен + + // Уведомляем клиента, что таймер на паузе + if (this.onTickCallback) { + // console.log(`[TurnTimer ${this.gameId}] Notifying client of pause. Remaining: ${remainingTime}, ForPlayer: ${wasForPlayerTurn}`); + this.onTickCallback(remainingTime, wasForPlayerTurn, true); // isPaused = true + } + + return { remainingTime, forPlayerRoleIsPlayer: wasForPlayerTurn, isAiCurrentlyMoving: wasAiMoving }; + } + + /** + * Возобновляет таймер с указанного оставшегося времени и для указанного состояния. + * @param {number} remainingTimeMs - Оставшееся время в миллисекундах для возобновления. + * @param {boolean} forPlayerSlotTurn - Для чьего хода (слот 'player' = true) возобновляется таймер. + * @param {boolean} isAiMakingMove - Был ли это ход AI, когда таймер приостановили (и возобновляем ли ход AI). + */ + resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) { + if (!this.isManuallyPaused) { + // console.warn(`[TurnTimer ${this.gameId}] Resume called, but timer was not manually paused. Starting normally or doing nothing.`); + // Если не был на ручной паузе, то либо запускаем заново (если не был ход AI), либо ничего не делаем + // if (!isAiMakingMove) this.start(forPlayerSlotTurn, false, remainingTimeMs > 0 ? remainingTimeMs : null); + // Безопаснее просто выйти, если не был на ручной паузе, GameInstance должен управлять этим. + return; + } + + if (remainingTimeMs <= 0) { + // console.log(`[TurnTimer ${this.gameId}] Resume called with 0 or less time. Triggering timeout.`); + this.isManuallyPaused = false; // Сбрасываем флаг + if (this.onTimeoutCallback) { + this.onTimeoutCallback(); // Немедленный таймаут + } + return; + } + // console.log(`[TurnTimer ${this.gameId}] Resuming. Remaining: ${remainingTimeMs}ms. For ${forPlayerSlotTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${isAiMakingMove}`); + + this.isManuallyPaused = false; // Сбрасываем флаг ручной паузы перед стартом + // Запускаем таймер с сохраненным состоянием и оставшимся временем + this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); + } + /** * Очищает (останавливает) все активные таймеры (setTimeout и setInterval). + * @param {boolean} [preserveManuallyPausedFlag=false] - Если true, не сбрасывает флаг isManuallyPaused. + * Используется внутренне при вызове clear из pause(). */ - clear() { - if (this.timerId) { - clearTimeout(this.timerId); - this.timerId = null; + clear(preserveManuallyPausedFlag = false) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; } - if (this.updateIntervalId) { - clearInterval(this.updateIntervalId); - this.updateIntervalId = null; + if (this.tickIntervalId) { + clearInterval(this.tickIntervalId); + this.tickIntervalId = null; } + + const wasPreviouslyRunning = this.isRunning; // Запоминаем, работал ли он до clear this.isRunning = false; - this.startTime = 0; - // console.log(`[TurnTimer] Cleared.`); + // this.startTimeMs = 0; // Не сбрасываем startTime, чтобы pause мог корректно вычислить remainingTime + + if (!preserveManuallyPausedFlag) { + this.isManuallyPaused = false; + } + + // Если таймер был очищен не через pause(), он был активен (и это не был ход AI, который и так не тикает) + // то опционально можно уведомить клиента, что таймер больше не тикает (например, ход сделан) + // Это может быть полезно, чтобы клиент сбросил свой отображаемый таймер на '--' + // if (wasPreviouslyRunning && !this.isAiCurrentlyMakingMove && !this.isManuallyPaused && this.onTickCallback) { + // // console.log(`[TurnTimer ${this.gameId}] Cleared while running (not AI, not manual pause). Notifying client.`); + // this.onTickCallback(null, this.isForPlayerTurn, this.isManuallyPaused); // remainingTime = null + // } + // console.log(`[TurnTimer ${this.gameId}] Cleared. Was running: ${wasPreviouslyRunning}. PreservePaused: ${preserveManuallyPausedFlag}`); } /** - * Проверяет, активен ли таймер в данный момент. + * Проверяет, активен ли таймер в данный момент (идет ли отсчет). * @returns {boolean} */ isActive() { return this.isRunning; } + + /** + * Проверяет, был ли таймер приостановлен вручную вызовом pause(). + * @returns {boolean} + */ + isPaused() { + return this.isManuallyPaused; + } } module.exports = TurnTimer; \ No newline at end of file diff --git a/server/game/logic/combatLogic.js b/server/game/logic/combatLogic.js index ade9b45..f955f5a 100644 --- a/server/game/logic/combatLogic.js +++ b/server/game/logic/combatLogic.js @@ -1,96 +1,103 @@ // /server/game/logic/combatLogic.js -// GAME_CONFIG и gameData/dataUtils будут передаваться в функции как параметры. -// const GAME_CONFIG_STATIC = require('../../core/config'); // Можно импортировать для внутренних нужд, если не все приходит через параметры +// Предполагается, что gameLogic.getRandomTaunt и dataUtils будут доступны +// через параметры, передаваемые из GameInstance, или через объект gameLogic. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Можно, если нужно /** * Обрабатывает базовую атаку одного бойца по другому. * @param {object} attackerState - Состояние атакующего бойца из gameState. * @param {object} defenderState - Состояние защищающегося бойца из gameState. - * @param {object} attackerBaseStats - Базовые статы атакующего (из dataUtils.getCharacterBaseStats). - * @param {object} defenderBaseStats - Базовые статы защищающегося (из dataUtils.getCharacterBaseStats). - * @param {object} currentGameState - Текущее полное состояние игры (для getRandomTaunt). + * @param {object} attackerBaseStats - Базовые статы атакующего. + * @param {object} defenderBaseStats - Базовые статы защищающегося. + * @param {object} currentGameState - Текущее полное состояние игры. * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). - * @param {object} defenderFullData - Полные данные защищающегося персонажа (baseStats, abilities) из dataUtils.getCharacterData(defenderKey), для getRandomTaunt. + * @param {object} dataUtils - Утилиты для доступа к данным игры. + * @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt, переданная для использования. */ function performAttack( attackerState, defenderState, attackerBaseStats, defenderBaseStats, - currentGameState, // Добавлен для контекста насмешек + currentGameState, addToLogCallback, configToUse, - defenderFullData // Добавлен для контекста насмешек цели + dataUtils, // Добавлен dataUtils + getRandomTauntFunction // Добавлена функция для насмешек ) { - // Расчет базового урона с вариацией let damage = Math.floor( attackerBaseStats.attackPower * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE) ); - let tauntMessagePart = ""; + let wasBlocked = false; - // Проверка на блок if (defenderState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); - - // Проверка на насмешку ОТ защищающегося (если это Елена или Альмагест) при блокировании атаки - if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') { - // Импортируем getRandomTaunt здесь или передаем как параметр, если он в другом файле logic - // Предположим, getRandomTaunt доступен в gameLogic (который будет передан или импортирован) - // Для примера, если бы он был в этом же файле или импортирован: - // const blockTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameData, currentGameState); - // Поскольку getRandomTaunt теперь в gameLogic.js, он должен быть вызван оттуда или передан. - // В GameInstance.js мы вызываем gameLogic.getRandomTaunt, так что здесь это дублирование. - // Лучше, чтобы GameInstance сам обрабатывал насмешки или передавал их как результат. - // Для простоты здесь оставим, но это кандидат на рефакторинг вызова насмешек в GameInstance. - // Однако, если defenderFullData передается, мы можем вызвать его, предполагая, что gameLogic.getRandomTaunt будет импортирован - // или доступен в объекте gameLogic, переданном в GameInstance. - // const blockTaunt = require('./index').getRandomTaunt(...) // Пример циклической зависимости, так не надо - // Будем считать, что GameInstance готовит насмешку заранее или эта функция вызывается с уже готовой насмешкой. - // Либо, если getRandomTaunt - это часть 'gameLogic' объекта, то: - // const blockTaunt = gameLogicFunctions.getRandomTaunt(...) - // Сейчас для простоты оставим вызов, но это архитектурный момент. - // Предположим, что gameLogic.getRandomTaunt доступен через какой-то объект, например, `sharedLogic` - } - - + wasBlocked = true; if (addToLogCallback) { addToLogCallback( - `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, + `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, configToUse.LOG_TYPE_BLOCK ); } } else { - // Насмешка при попадании также должна обрабатываться централизованно или передаваться if (addToLogCallback) { addToLogCallback( - `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.${tauntMessagePart}`, + `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`, configToUse.LOG_TYPE_DAMAGE ); } } - // Применяем урон, убеждаемся, что HP не ниже нуля + const actualDamageDealt = defenderState.currentHp - Math.max(0, Math.round(defenderState.currentHp - damage)); defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); + + // --- Насмешка от защищающегося (defenderState) в ответ на атаку --- + if (getRandomTauntFunction && dataUtils) { + let reactionTauntTrigger = null; + if (wasBlocked) { + reactionTauntTrigger = 'onOpponentAttackBlocked'; + } else if (actualDamageDealt > 0) { // Если урон прошел + reactionTauntTrigger = 'onOpponentAttackHit'; + } + // Можно добавить 'onOpponentAttackMissed' если actualDamageDealt === 0 и !wasBlocked + + if (reactionTauntTrigger) { + const attackerFullData = dataUtils.getCharacterData(attackerState.characterKey); + if (attackerFullData) { // Убедимся, что данные атакующего есть + const reactionTaunt = getRandomTauntFunction( + defenderState.characterKey, // Кто говорит (защищающийся) + reactionTauntTrigger, // Триггер (onOpponentAttackBlocked или onOpponentAttackHit) + {}, // Контекст (пока пустой для этих реакций) + configToUse, + attackerFullData, // Оппонент для говорящего - это атакующий + currentGameState + ); + if (reactionTaunt && reactionTaunt !== "(Молчание)") { + addToLogCallback(`${defenderState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); + } + } + } + } } /** - * Применяет эффект способности (урон, лечение, наложение баффа/дебаффа и т.д.). - * Насмешки, связанные с самим КАСТОМ способности (selfCastAbility), должны быть обработаны до вызова этой функции. - * Насмешки, связанные с РЕАКЦИЕЙ цели на эффект, могут быть обработаны здесь или после. + * Применяет эффект способности. * @param {object} ability - Объект способности. * @param {object} casterState - Состояние бойца, применившего способность. * @param {object} targetState - Состояние цели способности. * @param {object} casterBaseStats - Базовые статы кастера. * @param {object} targetBaseStats - Базовые статы цели. - * @param {object} currentGameState - Текущее полное состояние игры (для getRandomTaunt, если он здесь вызывается). + * @param {object} currentGameState - Текущее полное состояние игры. * @param {function} addToLogCallback - Функция для добавления лога. * @param {object} configToUse - Конфигурация игры. - * @param {object} targetFullData - Полные данные цели (baseStats, abilities) для getRandomTaunt. + * @param {object} dataUtils - Утилиты для доступа к данным игры. + * @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt. + * @param {function} checkIfActionWasSuccessfulFunction - Функция для проверки успеха действия (для контекстных насмешек). */ function applyAbilityEffect( ability, @@ -101,66 +108,64 @@ function applyAbilityEffect( currentGameState, addToLogCallback, configToUse, - targetFullData // Для насмешек цели + dataUtils, // Добавлен dataUtils + getRandomTauntFunction, // Добавлена функция для насмешек + checkIfActionWasSuccessfulFunction // Добавлена функция для проверки успеха ) { - let tauntMessagePart = ""; // Для насмешки цели - - // Насмешка цели (если это Елена/Альмагест) на применение способности противником - // Этот вызов лучше делать в GameInstance, передавая результат сюда, или эта функция должна иметь доступ к getRandomTaunt - // if ((targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') && casterState.id !== targetState.id) { - // const reactionTaunt = require('./index').getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id }, configToUse, targetFullData, currentGameState); - // if (reactionTaunt !== "(Молчание)") tauntMessagePart = ` (${reactionTaunt})`; - // } + let abilityApplicationSucceeded = true; // Флаг для отслеживания, применилась ли способность успешно (для контекста насмешек) + let actionOutcomeForTaunt = null; // 'success' или 'fail' для способностей типа безмолвия + // --- Основная логика применения способности --- switch (ability.type) { case configToUse.ACTION_TYPE_HEAL: const healAmount = Math.floor(ability.power * (configToUse.HEAL_VARIATION_MIN + Math.random() * configToUse.HEAL_VARIATION_RANGE)); const actualHeal = Math.min(healAmount, casterBaseStats.maxHp - casterState.currentHp); if (actualHeal > 0) { casterState.currentHp = Math.round(casterState.currentHp + actualHeal); - if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!${tauntMessagePart}`, configToUse.LOG_TYPE_HEAL); + if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL); } else { - if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; // Можно считать это "неудачей" для реакции, если хотите } break; case configToUse.ACTION_TYPE_DAMAGE: let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); + let wasAbilityBlocked = false; if (targetState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); - if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); + wasAbilityBlocked = true; + if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, configToUse.LOG_TYPE_BLOCK); } targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage)); - if (addToLogCallback && !targetState.isBlocking) { - addToLogCallback(`💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!${tauntMessagePart}`, configToUse.LOG_TYPE_DAMAGE); + if (addToLogCallback && !wasAbilityBlocked) { + addToLogCallback(`💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`, configToUse.LOG_TYPE_DAMAGE); } + if (damage <= 0 && !wasAbilityBlocked) abilityApplicationSucceeded = false; // Если урона не было (например, из-за эффектов) break; case configToUse.ACTION_TYPE_BUFF: - // Проверка на уже активный бафф должна быть сделана до вызова этой функции (в GameInstance) let effectDescriptionBuff = ability.description; if (typeof ability.descriptionFunction === 'function') { - // Для описания баффа может потребоваться информация о противнике (цели баффа, если бафф накладывается на другого) - // В данном случае, баффы накладываются на себя, так что targetBaseStats не всегда релевантен для описания. - // Передаем targetBaseStats (оппонента кастера), если описание функции его ожидает. - effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); + effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); // targetBaseStats здесь оппонент кастера } casterState.activeEffects.push({ id: ability.id, name: ability.name, description: effectDescriptionBuff, type: ability.type, duration: ability.duration, - turnsLeft: ability.duration, // Длительность эффекта в ходах владельца + turnsLeft: ability.duration, grantsBlock: !!ability.grantsBlock, isDelayed: !!ability.isDelayed, justCast: true }); - if (ability.grantsBlock) require('./effectsLogic').updateBlockingStatus(casterState); // Обновляем статус блока - if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + if (ability.grantsBlock) require('./effectsLogic').updateBlockingStatus(casterState); + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!`, configToUse.LOG_TYPE_EFFECT); break; case configToUse.ACTION_TYPE_DISABLE: - // Логика для 'Гипнотический взгляд' / 'Раскол Разума' (полное безмолвие) if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { + // ... (логика полного безмолвия как у вас) + // Установите actionOutcomeForTaunt = 'success' или 'fail' если нужно const effectIdFullSilence = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; if (!targetState.activeEffects.some(e => e.id === effectIdFullSilence)) { targetState.activeEffects.push({ @@ -168,17 +173,20 @@ function applyAbilityEffect( type: ability.type, duration: ability.effectDuration, turnsLeft: ability.effectDuration, power: ability.power, isFullSilence: true, justCast: true }); - if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}! Способности заблокированы на ${ability.effectDuration} хода и наносится урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}! Способности заблокированы на ${ability.effectDuration} хода и наносится урон!`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; } else { - if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; // Считаем провалом, если уже активен } } - // Логика для 'Эхо Безмолвия' Баларда else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') { const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE; - // Насмешка цели на успех/провал должна быть обработана в GameInstance, т.к. результат известен только здесь + actionOutcomeForTaunt = success ? 'success' : 'fail'; // Устанавливаем для контекста насмешки if (success) { - const targetAbilitiesList = require('../../data/dataUtils').getCharacterAbilities(targetState.characterKey); // Получаем абилки цели + // ... (ваша логика наложения безмолвия на способность) + const targetAbilitiesList = dataUtils.getCharacterAbilities(targetState.characterKey); const availableAbilitiesToSilence = targetAbilitiesList.filter(pa => !targetState.disabledAbilities?.some(d => d.abilityId === pa.id) && !targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) @@ -193,39 +201,43 @@ function applyAbilityEffect( type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, duration: turns, turnsLeft: turns + 1, justCast: true }); - if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" у ${targetBaseStats.name} заблокировано на ${turns} хода!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" у ${targetBaseStats.name} заблокировано на ${turns} хода!`, configToUse.LOG_TYPE_EFFECT); } else { - if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!`, configToUse.LOG_TYPE_INFO); + actionOutcomeForTaunt = 'fail'; // Провал, если нечего глушить } } else { - if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!`, configToUse.LOG_TYPE_INFO); } } break; case configToUse.ACTION_TYPE_DEBUFF: - // Логика для 'Печать Слабости' / 'Проклятие Увядания' - if (ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF) { - const effectIdDebuff = 'effect_' + ability.id; - if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { - let effectDescriptionDebuff = ability.description; - if (typeof ability.descriptionFunction === 'function') { - effectDescriptionDebuff = ability.descriptionFunction(configToUse, targetBaseStats); - } - targetState.activeEffects.push({ - id: effectIdDebuff, name: ability.name, description: effectDescriptionDebuff, - type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id, - duration: ability.effectDuration, turnsLeft: ability.effectDuration, - power: ability.power, justCast: true - }); - if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); - } else { - if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); + // ... (логика дебаффа как у вас) + // Установите actionOutcomeForTaunt если нужно + const effectIdDebuff = 'effect_' + ability.id; + if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { + let effectDescriptionDebuff = ability.description; + if (typeof ability.descriptionFunction === 'function') { + effectDescriptionDebuff = ability.descriptionFunction(configToUse, targetBaseStats); } + targetState.activeEffects.push({ + id: effectIdDebuff, name: ability.name, description: effectDescriptionDebuff, + type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id, + duration: ability.effectDuration, turnsLeft: ability.effectDuration, + power: ability.power, justCast: true + }); + if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; } break; - case configToUse.ACTION_TYPE_DRAIN: // Похищение Света Баларда + case configToUse.ACTION_TYPE_DRAIN: + // ... (логика дрейна как у вас) if (casterState.characterKey === 'balard') { let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; if (ability.powerDamage > 0) { @@ -249,19 +261,51 @@ function applyAbilityEffect( if (manaDrained > 0) logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name} и исцеляется на ${healthGained} HP!`; else if (damageDealtDrain > 0) logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; else logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; - logMsgDrain += tauntMessagePart; if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); + if (manaDrained <= 0 && damageDealtDrain <=0) abilityApplicationSucceeded = false; } break; default: if (addToLogCallback) addToLogCallback(`Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`, configToUse.LOG_TYPE_SYSTEM); console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type}`); + abilityApplicationSucceeded = false; + } + + // --- Насмешка от цели (targetState) в ответ на применение способности --- + // Вызываем только если способность не была нацелена на самого себя и успешно применилась (или как вы решите) + if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id && abilityApplicationSucceeded) { + const casterFullData = dataUtils.getCharacterData(casterState.characterKey); + if (casterFullData) { // Убедимся, что данные кастера есть + let tauntContext = { abilityId: ability.id }; + if (actionOutcomeForTaunt) { // Если для этой способности важен исход (success/fail) + tauntContext.outcome = actionOutcomeForTaunt; + } else if (checkIfActionWasSuccessfulFunction) { + // Если есть общая функция проверки успеха (менее специфично, чем actionOutcomeForTaunt) + // Это пример, вам нужно реализовать checkIfActionWasSuccessfulFunction + // const success = checkIfActionWasSuccessfulFunction(ability, casterState, targetState, currentGameState, configToUse); + // tauntContext.outcome = success ? 'success' : 'fail'; + } + + + const reactionTaunt = getRandomTauntFunction( + targetState.characterKey, // Кто говорит (цель способности) + 'onOpponentAction', // Триггер + tauntContext, // Контекст: ID способности и исход (если нужен) + configToUse, + casterFullData, // Оппонент для говорящего - это кастер + currentGameState + ); + if (reactionTaunt && reactionTaunt !== "(Молчание)") { + addToLogCallback(`${targetState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); + } + } } } + /** - * Проверяет валидность использования способности (ресурс, КД, безмолвие и т.д.). + * Проверяет валидность использования способности. * @param {object} ability - Объект способности. * @param {object} casterState - Состояние кастера. * @param {object} targetState - Состояние цели. @@ -269,6 +313,7 @@ function applyAbilityEffect( * @returns {{isValid: boolean, reason: string|null}} Результат проверки. */ function checkAbilityValidity(ability, casterState, targetState, configToUse) { + // ... (существующий код checkAbilityValidity без изменений) ... if (!ability) return { isValid: false, reason: "Способность не найдена." }; if (casterState.currentResource < ability.cost) { @@ -277,7 +322,6 @@ function checkAbilityValidity(ability, casterState, targetState, configToUse) { if ((casterState.abilityCooldowns?.[ability.id] || 0) > 0) { return { isValid: false, reason: `"${ability.name}" еще на перезарядке.` }; } - // Проверка специальных КД Баларда if (casterState.characterKey === 'balard') { if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && (casterState.silenceCooldownTurns || 0) > 0) { return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; @@ -309,5 +353,5 @@ function checkAbilityValidity(ability, casterState, targetState, configToUse) { module.exports = { performAttack, applyAbilityEffect, - checkAbilityValidity // Экспортируем новую функцию + checkAbilityValidity }; \ No newline at end of file diff --git a/server/game/logic/tauntLogic.js b/server/game/logic/tauntLogic.js index 89eb243..1356631 100644 --- a/server/game/logic/tauntLogic.js +++ b/server/game/logic/tauntLogic.js @@ -1,87 +1,148 @@ // /server/game/logic/tauntLogic.js -const GAME_CONFIG = require('../../core/config'); // Путь к config.js -// Вам понадобится доступ к gameData.tauntSystem здесь. -// Либо импортируйте весь gameData, либо только tauntSystem из data/taunts.js -const gameData = require('../../data'); // Импортируем собранный gameData из data/index.js +const GAME_CONFIG = require('../../core/config'); +// Предполагаем, что gameData.tauntSystem импортируется или доступен. +// Если tauntSystem экспортируется напрямую из data/taunts.js: +// const { tauntSystem } = require('../../data/taunts'); +// Если он часть общего gameData, который собирается в data/index.js: +const gameData = require('../../data'); // Тогда используем gameData.tauntSystem /** * Получает случайную насмешку из системы насмешек. - * (Ваша существующая функция getRandomTaunt) + * @param {string} speakerCharacterKey - Ключ персонажа, который говорит. + * @param {string} trigger - Тип триггера насмешки (например, 'selfCastAbility', 'onBattleState', 'onOpponentAction'). + * @param {string|number|object} [subTriggerOrContext={}] - Может быть ID способности, специфичный ключ состояния ('start', 'dominating') или объект контекста. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} opponentFullData - Полные данные персонажа, к которому обращена насмешка (цель). + * @param {object} currentGameState - Текущее полное состояние игры. + * @returns {string} Текст насмешки или "(Молчание)". */ -function getRandomTaunt(speakerCharacterKey, trigger, context = {}, configToUse, opponentFullData, currentGameState) { - // Проверяем наличие системы насмешек для говорящего персонажа - const speakerTauntSystem = gameData.tauntSystem?.[speakerCharacterKey]; // Используем gameData.tauntSystem - if (!speakerTauntSystem) return "(Молчание)"; +function getRandomTaunt(speakerCharacterKey, trigger, subTriggerOrContext = {}, configToUse, opponentFullData, currentGameState) { + // console.log(`[TauntLogic DEBUG] Called with: speaker=${speakerCharacterKey}, trigger=${trigger}, subTriggerOrContext=`, subTriggerOrContext, `opponentKey=${opponentFullData?.baseStats?.characterKey}`); - const opponentCharacterKey = opponentFullData?.baseStats?.characterKey || currentGameState?.opponent?.characterKey; // Получаем ключ оппонента - if (!opponentCharacterKey) { // Если оппонент не определен (например, начало игры с AI, где оппонент еще не fully в gameState) - // console.warn(`getRandomTaunt: Opponent character key not determined for speaker ${speakerCharacterKey}, trigger ${trigger}`); - // Можно попробовать определить оппонента по-другому или вернуть общую фразу / молчание - if (trigger === 'battleStart' && speakerCharacterKey === 'elena' && currentGameState.gameMode === 'ai') { - // Для Елены против AI Баларда в начале боя - const balardTaunts = speakerTauntSystem.balard; - if (balardTaunts?.onBattleState?.start) { - const potentialTaunts = balardTaunts.onBattleState.start; - return potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)] || "(Молчание)"; + const tauntSystemToUse = gameData.tauntSystem || (gameData.default && gameData.default.tauntSystem); // Совместимость, если gameData имеет default экспорт + if (!tauntSystemToUse) { + console.error("[TauntLogic ERROR] tauntSystem is not available from gameData import!"); + return "(Молчание)"; + } + + const speakerTauntBranch = tauntSystemToUse[speakerCharacterKey]; + if (!speakerTauntBranch) { + // console.log(`[TauntLogic] No taunt branch for speaker: ${speakerCharacterKey}`); + return "(Молчание)"; + } + + const opponentKeyForTaunts = opponentFullData?.baseStats?.characterKey; + if (!opponentKeyForTaunts) { + // console.log(`[TauntLogic] Opponent key for taunts not available for speaker ${speakerCharacterKey}, trigger ${trigger}. OpponentData:`, opponentFullData); + // Особый случай для старта AI игры, где оппонент (AI Балард) может быть известен, даже если opponentFullData не полон + if (trigger === 'onBattleState' && subTriggerOrContext === 'start' && speakerCharacterKey === 'elena' && currentGameState.gameMode === 'ai') { + // Елена против Баларда (AI) в начале боя + const elenaVsBalardStartTaunts = speakerTauntBranch.balard?.onBattleState?.start; + if (Array.isArray(elenaVsBalardStartTaunts) && elenaVsBalardStartTaunts.length > 0) { + return elenaVsBalardStartTaunts[Math.floor(Math.random() * elenaVsBalardStartTaunts.length)] || "(Молчание)"; } } return "(Молчание)"; } - - const tauntBranch = speakerTauntSystem[opponentCharacterKey]; - if (!tauntBranch) { + const specificTauntBranch = speakerTauntBranch[opponentKeyForTaunts]; + if (!specificTauntBranch || !specificTauntBranch[trigger]) { + // console.log(`[TauntLogic] No specific taunt branch or trigger branch for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}`); return "(Молчание)"; } + let tauntSet = specificTauntBranch[trigger]; + let context = {}; + let subTriggerKey = null; // Это будет ключ для прямого доступа к массиву насмешек, например, ID способности или 'start' + + if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') { + subTriggerKey = subTriggerOrContext; + // Если subTriggerOrContext - это ID способности, помещаем его в контекст для onOpponentAction + if (trigger === 'onOpponentAction' || trigger === 'selfCastAbility') { + context.abilityId = subTriggerOrContext; + } + } else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) { + context = { ...subTriggerOrContext }; + // Если ID способности передан в контексте, используем его как subTriggerKey для прямого доступа + if (context.abilityId && (trigger === 'selfCastAbility' || trigger === 'onOpponentAction')) { + subTriggerKey = context.abilityId; + } else if (trigger === 'onBattleState' && typeof context === 'string') { // на случай если GameInstance передает строку для onBattleState + subTriggerKey = context; + } + } + // Для basicAttack subTriggerKey может быть 'merciful', 'dominating' или null (тогда general) + if (trigger === 'basicAttack' && typeof subTriggerOrContext === 'string') { + subTriggerKey = subTriggerOrContext; + } + + + // console.log(`[TauntLogic DEBUG] Parsed: trigger=${trigger}, subTriggerKey=${subTriggerKey}, context=`, context); + let potentialTaunts = []; - if (trigger === 'battleStart') { - potentialTaunts = tauntBranch.onBattleState?.start; - } else if (trigger === 'opponentNearDefeatCheck') { - const opponentState = currentGameState?.player?.characterKey === opponentCharacterKey ? currentGameState.player : currentGameState.opponent; - if (opponentState && opponentState.maxHp > 0 && opponentState.currentHp / opponentState.maxHp < 0.20) { - potentialTaunts = tauntBranch.onBattleState?.opponentNearDefeat; - } - } else if (trigger === 'selfCastAbility' && context.abilityId) { - potentialTaunts = tauntBranch.selfCastAbility?.[context.abilityId]; - } else if (trigger === 'basicAttack' && tauntBranch.basicAttack) { - const opponentState = currentGameState?.player?.characterKey === opponentCharacterKey ? currentGameState.player : currentGameState.opponent; - if (speakerCharacterKey === 'elena' && opponentCharacterKey === 'balard' && opponentState) { - const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100; - if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT) { - potentialTaunts = tauntBranch.basicAttack.dominating; - } else { - potentialTaunts = tauntBranch.basicAttack.merciful; - } - } else { - potentialTaunts = tauntBranch.basicAttack.general || []; // Фоллбэк на пустой массив - } - } else if (trigger === 'onOpponentAction' && context.abilityId) { - const actionResponses = tauntBranch.onOpponentAction?.[context.abilityId]; - if (actionResponses) { - if (typeof actionResponses === 'object' && !Array.isArray(actionResponses) && context.outcome && context.outcome in actionResponses) { - potentialTaunts = actionResponses[context.outcome]; - } else if (Array.isArray(actionResponses)) { - potentialTaunts = actionResponses; - } - } - } else if (trigger === 'onOpponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) { - potentialTaunts = tauntBranch.onOpponentAction.attackBlocked; - } else if (trigger === 'onOpponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) { - potentialTaunts = tauntBranch.onOpponentAction.attackHits; + if (subTriggerKey !== null && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && tauntSet[subTriggerKey]) { + // Если есть subTriggerKey и tauntSet - это объект (а не массив), то получаем вложенный набор + tauntSet = tauntSet[subTriggerKey]; + } else if (Array.isArray(tauntSet)) { + // Если tauntSet уже массив (например, для onOpponentAttackBlocked), используем его как есть + potentialTaunts = tauntSet; // Присваиваем сразу + } else if (typeof tauntSet === 'object' && tauntSet.general) { // Фоллбэк на general, если subTriggerKey не найден в объекте + tauntSet = tauntSet.general; } - if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) { - // Фоллбэк на общие фразы при basicAttack, если специфичные не найдены - if (trigger === 'basicAttack' && tauntBranch.basicAttack?.general && tauntBranch.basicAttack.general.length > 0) { - potentialTaunts = tauntBranch.basicAttack.general; + + // Специальная обработка для onOpponentAction с исходом (success/fail) + if (trigger === 'onOpponentAction' && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && context.outcome) { + if (tauntSet[context.outcome]) { + potentialTaunts = tauntSet[context.outcome]; } else { - return "(Молчание)"; + // console.log(`[TauntLogic] No outcome '${context.outcome}' for onOpponentAction, abilityId ${context.abilityId}`); + potentialTaunts = []; // Явно пустой, чтобы не упасть ниже } + } else if (Array.isArray(tauntSet)) { + potentialTaunts = tauntSet; + } + + + // Обработка basicAttack (merciful/dominating/general) + if (trigger === 'basicAttack' && specificTauntBranch.basicAttack) { // Убедимся что ветка basicAttack существует + const basicAttackBranch = specificTauntBranch.basicAttack; + if (speakerCharacterKey === 'elena' && opponentKeyForTaunts === 'balard' && currentGameState && currentGameState[GAME_CONFIG.OPPONENT_ID]) { + const opponentState = currentGameState[GAME_CONFIG.OPPONENT_ID]; // Балард всегда оппонент для Елены в этом контексте + if (opponentState && opponentState.maxHp > 0) { + const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100; + if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT && basicAttackBranch.dominating) { + potentialTaunts = basicAttackBranch.dominating; + } else if (basicAttackBranch.merciful) { + potentialTaunts = basicAttackBranch.merciful; + } else if (basicAttackBranch.general) { // Фоллбэк на general если нет merciful + potentialTaunts = basicAttackBranch.general; + } + } else if (basicAttackBranch.general) { // Если нет HP данных, используем general + potentialTaunts = basicAttackBranch.general; + } + } else if (basicAttackBranch.general) { // Общий случай для basicAttack + potentialTaunts = basicAttackBranch.general; + } + // Если subTriggerKey был ('merciful'/'dominating') и он найден в basicAttackBranch, то tauntSet уже установлен выше + // Этот блок if (trigger === 'basicAttack') должен быть более специфичным или объединен с логикой subTriggerKey выше. + // Пока оставим как есть, предполагая, что subTriggerKey для basicAttack обрабатывается отдельно. + // Если subTriggerKey был 'merciful' или 'dominating', и такой ключ есть в basicAttackBranch, то tauntSet уже должен быть им. + if (subTriggerKey && basicAttackBranch[subTriggerKey]) { + potentialTaunts = basicAttackBranch[subTriggerKey]; + } else if (potentialTaunts.length === 0 && basicAttackBranch.general) { // Если не нашли по subTriggerKey, берем general + potentialTaunts = basicAttackBranch.general; + } + } + + + if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) { + // console.log(`[TauntLogic] No potential taunts found or empty array for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}, subTriggerKey: ${subTriggerKey}`); + return "(Молчание)"; } const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)]; + // console.log(`[TauntLogic] Selected for ${speakerCharacterKey} vs ${opponentKeyForTaunts} (Trigger: ${trigger}, SubTriggerKey: ${subTriggerKey}): "${selectedTaunt}"`); return selectedTaunt || "(Молчание)"; }