From a9d2c7cb27cc3edc3f1f575af7629447ea6d8ae5 Mon Sep 17 00:00:00 2001 From: PsiMagistr Date: Sun, 25 May 2025 08:54:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20jwt=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8.=20=D0=92=D0=BE=D0=B7=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 113 +++++ package.json | 1 + public/js/auth.js | 208 +++++--- public/js/{client.js => client_del.js} | 0 public/js/gameplay.js | 349 +++++++------- public/js/main.js | 465 +++++++++++------- server/auth/authService.js | 38 +- server/bc.js | 207 +++++--- server/core/config.js | 1 + server/game/GameManager.js | 635 +++++++++++++++++-------- server/game/instance/GameInstance.js | 500 ++++++++++++++++--- 11 files changed, 1772 insertions(+), 745 deletions(-) rename public/js/{client.js => client_del.js} (100%) diff --git a/package-lock.json b/package-lock.json index b0e68f7..e81e8de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^16.5.0", "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.1", "socket.io": "^4.8.1", "uuid": "^11.1.0" @@ -97,6 +98,12 @@ "node": ">=18" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -247,6 +254,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -619,6 +635,91 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -901,6 +1002,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/package.json b/package.json index 1e86957..d102f2a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^16.5.0", "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.1", "socket.io": "^4.8.1", "uuid": "^11.1.0" diff --git a/public/js/auth.js b/public/js/auth.js index b4465c8..5be2017 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -5,9 +5,69 @@ 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 + + // Название ключа для хранения JWT в localStorage + const JWT_TOKEN_KEY = 'jwtToken'; + + async function handleAuthResponse(response, formType) { + const regButton = registerForm ? registerForm.querySelector('button') : null; + const loginButton = loginForm ? loginForm.querySelector('button') : null; + + try { + const data = await response.json(); + + if (response.ok && data.success && data.token) { + // Успешная аутентификация/регистрация + localStorage.setItem(JWT_TOKEN_KEY, data.token); // Сохраняем токен + + clientState.isLoggedIn = true; + clientState.loggedInUsername = data.username; + clientState.myUserId = data.userId; + + ui.setAuthMessage(''); // Очищаем сообщение об аутентификации + ui.showGameSelectionScreen(data.username); // Показываем экран выбора игры + + // Важно: переподключить сокет с новым токеном + if (socket.connected) { + socket.disconnect(); + } + // Обновляем auth объект сокета перед подключением + // В main.js при создании сокета, он должен уже брать токен из localStorage + // Но если сокет уже существует, нужно обновить его auth данные + socket.auth = { token: data.token }; + socket.connect(); // Это вызовет 'connect' и 'requestGameState' в main.js + + } else { + // Ошибка аутентификации/регистрации + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + localStorage.removeItem(JWT_TOKEN_KEY); // Удаляем старый токен, если был + ui.setAuthMessage(data.message || 'Ошибка сервера.', true); + } + } catch (error) { + // Ошибка парсинга JSON или другая сетевая ошибка + console.error(`[Auth] Error processing ${formType} response:`, error); + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + localStorage.removeItem(JWT_TOKEN_KEY); + ui.setAuthMessage('Произошла ошибка сети. Попробуйте снова.', true); + } finally { + // Разблокируем кнопки в любом случае + if (regButton) regButton.disabled = false; + if (loginButton) loginButton.disabled = false; + if (logoutButton && clientState.isLoggedIn) logoutButton.disabled = false; + } + } + + // --- Обработчики событий DOM --- if (registerForm) { - registerForm.addEventListener('submit', (e) => { + registerForm.addEventListener('submit', async (e) => { e.preventDefault(); const usernameInput = document.getElementById('register-username'); const passwordInput = document.getElementById('register-password'); @@ -16,19 +76,32 @@ export function initAuth(dependencies) { const username = usernameInput.value; const password = passwordInput.value; - // Блокируем кнопки на время запроса const regButton = registerForm.querySelector('button'); const loginButton = loginForm ? loginForm.querySelector('button') : null; if (regButton) regButton.disabled = true; if (loginButton) loginButton.disabled = true; ui.setAuthMessage('Регистрация...'); - socket.emit('register', { username, password }); + + try { + const response = await fetch(`${API_BASE_URL}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + await handleAuthResponse(response, 'register'); + if (response.ok && registerForm) registerForm.reset(); // Очищаем форму при успехе + } catch (error) { + console.error('[Auth] Network error during registration:', error); + ui.setAuthMessage('Ошибка сети при регистрации. Пожалуйста, проверьте ваше подключение.', true); + if (regButton) regButton.disabled = false; + if (loginButton) loginButton.disabled = false; + } }); } if (loginForm) { - loginForm.addEventListener('submit', (e) => { + loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); @@ -37,92 +110,101 @@ export function initAuth(dependencies) { const username = usernameInput.value; const password = passwordInput.value; - // Блокируем кнопки на время запроса const loginButton = loginForm.querySelector('button'); const regButton = registerForm ? registerForm.querySelector('button') : null; if (loginButton) loginButton.disabled = true; if (regButton) regButton.disabled = true; ui.setAuthMessage('Вход...'); - socket.emit('login', { username, password }); + + try { + const response = await fetch(`${API_BASE_URL}/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); + if (loginButton) loginButton.disabled = false; + if (regButton) regButton.disabled = false; + } }); } if (logoutButton) { logoutButton.addEventListener('click', () => { logoutButton.disabled = true; - socket.emit('logout'); - // Обновляем состояние клиента немедленно, не дожидаясь ответа сервера (опционально) + // --- НАЧАЛО ИЗМЕНЕНИЯ --- + // Если игрок в активной 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' и соответствующим образом завершит игру. + } + // --- КОНЕЦ ИЗМЕНЕНИЯ --- + + + // Серверный эндпоинт для логаута не обязателен для JWT, + // если нет необходимости аннулировать токен на сервере (что сложно с JWT). + // Основное действие - удаление токена на клиенте. + // socket.emit('logout'); // Можно оставить, если на сервере есть логика для этого (например, GameManager.handleDisconnect) + + localStorage.removeItem(JWT_TOKEN_KEY); // Удаляем токен + clientState.isLoggedIn = false; clientState.loggedInUsername = ''; clientState.myUserId = null; // isInGame и другие игровые переменные сбросятся в ui.showAuthScreen() - // disableGameControls() также будет вызван опосредованно через showAuthScreen -> resetGameVariables + // ui.disableGameControls() также будет вызван опосредованно ui.showAuthScreen(); // Показываем экран логина - ui.setGameStatusMessage("Вы вышли из системы."); // Используем gameStatusMessage для общего статуса после выхода - // ui.setAuthMessage("Вы вышли из системы."); // или authMessage, если он виден - // Кнопка разблокируется при следующем показе userInfoDiv или можно здесь - // logoutButton.disabled = false; // но лучше, чтобы UI сам управлял этим при показе + ui.setGameStatusMessage("Вы вышли из системы."); // Можно заменить на ui.setAuthMessage, если хотим видеть сообщение на экране логина + + // Переподключаем сокет без токена + if (socket.connected) { + socket.disconnect(); + } + socket.auth = { token: null }; // Очищаем токен в auth объекте сокета + socket.connect(); // Сокет подключится как неаутентифицированный + + // Кнопка logout будет активирована, когда пользователь снова войдет + // или если она видна только залогиненным пользователям, то исчезнет. + // (В showAuthScreen logoutButton.disabled устанавливается в true) }); } // --- Обработчики событий Socket.IO --- - socket.on('registerResponse', (data) => { - ui.setAuthMessage(data.message, !data.success); - if (data.success && registerForm) { - registerForm.reset(); // Очищаем форму при успехе - } - // Разблокируем кнопки - if (registerForm) { - const regButton = registerForm.querySelector('button'); - if (regButton) regButton.disabled = false; - } - if (loginForm) { - const loginButton = loginForm.querySelector('button'); - if (loginButton) loginButton.disabled = false; - } - }); + // Старые 'registerResponse' и 'loginResponse' больше не нужны, + // так как эти ответы приходят через HTTP. - socket.on('loginResponse', (data) => { - if (data.success) { - clientState.isLoggedIn = true; - clientState.loggedInUsername = data.username; - clientState.myUserId = data.userId; - - ui.setAuthMessage(""); // Очищаем сообщение об аутентификации - ui.showGameSelectionScreen(data.username); // Показываем экран выбора игры - // Запрос gameState при успешном логине и реконнекте теперь обрабатывается в main.js - // если пользователь уже был залогинен при 'connect' - } else { - clientState.isLoggedIn = false; - clientState.loggedInUsername = ''; - clientState.myUserId = null; - ui.setAuthMessage(data.message, true); // Показываем ошибку - } - // Разблокируем кнопки - if (registerForm) { - const regButton = registerForm.querySelector('button'); - if (regButton) regButton.disabled = false; - } - if (loginForm) { - const loginButton = loginForm.querySelector('button'); - if (loginButton) loginButton.disabled = false; - } - // Убедимся, что кнопка logout активна, если пользователь успешно вошел - if (logoutButton && clientState.isLoggedIn) { - logoutButton.disabled = false; - } - }); - - // Примечание: событие 'logout' от сервера обычно не требует специального обработчика здесь, - // так как клиент сам инициирует выход и обновляет UI. - // Если сервер принудительно разлогинивает, то такой обработчик может понадобиться. + // Можно добавить обработчик для принудительного разлогинивания от сервера, если такой будет // 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, + // отдельная логика здесь не так критична. } \ No newline at end of file diff --git a/public/js/client.js b/public/js/client_del.js similarity index 100% rename from public/js/client.js rename to public/js/client_del.js diff --git a/public/js/gameplay.js b/public/js/gameplay.js index 5048d0c..ccd2c3b 100644 --- a/public/js/gameplay.js +++ b/public/js/gameplay.js @@ -1,16 +1,9 @@ -// /public/js/gameplay.js +// /public/js/gameplay.js (Откаченная версия, совместимая с последним GameInstance.js) export function initGameplay(dependencies) { const { socket, clientState, ui } = dependencies; - // Элементы управления боем обычно находятся внутри gameWrapper и управляются через ui.js, - // но нам могут понадобиться ссылки на кнопки для привязки событий, если они не привязаны в ui.js - // или если ui.js не экспортирует их напрямую. - // В данном случае, attackButton и abilitiesGrid есть в client.js, так что получим их. - // ui.elements из main.js содержит returnToMenuButton const { returnToMenuButton } = ui.elements; - // Получаем ссылки на кнопки атаки и способностей напрямую, как было в client.js - // или, если бы ui.js их экспортировал, можно было бы через window.gameUI.uiElements const attackButton = document.getElementById('button-attack'); const abilitiesGrid = document.getElementById('abilities-grid'); @@ -18,28 +11,26 @@ export function initGameplay(dependencies) { function enableGameControls(enableAttack = true, enableAbilities = true) { if (attackButton) attackButton.disabled = !enableAttack; if (abilitiesGrid) { - // Предполагаем, что GAME_CONFIG доступен глобально или его нужно передать const config = window.GAME_CONFIG || {}; const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = !enableAbilities; }); } - // Если кнопка блока есть и управляется отсюда - // if (window.gameUI?.uiElements?.controls?.buttonBlock) window.gameUI.uiElements.controls.buttonBlock.disabled = true; + if (window.gameUI?.updateUI) { + requestAnimationFrame(() => window.gameUI.updateUI()); + } } function disableGameControls() { enableGameControls(false, false); } - // Эта функция была в client.js, переносим сюда function initializeAbilityButtons() { if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) { if (abilitiesGrid) abilitiesGrid.innerHTML = '

Ошибка загрузки способностей.

'; return; } - abilitiesGrid.innerHTML = ''; // Очищаем предыдущие кнопки + abilitiesGrid.innerHTML = ''; const config = window.GAME_CONFIG; - // Используем данные из clientState, которые были обновлены из событий сервера const abilitiesToDisplay = clientState.playerAbilitiesServer; const baseStatsForResource = clientState.playerBaseStatsServer; @@ -56,34 +47,18 @@ export function initGameplay(dependencies) { button.id = `ability-btn-${ability.id}`; button.classList.add(abilityButtonClass); button.dataset.abilityId = ability.id; - - let cooldown = ability.cooldown; // Это базовый КД из данных персонажа + let cooldown = ability.cooldown; let cooldownText = (typeof cooldown === 'number' && cooldown > 0) ? ` (КД: ${cooldown} х.)` : ""; let title = `${ability.name} (${ability.cost} ${resourceName})${cooldownText} - ${ability.description || 'Нет описания'}`; button.setAttribute('title', title); - - const nameSpan = document.createElement('span'); - nameSpan.classList.add('ability-name'); - nameSpan.textContent = ability.name; - button.appendChild(nameSpan); - - const descSpan = document.createElement('span'); - descSpan.classList.add('ability-desc'); - descSpan.textContent = `(${ability.cost} ${resourceName})`; - button.appendChild(descSpan); - - const cdDisplay = document.createElement('span'); - cdDisplay.classList.add('ability-cooldown-display'); // Для отображения текущего КД - cdDisplay.style.display = 'none'; // Скрыт по умолчанию - button.appendChild(cdDisplay); - + const nameSpan = document.createElement('span'); nameSpan.classList.add('ability-name'); nameSpan.textContent = ability.name; button.appendChild(nameSpan); + const descSpan = document.createElement('span'); descSpan.classList.add('ability-desc'); descSpan.textContent = `(${ability.cost} ${resourceName})`; button.appendChild(descSpan); + const cdDisplay = document.createElement('span'); cdDisplay.classList.add('ability-cooldown-display'); cdDisplay.style.display = 'none'; button.appendChild(cdDisplay); button.addEventListener('click', handleAbilityButtonClick); abilitiesGrid.appendChild(button); }); - const placeholder = abilitiesGrid.querySelector('.placeholder-text'); if (placeholder) placeholder.remove(); - // После инициализации кнопок, их состояние (disabled/enabled) будет управляться window.gameUI.updateUI() } function handleAbilityButtonClick(event) { @@ -94,22 +69,13 @@ export function initGameplay(dependencies) { abilityId && clientState.currentGameState && !clientState.currentGameState.isGameOver) { - // Перед отправкой действия можно добавить быструю проверку на клиенте (например, хватает ли ресурса), - // но основная валидация все равно на сервере. socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId }); - disableGameControls(); // Блокируем управление до ответа сервера или следующего хода + disableGameControls(); } else { - console.warn("Cannot perform ability action, invalid state:", { - isLoggedIn: clientState.isLoggedIn, - isInGame: clientState.isInGame, - gameId: clientState.currentGameId, - abilityId, - gameState: clientState.currentGameState - }); + console.warn("Cannot perform ability action, invalid state"); } } - // --- Обработчики событий DOM --- if (attackButton) { attackButton.addEventListener('click', () => { @@ -119,95 +85,50 @@ export function initGameplay(dependencies) { clientState.currentGameState && !clientState.currentGameState.isGameOver) { socket.emit('playerAction', { actionType: 'attack' }); - disableGameControls(); // Блокируем управление до ответа сервера или следующего хода + disableGameControls(); } else { console.warn("Cannot perform attack action, invalid state."); } }); } - if (returnToMenuButton) { // Кнопка из модалки GameOver + if (returnToMenuButton) { returnToMenuButton.addEventListener('click', () => { if (!clientState.isLoggedIn) { - ui.showAuthScreen(); // Если как-то оказались здесь без логина + ui.showAuthScreen(); return; } - returnToMenuButton.disabled = true; // Блокируем на время перехода - // ui.resetGameVariables(); // Вызывается в showGameSelectionScreen - clientState.isInGame = false; // Устанавливаем, что мы больше не в игре - disableGameControls(); // Деактивируем игровые контролы - // window.gameUI.showGameOver(false, "", null, { finalGameState: { isGameOver: false } }); // Скрываем модалку (делается в showGameSelectionScreen) - - ui.showGameSelectionScreen(clientState.loggedInUsername); // Возвращаемся на экран выбора - // Кнопка returnToMenuButton включится при следующем показе модалки GameOver (логика в ui.js или здесь при gameOver) + returnToMenuButton.disabled = true; + clientState.isInGame = false; + disableGameControls(); + ui.showGameSelectionScreen(clientState.loggedInUsername); }); } - // --- Обработчики событий Socket.IO --- - socket.on('gameStarted', (data) => { - if (!clientState.isLoggedIn) return; // Игнорируем, если не залогинены - console.log('[Gameplay] Game started:', data); - // Обновляем состояние клиента - clientState.currentGameId = data.gameId; - clientState.myPlayerId = data.yourPlayerId; - clientState.currentGameState = data.initialGameState; - clientState.playerBaseStatsServer = data.playerBaseStats; - clientState.opponentBaseStatsServer = data.opponentBaseStats; - clientState.playerAbilitiesServer = data.playerAbilities; - clientState.opponentAbilitiesServer = data.opponentAbilities; - clientState.myCharacterKey = data.playerBaseStats?.characterKey; - clientState.opponentCharacterKey = data.opponentBaseStats?.characterKey; - clientState.isInGame = true; - - if (data.clientConfig) { // Если сервер прислал конфиг - // Важно: GAME_CONFIG используется в ui.js и других местах - window.GAME_CONFIG = { ...window.GAME_CONFIG, ...data.clientConfig }; - } else if (!window.GAME_CONFIG) { // Базовый конфиг, если не пришел с сервера и не определен - window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' /* ... другие важные ключи ... */ }; - } - - ui.updateGlobalWindowVariablesForUI(); // Обновляем глобальные переменные для ui.js - - ui.showGameScreen(); // Показываем игровой экран - initializeAbilityButtons(); // Инициализируем кнопки способностей с новыми данными - - // Очистка лога перед началом новой игры - if (window.gameUI?.uiElements?.log?.list) { - window.gameUI.uiElements.log.list.innerHTML = ''; - } - // Добавление начальных логов, если есть - if (window.gameUI && typeof window.gameUI.addToLog === 'function' && data.log) { - data.log.forEach(logEntry => window.gameUI.addToLog(logEntry.message, logEntry.type)); - } - - // Первичное обновление UI боевого экрана - requestAnimationFrame(() => { - if (window.gameUI && typeof window.gameUI.updateUI === 'function') { - window.gameUI.updateUI(); - } - }); - // ui.hideGameOverModal(); // Теперь делается в showGameScreen - ui.setGameStatusMessage(""); // Очищаем общий статус - // Таймер хода будет обновлен событием 'turnTimerUpdate' - }); - - // Используется для восстановления состояния уже идущей игры (например, при реконнекте) - socket.on('gameState', (data) => { + // --- ОБЩИЙ ОБРАБОТЧИК ДЛЯ ЗАПУСКА/ВОССТАНОВЛЕНИЯ ИГРЫ --- + function handleGameDataReceived(data, eventName = "unknown") { if (!clientState.isLoggedIn) return; - console.log('[Gameplay] Received full gameState (e.g. on reconnect):', data); + const username = clientState.loggedInUsername || 'N/A'; // Для логов + console.log(`[CLIENT ${username}] ${eventName} received.`); + // if (data.log) console.log(`[CLIENT ${username}] ${eventName} log content:`, JSON.parse(JSON.stringify(data.log))); + - // Обновляем состояние клиента (похоже на gameStarted) clientState.currentGameId = data.gameId; clientState.myPlayerId = data.yourPlayerId; - clientState.currentGameState = data.gameState; // Используем gameState вместо initialGameState + clientState.currentGameState = data.initialGameState || data.gameState; clientState.playerBaseStatsServer = data.playerBaseStats; clientState.opponentBaseStatsServer = data.opponentBaseStats; clientState.playerAbilitiesServer = data.playerAbilities; clientState.opponentAbilitiesServer = data.opponentAbilities; clientState.myCharacterKey = data.playerBaseStats?.characterKey; clientState.opponentCharacterKey = data.opponentBaseStats?.characterKey; - clientState.isInGame = true; // Устанавливаем, что мы в игре + + if (clientState.currentGameState && !clientState.currentGameState.isGameOver) { + clientState.isInGame = true; + } else if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + clientState.isInGame = false; + } if (data.clientConfig) { window.GAME_CONFIG = { ...window.GAME_CONFIG, ...data.clientConfig }; @@ -216,46 +137,132 @@ export function initGameplay(dependencies) { } ui.updateGlobalWindowVariablesForUI(); - if (!clientState.isInGame || document.querySelector('.game-wrapper').style.display === 'none') { - ui.showGameScreen(); // Показываем игровой экран, если еще не там + const gameWrapperElement = document.querySelector('.game-wrapper'); + if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver) { + const isGameWrapperVisible = gameWrapperElement && (gameWrapperElement.style.display === 'flex' || getComputedStyle(gameWrapperElement).display === 'flex'); + if (!isGameWrapperVisible) { + ui.showGameScreen(); + } } - initializeAbilityButtons(); // Переинициализируем кнопки способностей - // Лог при 'gameState' может быть уже накопленным, очищаем и добавляем новый - if (window.gameUI?.uiElements?.log?.list && data.log) { - window.gameUI.uiElements.log.list.innerHTML = ''; + initializeAbilityButtons(); + + if (window.gameUI?.uiElements?.log?.list) { + // console.log(`[CLIENT ${username}] Log BEFORE clear in ${eventName}:`, window.gameUI.uiElements.log.list.innerHTML.substring(0,100)); + window.gameUI.uiElements.log.list.innerHTML = ''; // Очищаем UI-лог перед добавлением новых + // console.log(`[CLIENT ${username}] Log AFTER clear in ${eventName}:`, window.gameUI.uiElements.log.list.innerHTML); } - if (window.gameUI && typeof window.gameUI.addToLog === 'function' && data.log) { - data.log.forEach(logEntry => window.gameUI.addToLog(logEntry.message, logEntry.type)); + if (window.gameUI?.addToLog && data.log) { + data.log.forEach(logEntry => { + // console.log(`[CLIENT ${username}] Adding to UI log from ${eventName}: "${logEntry.message}"`); + window.gameUI.addToLog(logEntry.message, logEntry.type); + }); } requestAnimationFrame(() => { - if (window.gameUI && typeof window.gameUI.updateUI === 'function') { + if (window.gameUI?.updateUI) { window.gameUI.updateUI(); } + if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver && window.GAME_CONFIG) { + const config = window.GAME_CONFIG; + const isMyActualTurn = clientState.myPlayerId && + ((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || + (!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID)); + if (isMyActualTurn) { + enableGameControls(); + } else { + disableGameControls(); + } + } }); - // ui.hideGameOverModal(); // Делается в showGameScreen - // Таймер хода будет обновлен событием 'turnTimerUpdate' + + // Управление gameStatusMessage + if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + // gameOver имеет свой обработчик статуса (внутри socket.on('gameOver',...)) + } else if (eventName === 'gameStarted' || eventName === 'gameState (reconnect)') { + // Это начало игры или восстановление сессии, статус должен быть чистым + console.log(`[CLIENT ${username}] ${eventName} - Clearing game status message because it's a fresh game/state load.`); + ui.setGameStatusMessage(""); + } else { + // Для gameStateUpdate и других событий, не являющихся полной перезагрузкой, + // gameStatusMessage будет управляться в их обработчиках или через turnTimerUpdate. + // Если игра продолжается и не gameOver, общее сообщение "Ожидание" должно сниматься. + if (clientState.isInGame) { + ui.setGameStatusMessage(""); + } + } + // Если игра пришла завершенной, то showGameOver должен быть вызван. + if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + if (window.gameUI?.showGameOver && !document.getElementById('game-over-screen').classList.contains('hidden')) { + // Экран уже показан + } else if (window.gameUI?.showGameOver) { + let playerWon = false; + if (data.winnerId) { + playerWon = data.winnerId === clientState.myPlayerId; + } else if (clientState.currentGameState.player && clientState.currentGameState.opponent) { + if (clientState.currentGameState.player.currentHp > 0 && clientState.currentGameState.opponent.currentHp <=0) { + playerWon = clientState.myPlayerId === clientState.currentGameState.player.id; + } else if (clientState.currentGameState.opponent.currentHp > 0 && clientState.currentGameState.player.currentHp <=0) { + playerWon = clientState.myPlayerId === clientState.currentGameState.opponent.id; + } + } + window.gameUI.showGameOver(playerWon, data.reason || "Игра завершена", clientState.opponentCharacterKey, { finalGameState: clientState.currentGameState, ...data }); + } + if (returnToMenuButton) returnToMenuButton.disabled = false; + } + } + + + // --- Обработчики событий Socket.IO --- + socket.on('gameStarted', (data) => { + handleGameDataReceived(data, 'gameStarted'); + }); + + socket.on('gameState', (data) => { + handleGameDataReceived(data, 'gameState (reconnect)'); }); socket.on('gameStateUpdate', (data) => { if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return; + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Event: gameStateUpdate.`); clientState.currentGameState = data.gameState; - ui.updateGlobalWindowVariablesForUI(); // Обновляем window.gameState для ui.js + ui.updateGlobalWindowVariablesForUI(); - if (window.gameUI?.updateUI) window.gameUI.updateUI(); + if (window.gameUI?.updateUI) { + requestAnimationFrame(() => { + window.gameUI.updateUI(); + if (clientState.isInGame && clientState.currentGameState && !clientState.currentGameState.isGameOver && window.GAME_CONFIG) { + const config = window.GAME_CONFIG; + const isMyActualTurn = clientState.myPlayerId && + ((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || + (!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID)); + + if (isMyActualTurn) { + enableGameControls(); + } else { + disableGameControls(); + } + + console.log(`[CLIENT ${username}] gameStateUpdate - Clearing game status message as game is active.`); + ui.setGameStatusMessage(""); + + } else if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + disableGameControls(); + } + }); + } - // Добавляем только новые логи, если они есть в этом частичном обновлении if (window.gameUI?.addToLog && data.log) { data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); } - // Логика включения/выключения контролов на основе gameState.isPlayerTurn и myPlayerId - // обычно делается внутри window.gameUI.updateUI() }); socket.on('logUpdate', (data) => { if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return; + const username = clientState.loggedInUsername || 'N/A'; + // console.log(`[CLIENT ${username}] Event: logUpdate. Logs:`, data.log); if (window.gameUI?.addToLog && data.log) { data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); } @@ -263,77 +270,93 @@ export function initGameplay(dependencies) { socket.on('gameOver', (data) => { if (!clientState.isLoggedIn || !clientState.currentGameId || !window.GAME_CONFIG) { - // Если мы не в игре, но залогинены, запросим состояние (вдруг это старое событие) if (!clientState.currentGameId && clientState.isLoggedIn) socket.emit('requestGameState'); - else if (!clientState.isLoggedIn) ui.showAuthScreen(); // Если вообще не залогинены + else if (!clientState.isLoggedIn) ui.showAuthScreen(); return; } + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Event: gameOver.`); const playerWon = data.winnerId === clientState.myPlayerId; - clientState.currentGameState = data.finalGameState; // Обновляем состояние последним - // clientState.isInGame = false; // НЕ СБРАСЫВАЕМ ЗДЕСЬ, чтобы UI показывал экран GameOver. Сбросится при выходе в меню. + clientState.currentGameState = data.finalGameState; + clientState.isInGame = false; - ui.updateGlobalWindowVariablesForUI(); // Обновляем window.gameState для ui.js - - if (window.gameUI?.updateUI) window.gameUI.updateUI(); // Обновляем панели в последний раз + ui.updateGlobalWindowVariablesForUI(); + if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI()); if (window.gameUI?.addToLog && data.log) { data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); } - if (window.gameUI?.showGameOver) { const oppKey = clientState.opponentBaseStatsServer?.characterKey; - window.gameUI.showGameOver(playerWon, data.reason, oppKey, data); // ui.js покажет модалку + window.gameUI.showGameOver(playerWon, data.reason, oppKey, data); } - - if (returnToMenuButton) returnToMenuButton.disabled = false; // Активируем кнопку "Вернуться в меню" - - ui.setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли.")); - - // Обновляем UI таймера, чтобы показать "Конец" или скрыть + if (returnToMenuButton) returnToMenuButton.disabled = false; + // `ui.setGameStatusMessage` будет установлено специфичным сообщением о результате игры + // ui.setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли.")); if (window.gameUI?.updateTurnTimerDisplay) { window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode); } - // Контролы должны быть заблокированы, т.к. игра окончена (ui.js->updateUI это сделает) + disableGameControls(); }); socket.on('opponentDisconnected', (data) => { if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return; - + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Event: opponentDisconnected.`); const name = data.disconnectedCharacterName || clientState.opponentBaseStatsServer?.name || 'Противник'; - if (window.gameUI?.addToLog) { - window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system'); - } - // Если игра еще не окончена, сервер может дать время на переподключение или объявить победу + // Сообщение об отключении оппонента должно приходить через 'logUpdate' от сервера + // if (window.gameUI?.addToLog) { + // window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system'); + // } + if (clientState.currentGameState && !clientState.currentGameState.isGameOver) { ui.setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true); - disableGameControls(); // Блокируем управление, пока сервер не решит исход + disableGameControls(); } }); socket.on('turnTimerUpdate', (data) => { - if (!clientState.isInGame || !clientState.currentGameState || clientState.currentGameState.isGameOver) { - // Если игра окончена или не в игре, обновляем таймер соответственно - if (window.gameUI?.updateTurnTimerDisplay && !clientState.currentGameState?.isGameOver) { - window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode); + if (!clientState.isInGame || !clientState.currentGameState || !window.GAME_CONFIG) { + if (window.gameUI?.updateTurnTimerDisplay && clientState.currentGameState && !clientState.currentGameState.isGameOver) { + window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode); } return; } - if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') { - const config = window.GAME_CONFIG || {}; - // Определяем, является ли текущий ход ходом этого клиента - const isMyActualTurn = clientState.myPlayerId && clientState.currentGameState.isPlayerTurn === (clientState.myPlayerId === config.PLAYER_ID); - window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, clientState.currentGameState.gameMode); + if (clientState.currentGameState.isGameOver) { + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode); + } + disableGameControls(); + return; + } + + const username = clientState.loggedInUsername || 'N/A'; + // console.log(`[CLIENT ${username}] Event: turnTimerUpdate.`); + + if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') { + const config = window.GAME_CONFIG; + const isMyActualTurn = clientState.myPlayerId && clientState.currentGameState && + ((clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || + (!clientState.currentGameState.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID)); + + window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, clientState.currentGameState.gameMode); + + if (isMyActualTurn) { + enableGameControls(); + } else { + disableGameControls(); + } + + if (!clientState.currentGameState.isGameOver) { + console.log(`[CLIENT ${username}] turnTimerUpdate - Clearing game status message as timer is active.`); + ui.setGameStatusMessage(""); + } } - // Логика включения/выключения контролов на основе isMyActualTurn - // обычно выполняется в window.gameUI.updateUI(), которая вызывается после gameStateUpdate. - // Если turnTimerUpdate приходит отдельно и должен влиять на контролы, то нужно добавить: - // if (isMyActualTurn) enableGameControls(); else disableGameControls(); - // Но это может конфликтовать с логикой в updateUI(). Обычно updateUI() - главный источник правды. }); - // Начальная деактивация игровых контролов при загрузке модуля + // Начальная деактивация disableGameControls(); } \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js index a326102..ea0ea2b 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,56 +3,37 @@ import { initAuth } from './auth.js'; import { initGameSetup } from './gameSetup.js'; import { initGameplay } from './gameplay.js'; -// Предполагаем, что ui.js загружен перед этим скриптом (в HTML) -// и создал глобальный объект window.gameUI -// Также ui.js будет читать window.gameState, window.gameData, window.myPlayerId, window.GAME_CONFIG +// ui.js загружен глобально + +function parseJwtPayload(token) { + try { + if (typeof token !== 'string') { return null; } + const parts = token.split('.'); + if (parts.length !== 3) { return null; } + const base64Url = parts[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + return JSON.parse(jsonPayload); + } catch (e) { + console.error("[parseJwtPayload] Error parsing JWT payload:", e); + return null; + } +} document.addEventListener('DOMContentLoaded', () => { - const socket = io({ - // Опции Socket.IO, если нужны - }); + const SERVER_URL = 'http://127.0.0.1:3200'; + const API_BASE_URL = SERVER_URL; + const initialToken = localStorage.getItem('jwtToken'); - // --- DOM Элементы для общего UI-управления --- - // (Эти элементы управляют общим потоком приложения, а не деталями боя) - const authSection = document.getElementById('auth-section'); - const loginForm = document.getElementById('login-form'); - const registerForm = document.getElementById('register-form'); - const authMessage = document.getElementById('auth-message'); - - const statusContainer = document.getElementById('status-container'); - const userInfoDiv = document.getElementById('user-info'); - const loggedInUsernameSpan = document.getElementById('logged-in-username'); - const logoutButton = document.getElementById('logout-button'); // Для auth.js - - const gameSetupDiv = document.getElementById('game-setup'); - const createAIGameButton = document.getElementById('create-ai-game'); - const createPvPGameButton = document.getElementById('create-pvp-game'); - const joinPvPGameButton = document.getElementById('join-pvp-game'); - const findRandomPvPGameButton = document.getElementById('find-random-pvp-game'); - const gameIdInput = document.getElementById('game-id-input'); - const availableGamesDiv = document.getElementById('available-games-list'); - const gameStatusMessage = document.getElementById('game-status-message'); - const pvpCharacterRadios = document.querySelectorAll('input[name="pvp-character"]'); // для gameSetup.js - - const gameWrapper = document.querySelector('.game-wrapper'); - // Элементы, связанные с gameOver, управляются через window.gameUI.showGameOver, - // но кнопка "Вернуться в меню" может быть здесь для общего сброса. - const returnToMenuButton = document.getElementById('return-to-menu-button'); - const turnTimerContainer = document.getElementById('turn-timer-container'); - const turnTimerSpan = document.getElementById('turn-timer'); - - - // --- Состояние клиента (глобальное для main и передаваемое в модули) --- - // Это состояние будет модифицироваться из разных модулей let clientState = { isLoggedIn: false, loggedInUsername: '', myUserId: null, isInGame: false, - // Игровые переменные, которые ранее были глобальными в client.js - // и от которых зависит ui.js currentGameId: null, - currentGameState: null, + currentGameState: null, // Будет объектом или null myPlayerId: null, myCharacterKey: null, opponentCharacterKey: null, @@ -62,10 +43,55 @@ document.addEventListener('DOMContentLoaded', () => { opponentAbilitiesServer: null, }; - // Обновляем глобальные переменные window, на которые рассчитывает ui.js - // Это временная мера. В идеале, ui.js должен получать эти данные как аргументы функций. + if (initialToken) { + const decodedToken = parseJwtPayload(initialToken); + if (decodedToken && decodedToken.userId && decodedToken.username) { + const nowInSeconds = Math.floor(Date.now() / 1000); + if (decodedToken.exp && decodedToken.exp > nowInSeconds) { + console.log("[Client Init] Token found, pre-populating clientState."); + clientState.isLoggedIn = true; + clientState.myUserId = decodedToken.userId; + clientState.loggedInUsername = decodedToken.username; + } else { + console.warn("[Client Init] Token expired or invalid 'exp'. Clearing."); + localStorage.removeItem('jwtToken'); + } + } else { + console.warn("[Client Init] Token invalid or missing data. Clearing."); + localStorage.removeItem('jwtToken'); + } + } + + const socket = io(SERVER_URL, { + autoConnect: false, + auth: { token: localStorage.getItem('jwtToken') } + }); + + const authSection = document.getElementById('auth-section'); + const loginForm = document.getElementById('login-form'); + const registerForm = document.getElementById('register-form'); + const authMessage = document.getElementById('auth-message'); + const statusContainer = document.getElementById('status-container'); + const userInfoDiv = document.getElementById('user-info'); + const loggedInUsernameSpan = document.getElementById('logged-in-username'); + const logoutButton = document.getElementById('logout-button'); + const gameSetupDiv = document.getElementById('game-setup'); + const createAIGameButton = document.getElementById('create-ai-game'); + const createPvPGameButton = document.getElementById('create-pvp-game'); + const joinPvPGameButton = document.getElementById('join-pvp-game'); + const findRandomPvPGameButton = document.getElementById('find-random-pvp-game'); + const gameIdInput = document.getElementById('game-id-input'); + const availableGamesDiv = document.getElementById('available-games-list'); + const gameStatusMessage = document.getElementById('game-status-message'); + const pvpCharacterRadios = document.querySelectorAll('input[name="pvp-character"]'); + const gameWrapper = document.querySelector('.game-wrapper'); + const returnToMenuButton = document.getElementById('return-to-menu-button'); // Он же в ui.elements.gameOver.returnToMenuButton + const turnTimerContainer = document.getElementById('turn-timer-container'); + const turnTimerSpan = document.getElementById('turn-timer'); + function updateGlobalWindowVariablesForUI() { - window.gameState = clientState.currentGameState; + // console.log("[Main] Updating global window variables. currentGameState:", clientState.currentGameState ? JSON.parse(JSON.stringify(clientState.currentGameState)) : null); + window.gameState = clientState.currentGameState; // Может быть null window.gameData = { playerBaseStats: clientState.playerBaseStatsServer, opponentBaseStats: clientState.opponentBaseStatsServer, @@ -73,78 +99,18 @@ document.addEventListener('DOMContentLoaded', () => { opponentAbilities: clientState.opponentAbilitiesServer }; window.myPlayerId = clientState.myPlayerId; - // window.GAME_CONFIG остается как есть, если он глобальный и не меняется часто - // Если GAME_CONFIG приходит от сервера, его тоже нужно обновлять здесь - // if (clientState.serverConfig) window.GAME_CONFIG = { ...clientState.serverConfig }; - } - - - // --- Функции управления UI (для переключения основных экранов и общих сообщений) --- - function showAuthScreen() { - authSection.style.display = 'block'; - userInfoDiv.style.display = 'none'; - gameSetupDiv.style.display = 'none'; - gameWrapper.style.display = 'none'; - if (window.gameUI?.showGameOver) { // Скрываем модалку GameOver, если была - window.gameUI.showGameOver(false, "", null, { finalGameState: { isGameOver: false } }); - } - setAuthMessage("Ожидание подключения к серверу..."); - statusContainer.style.display = 'block'; - clientState.isInGame = false; - // disableGameControls(); // Вызов будет из gameplay.js - resetGameVariables(); // Важно для сброса состояния - updateGlobalWindowVariablesForUI(); // Обновляем глоб. переменные для ui.js - if (turnTimerContainer) turnTimerContainer.style.display = 'none'; - if (turnTimerSpan) turnTimerSpan.textContent = '--'; - } - - function showGameSelectionScreen(username) { - authSection.style.display = 'none'; - userInfoDiv.style.display = 'block'; - loggedInUsernameSpan.textContent = username; - gameSetupDiv.style.display = 'block'; - gameWrapper.style.display = 'none'; - if (window.gameUI?.showGameOver) { // Скрываем модалку GameOver - window.gameUI.showGameOver(false, "", null, { finalGameState: { isGameOver: false } }); - } - setGameStatusMessage("Выберите режим игры или присоединитесь к существующей."); - statusContainer.style.display = 'block'; - socket.emit('requestPvPGameList'); // Запрашиваем список игр - if (availableGamesDiv) availableGamesDiv.innerHTML = '

Доступные PvP игры:

Загрузка...

'; // Очистка перед запросом - if (gameIdInput) gameIdInput.value = ''; - - const elenaRadio = document.getElementById('char-elena'); // Для сброса выбора персонажа - if (elenaRadio) elenaRadio.checked = true; - - clientState.isInGame = false; - // disableGameControls(); // Вызов будет из gameplay.js - resetGameVariables(); - updateGlobalWindowVariablesForUI(); - if (turnTimerContainer) turnTimerContainer.style.display = 'none'; - if (turnTimerSpan) turnTimerSpan.textContent = '--'; - enableSetupButtons(); - } - - function showGameScreen() { - if (window.gameUI?.showGameOver) { // Скрываем модалку GameOver - window.gameUI.showGameOver(false, "", null, { finalGameState: { isGameOver: false } }); - } - authSection.style.display = 'none'; - userInfoDiv.style.display = 'block'; // Оставляем инфо о пользователе - gameSetupDiv.style.display = 'none'; - gameWrapper.style.display = 'flex'; - setGameStatusMessage(""); // Очищаем статус - statusContainer.style.display = 'none'; // Скрываем общий статус контейнер - clientState.isInGame = true; - // disableGameControls(); // Начальная деактивация, gameplay.js включит при ходе - updateGlobalWindowVariablesForUI(); // Убедимся, что ui.js имеет свежие данные - if (turnTimerContainer) turnTimerContainer.style.display = 'block'; - if (turnTimerSpan) turnTimerSpan.textContent = '--'; + // window.GAME_CONFIG устанавливается при gameStarted/gameState из gameplay.js } function resetGameVariables() { + console.log("[Main:resetGameVariables] Resetting game variables. State BEFORE:", JSON.parse(JSON.stringify(clientState))); clientState.currentGameId = null; + // ВАЖНО: currentGameState должен быть сброшен в состояние "нет игры" + // Либо null, либо объект, который ui.js интерпретирует как "нет игры" clientState.currentGameState = null; + // Можно также так, если ui.js лучше работает с объектом: + // clientState.currentGameState = { isGameOver: false, player: null, opponent: null, turnNumber: 0 }; + clientState.myPlayerId = null; clientState.myCharacterKey = null; clientState.opponentCharacterKey = null; @@ -152,17 +118,123 @@ document.addEventListener('DOMContentLoaded', () => { clientState.opponentBaseStatsServer = null; clientState.playerAbilitiesServer = null; clientState.opponentAbilitiesServer = null; - // Также обновляем глобальные переменные для ui.js - updateGlobalWindowVariablesForUI(); + // clientState.isInGame будет установлено в вызывающей функции (showAuthScreen/showGameSelectionScreen) + + updateGlobalWindowVariablesForUI(); // Обновляем глобальные переменные СРАЗУ после сброса + console.log("[Main:resetGameVariables] Game variables reset. State AFTER:", JSON.parse(JSON.stringify(clientState))); } + function explicitlyHideGameOverModal() { + console.log("[Main:explicitlyHideGameOverModal] Attempting to hide Game Over modal."); + if (window.gameUI?.uiElements?.gameOver?.screen && window.GAME_CONFIG) { + const gameOverScreenElement = window.gameUI.uiElements.gameOver.screen; + const modalContentElement = window.gameUI.uiElements.gameOver.modalContent; + const messageElement = window.gameUI.uiElements.gameOver.message; + const hiddenClass = window.GAME_CONFIG.CSS_CLASS_HIDDEN || 'hidden'; + + if (gameOverScreenElement && !gameOverScreenElement.classList.contains(hiddenClass)) { + gameOverScreenElement.classList.add(hiddenClass); + // Принудительно сбрасываем стили для анимации скрытия, если она есть + gameOverScreenElement.style.opacity = '0'; + if (modalContentElement) { + modalContentElement.style.transform = 'scale(0.8) translateY(30px)'; + modalContentElement.style.opacity = '0'; + } + console.log("[Main:explicitlyHideGameOverModal] Game Over screen explicitly hidden."); + } else if (gameOverScreenElement) { + console.log("[Main:explicitlyHideGameOverModal] Game Over screen was already hidden or not found."); + } + if (messageElement) messageElement.textContent = ''; // Очищаем сообщение + } else { + console.warn("[Main:explicitlyHideGameOverModal] Cannot hide Game Over modal: gameUI or GAME_CONFIG not available."); + } + } + + + function showAuthScreen() { + console.log("[Main:showAuthScreen] Showing Auth Screen. Resetting game state."); + authSection.style.display = 'block'; + userInfoDiv.style.display = 'none'; + gameSetupDiv.style.display = 'none'; + gameWrapper.style.display = 'none'; + + explicitlyHideGameOverModal(); // <-- ЯВНО СКРЫВАЕМ МОДАЛКУ + + statusContainer.style.display = 'block'; + clientState.isInGame = false; // Важно + resetGameVariables(); // Сбрасываем все переменные предыдущей игры + + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + if(registerForm) registerForm.querySelector('button').disabled = false; + if(loginForm) loginForm.querySelector('button').disabled = false; + if(logoutButton) logoutButton.disabled = true; // Кнопка Logout должна быть недоступна на экране логина + } + + function showGameSelectionScreen(username) { + console.log(`[Main:showGameSelectionScreen] Showing Game Selection Screen for ${username}. Resetting game state.`); + authSection.style.display = 'none'; + userInfoDiv.style.display = 'block'; + if(loggedInUsernameSpan) loggedInUsernameSpan.textContent = username; + if(logoutButton) logoutButton.disabled = false; // Logout доступен + gameSetupDiv.style.display = 'block'; + gameWrapper.style.display = 'none'; + + explicitlyHideGameOverModal(); // <-- ЯВНО СКРЫВАЕМ МОДАЛКУ + + setGameStatusMessage("Выберите режим игры или присоединитесь к существующей."); + statusContainer.style.display = 'block'; + + if (socket.connected) { + socket.emit('requestPvPGameList'); + } else { + console.warn("[Main:showGameSelectionScreen] Socket not connected, cannot request PvP game list yet."); + } + + if (availableGamesDiv) availableGamesDiv.innerHTML = '

Доступные PvP игры:

Загрузка...

'; + if (gameIdInput) gameIdInput.value = ''; + const elenaRadio = document.getElementById('char-elena'); + if (elenaRadio) elenaRadio.checked = true; + + clientState.isInGame = false; // Важно + resetGameVariables(); // Сбрасываем все переменные предыдущей игры + + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + enableSetupButtons(); + // Убедимся, что кнопка "Вернуться в меню" на gameOver модалке (если она вдруг видима) активна, + // хотя сама модалка должна быть скрыта. + if (window.gameUI?.uiElements?.gameOver?.returnToMenuButton) { + window.gameUI.uiElements.gameOver.returnToMenuButton.disabled = false; + } + } + + function showGameScreen() { + console.log("[Main:showGameScreen] Showing Game Screen."); + // Не нужно здесь вызывать explicitlyHideGameOverModal, так как если игра начинается, + // а модалка была видима, это ошибка логики где-то еще. + // GameStarted/GameState должно само приводить UI в порядок. + authSection.style.display = 'none'; + userInfoDiv.style.display = 'block'; + if(logoutButton) logoutButton.disabled = false; + gameSetupDiv.style.display = 'none'; + gameWrapper.style.display = 'flex'; + setGameStatusMessage(""); + statusContainer.style.display = 'none'; + clientState.isInGame = true; // Важно + updateGlobalWindowVariablesForUI(); // Обновляем перед тем, как UI начнет рендерить игровой экран + if (turnTimerContainer) turnTimerContainer.style.display = 'block'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + } + + function setAuthMessage(message, isError = false) { if (authMessage) { authMessage.textContent = message; authMessage.className = isError ? 'error' : 'success'; authMessage.style.display = message ? 'block' : 'none'; } - if (message && gameStatusMessage) gameStatusMessage.style.display = 'none'; // Скрываем другой статус + if (message && gameStatusMessage && gameStatusMessage.style.display !== 'none') gameStatusMessage.style.display = 'none'; } function setGameStatusMessage(message, isError = false) { @@ -172,10 +244,9 @@ document.addEventListener('DOMContentLoaded', () => { gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)'; if (statusContainer) statusContainer.style.display = message ? 'block' : 'none'; } - if (message && authMessage) authMessage.style.display = 'none'; // Скрываем другой статус + if (message && authMessage && authMessage.style.display !== 'none') authMessage.style.display = 'none'; } - // Функции для управления кнопками на экране выбора игры (могут быть вызваны из gameSetup) function disableSetupButtons() { if(createAIGameButton) createAIGameButton.disabled = true; if(createPvPGameButton) createPvPGameButton.disabled = true; @@ -188,109 +259,159 @@ document.addEventListener('DOMContentLoaded', () => { if(createPvPGameButton) createPvPGameButton.disabled = false; if(joinPvPGameButton) joinPvPGameButton.disabled = false; if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false; - // Кнопки в списке игр включаются в updateAvailableGamesList (в gameSetup.js) + // Кнопки в списке доступных игр управляются в gameSetup.js -> updateAvailableGamesList } - // --- Сборка зависимостей для передачи в модули --- const dependencies = { socket, - clientState, // Объект состояния, который модули могут читать и изменять - ui: { // Функции и элементы для управления общим UI и состоянием + clientState, + ui: { showAuthScreen, showGameSelectionScreen, showGameScreen, setAuthMessage, setGameStatusMessage, - resetGameVariables, - updateGlobalWindowVariablesForUI, // Важно для ui.js + resetGameVariables, // Передаем, чтобы другие модули могли вызвать при необходимости (хотя лучше избегать) + updateGlobalWindowVariablesForUI, disableSetupButtons, enableSetupButtons, - elements: { // Передаем элементы, нужные для специфической логики модулей - // Для auth.js - loginForm, - registerForm, - logoutButton, - // Для gameSetup.js - createAIGameButton, - createPvPGameButton, - joinPvPGameButton, - findRandomPvPGameButton, - gameIdInput, - availableGamesDiv, - pvpCharacterRadios, - // Для gameplay.js (или для обработки gameover здесь) - returnToMenuButton, + elements: { + loginForm, registerForm, logoutButton, + createAIGameButton, createPvPGameButton, joinPvPGameButton, + findRandomPvPGameButton, gameIdInput, availableGamesDiv, + pvpCharacterRadios, returnToMenuButton, // returnToMenuButton из gameplay.js, но здесь тоже может быть полезен } }, - // gameUI: window.gameUI // Можно передать, если модули должны напрямую вызывать gameUI. - // Но пока gameplay.js будет использовать глобальный window.gameUI + API_BASE_URL: API_BASE_URL }; - // Инициализация модулей initAuth(dependencies); initGameSetup(dependencies); initGameplay(dependencies); - - // --- Обработчики событий Socket.IO (глобальные для приложения) --- socket.on('connect', () => { - console.log('[Client] Socket connected:', socket.id); - setAuthMessage("Успешно подключено к серверу. Вход..."); + const currentToken = socket.auth.token || localStorage.getItem('jwtToken'); + console.log('[Main:SocketConnect] Socket connected:', socket.id, 'Auth token sent:', !!currentToken); + if (clientState.isLoggedIn && clientState.myUserId) { - // Пытаемся восстановить состояние игры, если были залогинены + console.log(`[Main:SocketConnect] Client state indicates logged in as ${clientState.loggedInUsername}. Requesting game state.`); + if (authSection.style.display === 'block' || gameSetupDiv.style.display === 'block') { + // Если мы на экране логина или выбора игры, но считаем себя залогиненными, + // покажем сообщение о восстановлении. + setGameStatusMessage("Восстановление игровой сессии..."); + } + // Не очищаем здесь resetGameVariables, так как gameplay.js ожидает, что clientState может содержать + // предыдущие данные, которые он перезапишет при получении gameState или gameStarted. + // Если придет gameNotFound, то там уже будет reset. socket.emit('requestGameState'); } else { - // Показываем экран логина, если не залогинены - showAuthScreen(); + console.log('[Main:SocketConnect] Client state indicates NOT logged in. Showing auth screen.'); + showAuthScreen(); // Убеждаемся, что все сброшено и показан экран логина + setAuthMessage("Пожалуйста, войдите или зарегистрируйтесь."); } }); - socket.on('disconnect', (reason) => { - console.warn('[Client] Disconnected:', reason); - setGameStatusMessage(`Отключено от сервера: ${reason}. Попытка переподключения...`, true); - // Здесь можно добавить логику для UI, показывающую состояние "отключено" - // disableGameControls(); // будет в gameplay - if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.'; - // Не сбрасываем isLoggedIn, чтобы при переподключении можно было восстановить сессию + socket.on('connect_error', (err) => { + console.error('[Main:SocketConnectError] Socket connection error:', err.message, err.data ? err.data : ''); + const errorMessageLower = err.message ? err.message.toLowerCase() : ""; + const isAuthError = errorMessageLower.includes('auth') || errorMessageLower.includes('token') || + errorMessageLower.includes('unauthorized') || err.message === 'invalid token' || + err.message === 'no token' || (err.data && typeof err.data === 'string' && err.data.toLowerCase().includes('auth')); + + if (isAuthError) { + console.warn('[Main:SocketConnectError] Authentication error. Clearing token, resetting state, showing auth screen.'); + localStorage.removeItem('jwtToken'); + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + if (socket.auth) socket.auth.token = null; + + showAuthScreen(); // Это вызовет resetGameVariables и скроет модалку + setAuthMessage("Ошибка аутентификации. Пожалуйста, войдите снова.", true); + } else { + if (clientState.isLoggedIn && clientState.isInGame) { + setGameStatusMessage(`Ошибка подключения: ${err.message}. Попытка переподключения...`, true); + } else if (clientState.isLoggedIn) { + setGameStatusMessage(`Ошибка подключения к серверу: ${err.message}. Попытка переподключения...`, true); + } else { + setAuthMessage(`Ошибка подключения к серверу: ${err.message}. Попытка переподключения...`, true); + if (authSection.style.display !== 'block') { + showAuthScreen(); // Если не на экране логина, но ошибка не auth, все равно показываем его + } + } + } + if (turnTimerSpan) turnTimerSpan.textContent = 'Ошибка'; + }); + + socket.on('disconnect', (reason) => { + console.warn('[Main:SocketDisconnect] Disconnected from server:', reason); + // Сообщения в зависимости от текущего состояния + if (clientState.isInGame) { + setGameStatusMessage(`Потеряно соединение: ${reason}. Попытка переподключения...`, true); + } else if (clientState.isLoggedIn) { + // Уже должен быть на экране выбора игры или восстановления, setGameStatusMessage там уместно + if (gameSetupDiv.style.display === 'block') { + setGameStatusMessage(`Потеряно соединение с сервером: ${reason}. Попытка переподключения...`, true); + } else { + // Если где-то между экранами, но залогинен + setAuthMessage(`Потеряно соединение: ${reason}. Попытка переподключения...`, true); // Используем authMessage для общего случая + } + } else { + setAuthMessage(`Потеряно соединение с сервером: ${reason}. Попытка переподключения...`, true); + } + if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.'; + // Не сбрасываем clientState.isLoggedIn здесь, чтобы socket.connect мог попытаться восстановить сессию }); - // Общая обработка ошибок от сервера, если они не перехвачены в модулях socket.on('gameError', (data) => { - console.error('[Client] Received gameError:', data.message); - // Показываем ошибку пользователю + console.error('[Main:SocketGameError] Received gameError from server:', data.message); if (clientState.isInGame && window.gameUI?.addToLog) { window.gameUI.addToLog(`❌ Ошибка сервера: ${data.message}`, 'system'); - // Здесь можно решить, нужно ли возвращать в меню или просто показать сообщение - } else if (clientState.isLoggedIn) { + // Можно добавить setGameStatusMessage и здесь, если ошибка критическая для игры + } else if (clientState.isLoggedIn) { // На экране выбора игры setGameStatusMessage(`❌ Ошибка: ${data.message}`, true); - enableSetupButtons(); // Возвращаем активность кнопкам на экране выбора игры - } else { + enableSetupButtons(); // Разблокировать кнопки, если ошибка при создании/присоединении + } else { // На экране логина setAuthMessage(`❌ Ошибка: ${data.message}`, true); if(registerForm) registerForm.querySelector('button').disabled = false; if(loginForm) loginForm.querySelector('button').disabled = false; } }); - // Обработчик для gameNotFound, который может прийти при реконнекте, если игры нет socket.on('gameNotFound', (data) => { - console.log('[Client] Main: Game not found/ended:', data?.message); - dependencies.ui.resetGameVariables(); // Сбрасываем игровые переменные - clientState.isInGame = false; - // disableGameControls(); // в gameplay - if (window.gameUI?.showGameOver) window.gameUI.showGameOver(false, "", null, { finalGameState: { isGameOver: false } }); // Скрыть модалку + console.log('[Main:SocketGameNotFound] Game not found/ended after request:', data?.message); + + // Важно: gameNotFound означает, что активной игры нет. + // Сбрасываем состояние и показываем экран выбора игры, если залогинены. + clientState.isInGame = false; // Явно выходим из игры + resetGameVariables(); // Полный сброс игровых переменных + explicitlyHideGameOverModal(); // Убеждаемся, что модалка скрыта + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; if (turnTimerSpan) turnTimerSpan.textContent = '--'; - if (clientState.isLoggedIn) { - showGameSelectionScreen(clientState.loggedInUsername); - setGameStatusMessage(data?.message || "Активная игровая сессия не найдена."); + if (clientState.isLoggedIn && clientState.myUserId) { + showGameSelectionScreen(clientState.loggedInUsername); // Переходим на выбор игры (он вызовет resetGameVariables еще раз, но это не страшно) + setGameStatusMessage(data?.message || "Активная игровая сессия не найдена. Выберите новую игру."); } else { + // Если по какой-то причине мы не залогинены (например, токен истек и connect_error сбросил isLoggedIn) showAuthScreen(); setAuthMessage(data?.message || "Пожалуйста, войдите."); } }); + // Инициализация UI + authSection.style.display = 'none'; + gameSetupDiv.style.display = 'none'; + gameWrapper.style.display = 'none'; + userInfoDiv.style.display = 'none'; + statusContainer.style.display = 'block'; - // --- Инициализация UI --- - showAuthScreen(); // Показываем начальный экран аутентификации + if (clientState.isLoggedIn) { + setGameStatusMessage("Подключение и восстановление сессии..."); // Или setAuthMessage, если statusContainer не виден сразу + } else { + setAuthMessage("Подключение к серверу..."); + } + + socket.connect(); }); \ No newline at end of file diff --git a/server/auth/authService.js b/server/auth/authService.js index f0a6e79..99325c2 100644 --- a/server/auth/authService.js +++ b/server/auth/authService.js @@ -1,14 +1,15 @@ // /server/auth/authService.js const bcrypt = require('bcryptjs'); // Для хеширования паролей -const db = require('../core/db'); // Путь к вашему модулю для работы с базой данных (в папке core) +const jwt = require('jsonwebtoken'); // <<< ДОБАВЛЕНО +const db = require('../core/db'); // Путь к вашему модулю для работы с базой данных const SALT_ROUNDS = 10; // Количество раундов для генерации соли bcrypt /** - * Регистрирует нового пользователя. + * Регистрирует нового пользователя и генерирует JWT. * @param {string} username - Имя пользователя. * @param {string} password - Пароль пользователя. - * @returns {Promise} Объект с результатом: { success: boolean, message: string, userId?: number, username?: string } + * @returns {Promise} Объект с результатом: { success: boolean, message: string, token?: string, userId?: number, username?: string } */ async function registerUser(username, password) { console.log(`[AuthService DEBUG] registerUser called with username: "${username}"`); @@ -50,11 +51,21 @@ async function registerUser(username, password) { console.log(`[AuthService DEBUG] Stage C: DB insert result for "${username}":`, result); if (result && result.insertId) { - console.log(`[AuthService] Пользователь "${username}" успешно зарегистрирован с ID: ${result.insertId}.`); + const userId = result.insertId; + // Генерируем JWT токен + const tokenPayload = { userId: userId, username: username }; + const token = jwt.sign( + tokenPayload, + process.env.JWT_SECRET, // Используем секрет из .env + { expiresIn: process.env.JWT_EXPIRES_IN || '1h' } // Используем срок из .env или по умолчанию 1 час + ); + + console.log(`[AuthService] Пользователь "${username}" успешно зарегистрирован с ID: ${userId}. Токен выдан.`); return { success: true, - message: 'Регистрация прошла успешно!', - userId: result.insertId, + message: 'Регистрация прошла успешно! Вы вошли в систему.', + token: token, // <<< ВОЗВРАЩАЕМ ТОКЕН + userId: userId, username: username // Возвращаем и имя пользователя }; } else { @@ -74,10 +85,10 @@ async function registerUser(username, password) { } /** - * Выполняет вход пользователя. + * Выполняет вход пользователя и генерирует JWT. * @param {string} username - Имя пользователя. * @param {string} password - Пароль пользователя. - * @returns {Promise} Объект с результатом: { success: boolean, message: string, userId?: number, username?: string } + * @returns {Promise} Объект с результатом: { success: boolean, message: string, token?: string, userId?: number, username?: string } */ async function loginUser(username, password) { console.log(`[AuthService DEBUG] loginUser called with username: "${username}"`); @@ -104,10 +115,19 @@ async function loginUser(username, password) { console.log(`[AuthService DEBUG] Password comparison result for "${username}": ${passwordMatch}`); if (passwordMatch) { - console.log(`[AuthService] Пользователь "${user.username}" (ID: ${user.id}) успешно вошел в систему.`); + // Генерируем JWT токен + const tokenPayload = { userId: user.id, username: user.username }; + const token = jwt.sign( + tokenPayload, + process.env.JWT_SECRET, // Используем секрет из .env + { expiresIn: process.env.JWT_EXPIRES_IN || '1h' } // Используем срок из .env или по умолчанию 1 час + ); + + console.log(`[AuthService] Пользователь "${user.username}" (ID: ${user.id}) успешно вошел в систему. Токен выдан.`); return { success: true, message: 'Вход выполнен успешно!', + token: token, // <<< ВОЗВРАЩАЕМ ТОКЕН userId: user.id, username: user.username // Возвращаем имя пользователя }; diff --git a/server/bc.js b/server/bc.js index d0a5e35..dbf860e 100644 --- a/server/bc.js +++ b/server/bc.js @@ -1,104 +1,155 @@ // /server/bc.js - Главный файл сервера Battle Club -// Загружаем переменные окружения В САМОМ НАЧАЛЕ, до всех других импортов, -// которые могут зависеть от process.env -// Убедитесь, что путь к .env правильный относительно места запуска приложения. -// Если bc.js запускается из папки server/, а .env в корне, то путь должен быть '../.env' -// Но обычно dotenv ищет .env в process.cwd() -// Но обычно dotenv ищет .env в process.cwd() - -//Test - require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') }); - const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); +const jwt = require('jsonwebtoken'); +const cors = require('cors'); -// Импорт серверных модулей из их новых местоположений -const authService = require('./auth/authService'); // Сервис аутентификации -const GameManager = require('./game/GameManager'); // Менеджер игр -const db = require('./core/db'); // Модуль базы данных (важно, чтобы он тоже использовал dotenv) -const GAME_CONFIG = require('./core/config'); // Глобальный конфиг игры +// Импорт серверных модулей +const authService = require('./auth/authService'); +const GameManager = require('./game/GameManager'); +const db = require('./core/db'); +const GAME_CONFIG = require('./core/config'); const app = express(); const server = http.createServer(app); -// Настройка Socket.IO -const io = new Server(server, { - cors: { - // origin: process.env.CORS_ORIGIN || "https://pavel-chagovsky.com:3200", // Пример, если нужно CORS из .env - // methods: ["GET", "POST"] - }, - // pingInterval: 10000, - // pingTimeout: 5000, -}); +// --- НАСТРОЙКА EXPRESS --- +const clientOrigin = process.env.CORS_ORIGIN_CLIENT || (process.env.NODE_ENV === 'development' ? '*' : undefined); +if (!clientOrigin && process.env.NODE_ENV !== 'development') { + console.warn("[Server Config] CORS_ORIGIN_CLIENT не установлен для продакшн сборки. HTTP API могут быть недоступны."); +} -// Раздача статических файлов из папки 'public' +app.use(cors({ + origin: clientOrigin, + methods: ["GET", "POST"], +})); + +app.use(express.json()); app.use(express.static(path.join(__dirname, '..', 'public'))); -// Создаем экземпляр GameManager -const gameManager = new GameManager(io); +// --- HTTP МАРШРУТЫ АУТЕНТИФИКАЦИИ --- +app.post('/auth/register', async (req, res) => { + const { username, password } = req.body; + console.log(`[HTTP /auth/register] Attempt for username: "${username}"`); + if (!username || !password) { + return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' }); + } + const result = await authService.registerUser(username, password); + if (result.success) { + res.status(201).json(result); + } else { + res.status(400).json(result); + } +}); +app.post('/auth/login', async (req, res) => { + const { username, password } = req.body; + console.log(`[HTTP /auth/login] Attempt for username: "${username}"`); + if (!username || !password) { + return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' }); + } + const result = await authService.loginUser(username, password); + if (result.success) { + res.json(result); + } else { + res.status(401).json(result); + } +}); +// ------------------------------ + +const socketCorsOrigin = process.env.CORS_ORIGIN_SOCKET || (process.env.NODE_ENV === 'development' ? '*' : undefined); +if (!socketCorsOrigin && process.env.NODE_ENV !== 'development') { + console.warn("[Server Config] CORS_ORIGIN_SOCKET не установлен для продакшн сборки. Socket.IO может быть недоступен."); +} + +const io = new Server(server, { + cors: { + origin: socketCorsOrigin, + methods: ["GET", "POST"] + }, +}); + +const gameManager = new GameManager(io); const loggedInUsers = {}; +// --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO --- +io.use(async (socket, next) => { + const token = socket.handshake.auth.token; + console.log(`[Socket.IO Middleware] Attempting to auth socket ${socket.id}. Token ${token ? 'present' : 'absent'}.`); + + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + socket.userData = { userId: decoded.userId, username: decoded.username }; + console.log(`[Socket.IO Middleware] Socket ${socket.id} authenticated for user ${decoded.username} (ID: ${decoded.userId}).`); + return next(); + } catch (err) { + console.warn(`[Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}`); + } + } else { + console.log(`[Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`); + } + next(); +}); +// ------------------------------------ + io.on('connection', (socket) => { - console.log(`[Socket.IO] Пользователь подключился: ${socket.id}`); - socket.userData = null; - - socket.on('register', async (data) => { - console.log(`[Socket.IO] Register attempt for username: "${data?.username}" from ${socket.id}`); - const result = await authService.registerUser(data?.username, data?.password); - if (result.success) { - console.log(`[Socket.IO] Registration successful for ${result.username} (${result.userId})`); - } else { - console.warn(`[Socket.IO] Registration failed for "${data?.username}": ${result.message}`); + if (socket.userData && socket.userData.userId) { + console.log(`[Socket.IO] Authenticated user ${socket.userData.username} (ID: ${socket.userData.userId}) connected: ${socket.id}`); + loggedInUsers[socket.id] = socket.userData; + if (gameManager && typeof gameManager.handleRequestGameState === 'function') { + gameManager.handleRequestGameState(socket, socket.userData.userId); } - socket.emit('registerResponse', result); - }); - - socket.on('login', async (data) => { - console.log(`[Socket.IO] Login attempt for username: "${data?.username}" from ${socket.id}`); - const result = await authService.loginUser(data?.username, data?.password); - if (result.success && result.userId && result.username) { - console.log(`[Socket.IO] Login successful for ${result.username} (${result.userId}). Assigning to socket ${socket.id}.`); - socket.userData = { userId: result.userId, username: result.username }; - loggedInUsers[socket.id] = socket.userData; - if (gameManager && typeof gameManager.handleRequestGameState === 'function') { - gameManager.handleRequestGameState(socket, result.userId); - } - } else { - console.warn(`[Socket.IO] Login failed for "${data?.username}": ${result.message}`); - socket.userData = null; - if (loggedInUsers[socket.id]) delete loggedInUsers[socket.id]; - } - socket.emit('loginResponse', result); - }); + } else { + console.log(`[Socket.IO] Unauthenticated user connected: ${socket.id}. No game state will be restored.`); + } socket.on('logout', () => { const username = socket.userData?.username || 'UnknownUser'; const userId = socket.userData?.userId; console.log(`[Socket.IO] Logout request from user ${username} (ID: ${userId}, Socket: ${socket.id})`); - if (gameManager && typeof gameManager.handleDisconnect === 'function' && userId) { - gameManager.handleDisconnect(socket.id, userId); - } + // GameManager.handleDisconnect будет вызван автоматически при событии 'disconnect' + // Если игрок нажал "выход", но еще в игре, клиент пошлет 'playerSurrender' ДО этого. + // Здесь просто очищаем локальные данные сессии для сокета. if (loggedInUsers[socket.id]) { delete loggedInUsers[socket.id]; } socket.userData = null; - console.log(`[Socket.IO] User ${username} (Socket: ${socket.id}) logged out.`); + console.log(`[Socket.IO] User ${username} (Socket: ${socket.id}) logged out (client-side action).`); }); - socket.on('createGame', (data) => { - const identifier = socket.userData?.userId || socket.id; - const mode = data?.mode || 'ai'; - if (mode === 'pvp' && !socket.userData) { - socket.emit('gameError', { message: 'Необходимо войти в систему для создания PvP игры.' }); + // --- НАЧАЛО ИЗМЕНЕНИЯ --- + socket.on('playerSurrender', () => { + if (!socket.userData?.userId) { + socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы сдаться в игре.' }); return; } - console.log(`[Socket.IO] Create Game from ${socket.userData?.username || socket.id} (ID: ${identifier}). Mode: ${mode}, Char: ${data?.characterKey}`); + const identifier = socket.userData.userId; + const username = socket.userData.username; + console.log(`[Socket.IO] Player Surrender request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`); + if (gameManager && typeof gameManager.handlePlayerSurrender === 'function') { + gameManager.handlePlayerSurrender(identifier); + } else { + console.error("[Socket.IO] CRITICAL: gameManager or handlePlayerSurrender method not found!"); + socket.emit('gameError', { message: 'Ошибка сервера при обработке сдачи игры.' }); + } + // После этого клиент выполнит logout, что вызовет 'disconnect' и GameManager.handleDisconnect + }); + // --- КОНЕЦ ИЗМЕНЕНИЯ --- + + socket.on('createGame', (data) => { + if (!socket.userData?.userId) { + socket.emit('gameError', { message: 'Необходимо войти в систему для создания игры.' }); + return; + } + const identifier = socket.userData.userId; + const mode = data?.mode || 'ai'; + console.log(`[Socket.IO] Create Game from ${socket.userData.username} (ID: ${identifier}). Mode: ${mode}, Char: ${data?.characterKey}`); gameManager.createGame(socket, mode, data?.characterKey, identifier); }); @@ -134,26 +185,30 @@ io.on('connection', (socket) => { }); socket.on('playerAction', (actionData) => { - const identifier = socket.userData?.userId || socket.id; + if (!socket.userData?.userId) { + socket.emit('gameError', { message: 'Действие не разрешено: пользователь не аутентифицирован.' }); + return; + } + const identifier = socket.userData.userId; gameManager.handlePlayerAction(identifier, actionData); }); socket.on('disconnect', (reason) => { - const identifier = socket.userData?.userId || socket.id; - console.log(`[Socket.IO] Пользователь отключился: ${socket.id} (Причина: ${reason}). Identifier: ${identifier}`); - gameManager.handleDisconnect(socket.id, identifier); + const identifier = socket.userData?.userId; + const username = socket.userData?.username || 'UnauthenticatedUser'; + console.log(`[Socket.IO] 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]; } }); }); -// Запуск HTTP сервера -// Используем переменные окружения или значения по умолчанию const PORT = parseInt(process.env.BC_APP_PORT || '3200', 10); const HOSTNAME = process.env.BC_APP_HOSTNAME || '127.0.0.1'; -// Проверка, что порт является числом if (isNaN(PORT)) { console.error(`[Server FATAL] Некорректное значение для BC_APP_PORT: "${process.env.BC_APP_PORT}". Ожидается число.`); process.exit(1); @@ -170,11 +225,13 @@ server.listen(PORT, HOSTNAME, () => { } console.log(`Environment (NODE_ENV): ${process.env.NODE_ENV || 'not set (defaults to development behavior)'}`); console.log(`Serving static files from: ${path.join(__dirname, '..', 'public')}`); + console.log(`Client HTTP requests should target: http://${HOSTNAME}:${PORT}`); + console.log(`Socket.IO CORS origin set to: ${socketCorsOrigin || 'Not explicitly set, defaults may apply'}`); + console.log(`HTTP API CORS origin set to: ${clientOrigin || 'Not explicitly set, defaults may apply'}`); }); process.on('unhandledRejection', (reason, promise) => { console.error('[Server FATAL] Unhandled Rejection at:', promise, 'reason:', reason); - // process.exit(1); }); process.on('uncaughtException', (err) => { diff --git a/server/core/config.js b/server/core/config.js index 864355a..d2b893b 100644 --- a/server/core/config.js +++ b/server/core/config.js @@ -29,6 +29,7 @@ const GAME_CONFIG = { TURN_DURATION_SECONDS: 60, // Длительность хода в секундах TURN_DURATION_MS: 60 * 1000, // Длительность хода в миллисекундах TIMER_UPDATE_INTERVAL_MS: 1000, // Интервал обновления таймера на клиенте (в мс) + RECONNECT_TIMEOUT_MS: 5000, // --- Идентификаторы и Типы --- PLAYER_ID: 'player', // Технический идентификатор для слота 'Игрок 1' diff --git a/server/game/GameManager.js b/server/game/GameManager.js index 6e15e5a..31402b5 100644 --- a/server/game/GameManager.js +++ b/server/game/GameManager.js @@ -7,335 +7,594 @@ const GAME_CONFIG = require('../core/config'); class GameManager { constructor(io) { this.io = io; - this.games = {}; - this.userIdentifierToGameId = {}; - this.pendingPvPGames = []; + this.games = {}; // Активные инстансы игр { gameId: GameInstance } + this.userIdentifierToGameId = {}; // { userId: gameId } + this.pendingPvPGames = []; // Массив gameId игр, ожидающих второго игрока console.log("[GameManager] Initialized."); } _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { + console.log(`[GameManager._removePreviousPendingGames] Called for user: ${identifier}, currentSocket: ${currentSocketId}, excludeGameId: ${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 oldOwnerInfo = Object.values(gameToRemove.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - if (oldOwnerInfo && (oldOwnerInfo.identifier === identifier)) { - console.log(`[GameManager] Пользователь ${identifier} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`); - this._cleanupGame(oldPendingGameId, 'replaced_by_new_game'); + 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.`); } } + } else { + console.log(`[GameManager._removePreviousPendingGames] No old pending game found for user ${identifier} or conditions not met.`); } } createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) { - this._removePreviousPendingGames(socket.id, identifier); + console.log(`[GameManager.createGame] User: ${identifier} (Socket: ${socket.id}), Mode: ${mode}, Char: ${chosenCharacterKey}`); if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { - socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); - this.handleRequestGameState(socket, identifier); - return; + 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}`); + + // Если игра существует и НЕ завершена + if (!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); // Попытка восстановить сессию в существующей игре + 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._removePreviousPendingGames(socket.id, identifier); const gameId = uuidv4(); + console.log(`[GameManager.createGame] Generated new GameID: ${gameId}`); const game = new GameInstance(gameId, this.io, mode, this); - game.ownerIdentifier = identifier; // Устанавливаем владельца игры this.games[gameId] = game; const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; if (game.addPlayer(socket, charKeyForInstance, identifier)) { this.userIdentifierToGameId[identifier] = gameId; - console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${identifier} (выбран: ${charKeyForInstance})`); - const assignedPlayerId = game.players[socket.id]?.id; + 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; if (!assignedPlayerId) { - this._cleanupGame(gameId, 'player_add_failed_no_role'); - socket.emit('gameError', { message: 'Ошибка сервера при создании игры (роль).' }); + 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: 'Ошибка сервера при создании игры (не удалось присвоить роль).' }); return; } socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); + console.log(`[GameManager.createGame] Emitted 'gameCreated' to ${identifier}. gameId: ${gameId}, yourPlayerId: ${assignedPlayerId}`); - if ((game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2)) { + if (mode === 'ai') { const isInitialized = game.initializeGame(); - if (isInitialized) game.startGame(); - else this._cleanupGame(gameId, 'initialization_failed_on_create'); - - if (game.mode === 'pvp' && game.playerCount === 2) { // Если PvP заполнилась - const idx = this.pendingPvPGames.indexOf(gameId); - if (idx > -1) this.pendingPvPGames.splice(idx, 1); - this.broadcastAvailablePvPGames(); + if (isInitialized) { + console.log(`[GameManager.createGame] AI game ${gameId} initialized, starting game...`); + game.startGame(); + } else { + console.error(`[GameManager.createGame] AI game ${gameId} initialization failed. Cleaning up.`); + this._cleanupGame(gameId, 'initialization_failed_on_ai_create'); + } + } else if (mode === 'pvp') { + 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(', ')}`); } - } else if (mode === 'pvp' && game.playerCount === 1) { - if (!this.pendingPvPGames.includes(gameId)) this.pendingPvPGames.push(gameId); game.initializeGame(); + console.log(`[GameManager.createGame] PvP game ${gameId} initialized (or placeholder). Emitting 'waitingForOpponent'.`); socket.emit('waitingForOpponent'); this.broadcastAvailablePvPGames(); } } else { - this._cleanupGame(gameId, 'player_add_failed_instance'); + console.error(`[GameManager.createGame] game.addPlayer failed for user ${identifier} in game ${gameId}. Cleaning up.`); + this._cleanupGame(gameId, 'player_add_failed_in_instance'); } } - joinGame(socket, gameId, identifier) { // identifier - это userId присоединяющегося - const game = this.games[gameId]; - if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } - if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться как к PvP.' }); return; } - if (game.playerCount >= 2) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } - - // === ИЗМЕНЕНИЕ: Запрет присоединения к своей же игре === - if (game.ownerIdentifier === identifier) { - socket.emit('gameError', { message: 'Вы не можете присоединиться к игре, которую сами создали и ожидаете.' }); - // Можно отправить состояние этой игры, если она действительно ожидает - this.handleRequestGameState(socket, identifier); + joinGame(socket, gameIdToJoin, identifier) { + console.log(`[GameManager.joinGame] User: ${identifier} (Socket: ${socket.id}) attempts to join GameID: ${gameIdToJoin}`); + 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 (this.userIdentifierToGameId[identifier] && this.userIdentifierToGameId[identifier] !== gameId) { - socket.emit('gameError', { message: 'Вы уже находитесь в другой активной игре.' }); - this.handleRequestGameState(socket, 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; } - // Проверка на случай, если игрок пытается присоединиться к игре, где он уже есть (хотя ownerIdentifier проверка выше это частично покрывает для создателя) - const existingPlayerInThisGame = Object.values(game.players).find(p => p.identifier === identifier); - if (existingPlayerInThisGame) { - socket.emit('gameError', { message: 'Вы уже находитесь в этой игре.' }); - this.handleRequestGameState(socket, identifier); // Отправляем состояние игры - 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}`); + socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } - - - this._removePreviousPendingGames(socket.id, identifier, gameId); + 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 (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}`); + } + } + this._removePreviousPendingGames(socket.id, identifier, gameIdToJoin); if (game.addPlayer(socket, null, identifier)) { - this.userIdentifierToGameId[identifier] = gameId; - console.log(`[GameManager] Игрок ${identifier} присоединился к PvP игре ${gameId}`); - - if (game.mode === 'pvp' && game.playerCount === 2) { + 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) game.startGame(); - else this._cleanupGame(gameId, 'initialization_failed_on_join'); - - const idx = this.pendingPvPGames.indexOf(gameId); - if (idx > -1) this.pendingPvPGames.splice(idx, 1); + if (isInitialized) { + 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; + } + 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(', ')}`); + } 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) { - this._removePreviousPendingGames(socket.id, identifier); + console.log(`[GameManager.findAndJoinRandomPvPGame] User: ${identifier} (Socket: ${socket.id}), CharForCreation: ${chosenCharacterKeyForCreation}`); if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { - socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); - this.handleRequestGameState(socket, identifier); - return; - } - - let gameIdToJoin = null; - const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena'; - - // Ищем игру, созданную НЕ текущим пользователем - for (const id of [...this.pendingPvPGames]) { - const pendingGame = this.games[id]; - // === ИЗМЕНЕНИЕ: Убеждаемся, что не присоединяемся к игре, которую сами создали и ожидаем === - if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) { - // === КОНЕЦ ИЗМЕНЕНИЯ === - const firstPlayerInfo = Object.values(pendingGame.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) { - gameIdToJoin = id; break; - } - if (!gameIdToJoin) gameIdToJoin = id; // Берем первую подходящую, если нет с нужным персонажем + 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: 'Вы уже находитесь в активной или ожидающей игре.' }); + 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}`); } } + 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 + 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 (gameIdToJoin) { this.joinGame(socket, gameIdToJoin, identifier); } else { + console.log(`[GameManager.findAndJoinRandomPvPGame] No suitable pending game found for ${identifier}. Creating a new PvP game.`); this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); - // Сообщение о создании новой игры отправляется из createGame/initializeGame/startGame } } handlePlayerAction(identifier, actionData) { const gameId = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handlePlayerAction] User: ${identifier}, Action: ${actionData?.actionType}, AbilityID: ${actionData?.abilityId}, GameID from map: ${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); + return; + } const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); - const currentSocketId = playerInfo?.socket?.id; - if (playerInfo && currentSocketId) { - const actualSocket = this.io.sockets.sockets.get(currentSocketId); - if (actualSocket?.connected) game.processPlayerAction(currentSocketId, actionData); - else console.warn(`[GameManager] Игрок ${identifier}: действие, но сокет ${currentSocketId} отключен.`); + 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] Игрок ${identifier}: действие для игры ${gameId}, но не найден в game.players.`); + 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 s = this.io.sockets.sockets.get(identifier) || playerInfo?.socket; - if (s) s.emit('gameNotFound', { message: 'Ваша игровая сессия потеряна (ошибка игрока).' }); + const clientSocket = this._findClientSocketByIdentifier(identifier); + if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия потеряна (ошибка игрока при действии).' }); } } else { - console.warn(`[GameManager] Игрок ${identifier}: действие, но игра ${gameId} не найдена.`); + 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 s = this.io.sockets.sockets.get(identifier); - if (s) s.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена.' }); + const clientSocket = this._findClientSocketByIdentifier(identifier); + if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена (игра отсутствует).' }); } } - handleDisconnect(socketId, identifier) { + handlePlayerSurrender(identifier) { const gameId = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handlePlayerSurrender] User: ${identifier} surrendered. GameID from map: ${gameId}`); + const game = this.games[gameId]; if (game) { - // Ищем игрока по ИДЕНТИФИКАТОРУ, так как сокет мог уже обновиться при переподключении - const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); - - if (playerInfo) { - // Проверяем, действительно ли отключается АКТУАЛЬНЫЙ сокет этого игрока - if (playerInfo.socket.id === socketId) { - console.log(`[GameManager] Актуальный сокет ${socketId} игрока ${identifier} отключился из игры ${gameId}.`); - const dPlayerRole = playerInfo.id; - const dCharKey = playerInfo.chosenCharacterKey; - - game.removePlayer(socketId); // Удаляем именно этот сокет из игры - - if (game.playerCount === 0) { - this._cleanupGame(gameId, 'all_players_disconnected'); - } else if (game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) { - game.endGameDueToDisconnect(socketId, dPlayerRole, dCharKey); - } else if (game.mode === 'ai' && game.playerCount === 0 && game.gameState && !game.gameState.isGameOver) { - game.endGameDueToDisconnect(socketId, dPlayerRole, dCharKey); // Завершаем AI игру, если игрок ушел - } - // Если игра уже была isGameOver, _cleanupGame был вызван ранее. - // userIdentifierToGameId[identifier] для отключившегося игрока УДАЛЯЕТСЯ здесь, - // чтобы он мог начать новую игру или переподключиться. - delete this.userIdentifierToGameId[identifier]; - } else { - // Отключился старый сокет (socketId), но у игрока (identifier) уже новый активный сокет. - // Ничего не делаем с игрой, так как игрок по-прежнему в ней с новым сокетом. - // Просто логируем, что старый сокет отвалился. - console.log(`[GameManager] Отключился старый сокет ${socketId} для игрока ${identifier}, который уже переподключился с сокетом ${playerInfo.socket.id} в игре ${gameId}.`); - // Связь userIdentifierToGameId[identifier] остается, так как он все еще в игре. + 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. } + return; + } + + 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 { - // Игрока с таким identifier нет в этой игре. - // Это может случиться, если игра была очищена до того, как пришло событие disconnect. - // console.log(`[GameManager] Отключившийся сокет ${socketId} (identifier: ${identifier}) не найден в активных игроках игры ${gameId} (возможно, игра уже очищена).`); - delete this.userIdentifierToGameId[identifier]; // На всякий случай. + 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.`); + } } } else { - // console.log(`[GameManager] Отключился сокет ${socketId} (identifier: ${identifier}). Активная игра не найдена по идентификатору.`); - delete this.userIdentifierToGameId[identifier]; + 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]; + } + } + } + + _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; + } + } + // 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. + 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'}`); + + 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 (typeof game.handlePlayerPotentiallyLeft === 'function') { + game.handlePlayerPotentiallyLeft(playerInfo.id, playerInfo.identifier, playerInfo.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]; + } + } + } 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]}.`); + delete this.userIdentifierToGameId[identifier]; + } } } _cleanupGame(gameId, reason = 'unknown') { + console.log(`[GameManager._cleanupGame] Attempting to cleanup GameID: ${gameId}, Reason: ${reason}`); const game = this.games[gameId]; - if (!game) return false; - console.log(`[GameManager] Очистка игры ${gameId} (Причина: ${reason}).`); - - if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear(); - - Object.values(game.players).forEach(pInfo => { - if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) { - delete this.userIdentifierToGameId[pInfo.identifier]; + 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(); } - }); - if(game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId){ - delete this.userIdentifierToGameId[game.ownerIdentifier]; + // 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 } + console.log(`[GameManager._cleanupGame] Cleaning up game ${gameId}. Owner: ${game.ownerIdentifier}. Reason: ${reason}.`); + 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}.`); + } + + // 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 => { + 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++; + } + }); + // 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 (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); + if (pendingIdx > -1) { + this.pendingPvPGames.splice(pendingIdx, 1); + console.log(`[GameManager._cleanupGame] Game ${gameId} removed from pending list. Current pending: ${this.pendingPvPGames.join(', ')}`); + } delete this.games[gameId]; - this.broadcastAvailablePvPGames(); + 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 return true; } getAvailablePvPGamesListForClient() { - 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); - let p1Username = 'Игрок'; - let p1CharName = ''; - let ownerId = game.ownerIdentifier; // === ИЗМЕНЕНИЕ: Получаем ownerId === + // console.log(`[GameManager.getAvailablePvPGamesListForClient] Generating list from pending: ${this.pendingPvPGames.join(', ')}`); + 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 ownerId = game.ownerIdentifier; - if (p1Info) { - p1Username = p1Info.socket?.userData?.username || `User#${String(p1Info.identifier).substring(0,4)}`; - const charData = dataUtils.getCharacterBaseStats(p1Info.chosenCharacterKey); - p1CharName = charData?.name || p1Info.chosenCharacterKey; + 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) + 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 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 { - id: gameId, - status: `Ожидает (Создал: ${p1Username} за ${p1CharName})`, - ownerIdentifier: ownerId // === ИЗМЕНЕНИЕ: Отправляем ownerIdentifier клиенту === - }; - } - return null; - }).filter(info => info !== null); + return null; + }) + .filter(info => info !== null); } broadcastAvailablePvPGames() { - this.io.emit('availablePvPGamesList', this.getAvailablePvPGamesListForClient()); + const list = this.getAvailablePvPGamesListForClient(); + // console.log(`[GameManager.broadcastAvailablePvPGames] Broadcasting list of ${list.length} games.`); + this.io.emit('availablePvPGamesList', list); } handleRequestGameState(socket, identifier) { - const gameId = this.userIdentifierToGameId[identifier]; - const game = gameId ? this.games[gameId] : null; + const gameIdFromMap = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handleRequestGameState] User: ${identifier} (New 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'}`); + if (playerInfoInGameInstance) { if (game.gameState?.isGameOver) { - delete this.userIdentifierToGameId[identifier]; + 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. return; } - console.log(`[GameManager] Восстановление игры ${gameId} для ${identifier}. Новый сокет ${socket.id}.`); - const oldSocketId = playerInfoInGameInstance.socket?.id; // Добавил ?. на случай если сокета нет - if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) { - delete game.players[oldSocketId]; - if(game.playerSockets[playerInfoInGameInstance.id]?.id === oldSocketId) { - delete game.playerSockets[playerInfoInGameInstance.id]; + + console.log(`[GameManager.handleRequestGameState] Restoring game ${gameIdFromMap} for user ${identifier}. NewSocket: ${socket.id}. OldSocketInGame: ${playerInfoInGameInstance.socket?.id}. Player role: ${playerInfoInGameInstance.id}`); + + 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; + } + } 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; } } - playerInfoInGameInstance.socket = socket; - game.players[socket.id] = playerInfoInGameInstance; - 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 oCharKey = game.gameState?.[opponentRole]?.characterKey || (playerInfoInGameInstance.id === GAME_CONFIG.PLAYER_ID ? game.opponentCharacterKey : game.playerCharacterKey); - const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; // oData может быть null, если оппонента нет + 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}`); - if (pData && (oData || !game.opponentCharacterKey) && game.gameState) { socket.emit('gameStarted', { - gameId: game.id, yourPlayerId: playerInfoInGameInstance.id, initialGameState: game.gameState, + gameId: game.id, + yourPlayerId: playerInfoInGameInstance.id, + initialGameState: gameStateToSend, playerBaseStats: pData.baseStats, - opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1}, // Заглушка если оппонента нет + 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: game.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + log: logBufferToSend, + clientConfig: { ...GAME_CONFIG } }); - if(game.mode === 'pvp' && game.playerCount === 1 && game.ownerIdentifier === identifier) socket.emit('waitingForOpponent'); - if (!game.gameState.isGameOver && game.turnTimer?.start) { - game.turnTimer.start(game.gameState.isPlayerTurn, (game.mode === 'ai' && !game.gameState.isPlayerTurn)); + + 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 { - this._handleGameRecoveryError(socket, gameId, identifier, 'data_load_fail_reconnect'); + 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'); } } else { - this._handleGameRecoveryError(socket, gameId, identifier, 'player_not_in_instance_reconnect'); + 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'); } } 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]; + } } } _handleGameRecoveryError(socket, gameId, identifier, reasonCode) { - console.error(`[GameManager] Ошибка восстановления игры ${gameId} для ${identifier} (причина: ${reasonCode}).`); + console.error(`[GameManager._handleGameRecoveryError] Error recovering game (ID: ${gameId || 'N/A'}) for user ${identifier}. Reason: ${reasonCode}.`); socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' }); - this._cleanupGame(gameId, `recovery_error_${reasonCode}`); + if (gameId) { + // Attempt to cleanup the problematic game + 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}`); + } + // This will also clear userIdentifierToGameId[identifier] if _cleanupGame didn't. + 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 7ac1175..09d9606 100644 --- a/server/game/instance/GameInstance.js +++ b/server/game/instance/GameInstance.js @@ -1,9 +1,9 @@ // /server/game/instance/GameInstance.js const { v4: uuidv4 } = require('uuid'); const TurnTimer = require('./TurnTimer'); -const gameLogic = require('../logic'); // Импортирует index.js из папки logic +const gameLogic = require('../logic'); const dataUtils = require('../../data/dataUtils'); -const GAME_CONFIG = require('../../core/config'); // <--- УБЕДИТЕСЬ, ЧТО GAME_CONFIG ИМПОРТИРОВАН +const GAME_CONFIG = require('../../core/config'); class GameInstance { constructor(gameId, io, mode = 'ai', gameManager) { @@ -20,13 +20,16 @@ class GameInstance { this.opponentCharacterKey = null; this.ownerIdentifier = null; this.gameManager = gameManager; + this.reconnectTimers = {}; this.turnTimer = new TurnTimer( GAME_CONFIG.TURN_DURATION_MS, GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, () => this.handleTurnTimeout(), (remainingTime, isPlayerTurnForTimer) => { - this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, isPlayerTurn: isPlayerTurnForTimer }); + if (!this.isGameEffectivelyPaused()) { + this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, isPlayerTurn: isPlayerTurnForTimer }); + } } ); @@ -37,15 +40,23 @@ class GameInstance { } addPlayer(socket, chosenCharacterKey = 'elena', identifier) { - if (this.players[socket.id]) { - socket.emit('gameError', { message: 'Ваш сокет уже зарегистрирован в этой игре.' }); - return false; - } + 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) { - socket.emit('gameError', { message: 'Вы уже находитесь в этой игре под другим подключением.' }); + console.warn(`[GameInstance ${this.id}] Identifier ${identifier} already associated with player role ${existingPlayerByIdentifier.id} (socket ${existingPlayerByIdentifier.socket?.id}). Handling as potential reconnect.`); + 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; } + if (this.playerCount >= 2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); return false; @@ -63,84 +74,309 @@ class GameInstance { actualCharacterKey = 'elena'; this.ownerIdentifier = identifier; } else { // PvP - if (this.playerCount === 0) { + if (!Object.values(this.players).some(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected)) { assignedPlayerId = GAME_CONFIG.PLAYER_ID; - actualCharacterKey = (chosenCharacterKey === 'almagest') ? 'almagest' : 'elena'; + actualCharacterKey = (chosenCharacterKey === 'almagest' || chosenCharacterKey === 'balard') ? chosenCharacterKey : 'elena'; this.ownerIdentifier = identifier; - } else { + } else if (!Object.values(this.players).some(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected)) { assignedPlayerId = GAME_CONFIG.OPPONENT_ID; const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - actualCharacterKey = (firstPlayerInfo?.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; + 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 + } else { + socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' }); + return false; } } + const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId); + if (oldPlayerSocketIdForRole) { + console.log(`[GameInstance ${this.id}] Role ${assignedPlayerId} was previously occupied by socket ${oldPlayerSocketIdForRole}. Removing old entry.`); + delete this.players[oldPlayerSocketIdForRole]; + } + this.players[socket.id] = { - id: assignedPlayerId, socket: socket, - chosenCharacterKey: actualCharacterKey, identifier: identifier + id: assignedPlayerId, + socket: socket, + chosenCharacterKey: actualCharacterKey, + identifier: identifier, + isTemporarilyDisconnected: false }; this.playerSockets[assignedPlayerId] = socket; 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}.`); + console.log(`[GameInstance ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterBaseStats?.name || 'N/A'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Активных игроков: ${this.playerCount}.`); return true; } - removePlayer(socketId) { + 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}) покинул игру.`); + console.log(`[GameInstance ${this.id}] Окончательное удаление игрока ${playerInfo.identifier} (сокет: ${socketId}, роль: ${playerRole}). Причина: ${reason}.`); if (playerInfo.socket) { try { playerInfo.socket.leave(this.id); } catch (e) { /* ignore */ } } + + if (!playerInfo.isTemporarilyDisconnected) { // Только если он не был уже помечен как "временно отключен" + this.playerCount--; + } + delete this.players[socketId]; - this.playerCount--; if (this.playerSockets[playerRole]?.id === socketId) { delete this.playerSockets[playerRole]; } + + console.log(`[GameInstance ${this.id}] Игрок ${playerInfo.identifier} удален. Активных игроков: ${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); + } } + } else { + console.log(`[GameInstance ${this.id}] Попытка удалить игрока по socketId ${socketId}, но он не найден.`); } } - initializeGame() { - console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Игроков: ${this.playerCount}.`); - if (this.mode === 'ai' && this.playerCount === 1) { - this.playerCharacterKey = 'elena'; this.opponentCharacterKey = 'balard'; - } else if (this.mode === 'pvp' && this.playerCount === 2) { - const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - this.playerCharacterKey = p1Info?.chosenCharacterKey || 'elena'; - this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; - } else if (this.mode === 'pvp' && this.playerCount === 1) { - const p1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - this.playerCharacterKey = p1Info?.chosenCharacterKey || 'elena'; - this.opponentCharacterKey = null; - } else { - console.error(`[GameInstance ${this.id}] Некорректное состояние для инициализации!`); return false; + handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) { + console.log(`[GameInstance ${this.id}] handlePlayerPotentiallyLeft CALLED for role ${playerIdRole}, identifier ${identifier}, charKey ${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; } - const playerData = dataUtils.getCharacterData(this.playerCharacterKey); - let opponentData = null; - const isOpponentDefined = !!this.opponentCharacterKey; - if (isOpponentDefined) opponentData = dataUtils.getCharacterData(this.opponentCharacterKey); + playerEntry.isTemporarilyDisconnected = true; + this.playerCount--; // Уменьшаем счетчик АКТИВНЫХ игроков + console.log(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) помечен как временно отключенный. Активных игроков: ${this.playerCount}. Запуск таймера реконнекта.`); - if (!playerData || (isOpponentDefined && !opponentData)) { - this._handleCriticalError('init_char_data_fail', 'Ошибка загрузки данных персонажей при инициализации.'); + const disconnectedPlayerName = this.gameState?.[playerIdRole]?.name || characterKey || `Игрок (роль ${playerIdRole})`; + const disconnectLogMessage = `🔌 Игрок ${disconnectedPlayerName} отключился. Ожидание переподключения...`; + this.addToLog(disconnectLogMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + + this.io.to(this.id).emit('opponentDisconnected', { + disconnectedPlayerId: playerIdRole, + disconnectedCharacterName: disconnectedPlayerName, + }); + this.broadcastLogUpdate(); // Отправляем лог об отключении + + 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}, таймер хода остановлен.`); + } + + 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}) уже переподключился или был удален ранее. Таймер истек без действия.`); + } + }, GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000); + } + + handlePlayerReconnected(playerIdRole, newSocket) { + const identifier = newSocket.userData?.userId; + console.log(`[GameInstance ${this.id}] handlePlayerReconnected CALLED for role ${playerIdRole}, identifier ${identifier}, newSocket ${newSocket.id}`); + + if (this.gameState && this.gameState.isGameOver) { + console.warn(`[GameInstance ${this.id}] Игрок ${identifier} (роль ${playerIdRole}) пытается переподключиться к уже ЗАВЕРШЕННОЙ игре. Отправка gameError.`); + newSocket.emit('gameError', { message: 'Не удалось восстановить сессию: игра уже завершена.' }); + // GameManager.handleRequestGameState должен был это перехватить, но на всякий случай. return false; } - if (isOpponentDefined && (!opponentData.baseStats.maxHp || opponentData.baseStats.maxHp <= 0)) { - this._handleCriticalError('init_opponent_hp_fail', 'Некорректные HP оппонента при инициализации.'); + + const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); + + if (playerEntry && playerEntry.isTemporarilyDisconnected) { + this.clearReconnectTimer(playerIdRole); + const oldSocketId = playerEntry.socket.id; + + if (this.players[oldSocketId]) { + delete this.players[oldSocketId]; + } + + playerEntry.socket = newSocket; + playerEntry.isTemporarilyDisconnected = false; + this.players[newSocket.id] = playerEntry; + + this.playerSockets[playerIdRole] = newSocket; + 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 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 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, + 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 } + }); + + 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 + } + + 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)); + } else { + console.log(`[GameInstance ${this.id}] Другой игрок (${otherPlayerEntry?.id}) отключен, таймер хода не запускается после реконнекта ${playerIdRole}.`); + } + } + return true; + + } else if (playerEntry && !playerEntry.isTemporarilyDisconnected) { + console.warn(`[GameInstance ${this.id}] Игрок ${identifier} (роль: ${playerIdRole}) пытается переподключиться, но не был помечен как отключенный.`); + if (playerEntry.socket.id !== newSocket.id) { + newSocket.emit('gameError', {message: "Вы уже активно подключены к этой игре."}); + } + return false; + } else { + console.warn(`[GameInstance ${this.id}] Не удалось найти игрока ${identifier} (роль: ${playerIdRole}) для переподключения, или он не был помечен как отключенный.`); + newSocket.emit('gameError', { message: 'Не удалось восстановить сессию в этой игре.'}); + return false; + } + } + + clearReconnectTimer(playerIdRole) { + if (this.reconnectTimers[playerIdRole]) { + clearTimeout(this.reconnectTimers[playerIdRole]); + delete this.reconnectTimers[playerIdRole]; + console.log(`[GameInstance ${this.id}] Таймер реконнекта для роли ${playerIdRole} очищен.`); + } + } + + clearAllReconnectTimers() { + console.log(`[GameInstance ${this.id}] Очистка ВСЕХ таймеров реконнекта.`); + for (const roleId in this.reconnectTimers) { + this.clearReconnectTimer(roleId); + } + } + + 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); + } 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 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'; + 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; + } + + const playerData = this.playerCharacterKey ? dataUtils.getCharacterData(this.playerCharacterKey) : null; + let opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null; + const isOpponentDefined = !!this.opponentCharacterKey; + + 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; } this.gameState = { - player: this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities), - opponent: isOpponentDefined ? + player: playerData ? + 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.OPPONENT_ID, opponentData.baseStats, opponentData.abilities) : - this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: 'Ожидание игрока...', maxHp: 1, maxResource: 0, resourceName: 'Ресурс', attackPower: 0, characterKey: null }, []), // Плейсхолдер + 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 }; @@ -149,14 +385,14 @@ class GameInstance { this.logBuffer = []; this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); const pCharKey = this.gameState.player.characterKey; - const oCharKey = this.gameState.opponent.characterKey; // Нужен ключ оппонента для контекста + const oCharKey = this.gameState.opponent.characterKey; if ((pCharKey === 'elena' || pCharKey === 'almagest') && oCharKey) { - const opponentFullDataForTaunt = dataUtils.getCharacterData(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}`); + console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Готовность к старту (isOpponentDefined): ${isOpponentDefined}`); return isOpponentDefined; } @@ -168,7 +404,7 @@ class GameInstance { resourceName: baseStats.resourceName, attackPower: baseStats.attackPower, isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} }; - (abilities || []).forEach(ability => { // Добавлена проверка abilities + (abilities || []).forEach(ability => { if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { fighterState.abilityCooldowns[ability.id] = 0; } @@ -181,8 +417,12 @@ class GameInstance { } startGame() { + if (this.isGameEffectivelyPaused()) { + console.log(`[GameInstance ${this.id}] Попытка старта игры, но она на паузе из-за дисконнекта. Старт отложен.`); + return; + } if (!this.gameState || !this.gameState.opponent?.characterKey) { - this._handleCriticalError('start_game_not_ready', 'Попытка старта не полностью готовой игры.'); + this._handleCriticalError('start_game_not_ready', 'Попытка старта не полностью готовой игры (нет оппонента).'); return; } console.log(`[GameInstance ${this.id}] Запуск игры.`); @@ -191,14 +431,17 @@ class GameInstance { const oData = dataUtils.getCharacterData(this.opponentCharacterKey); if (!pData || !oData) { this._handleCriticalError('start_char_data_fail', 'Ошибка данных персонажей при старте.'); return; } + const initialLog = this.consumeLogBuffer(); + Object.values(this.players).forEach(playerInfo => { - if (playerInfo.socket?.connected) { + if (playerInfo.socket?.connected && !playerInfo.isTemporarilyDisconnected) { const dataForClient = 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: this.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } + ...dataForClient, log: [...initialLog], + clientConfig: { ...GAME_CONFIG } }); } }); @@ -214,6 +457,12 @@ class GameInstance { } processPlayerAction(requestingSocketId, actionData) { + if (this.isGameEffectivelyPaused()) { + console.warn(`[GameInstance ${this.id}] Действие от сокета ${requestingSocketId}, но игра на паузе. Действие отклонено.`); + const playerInfo = this.players[requestingSocketId]; + if (playerInfo?.socket) playerInfo.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; } @@ -234,7 +483,7 @@ class GameInstance { if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail', 'Ошибка данных персонажа при действии.'); return; } let actionValid = true; - let tauntContextTargetData = defenderData; // Данные цели для контекста насмешек + let tauntContextTargetData = defenderData; if (actionData.actionType === 'attack') { const taunt = gameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, tauntContextTargetData, this.gameState); @@ -253,7 +502,7 @@ class GameInstance { if (!ability) { actionValid = false; actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); // Перезапуск таймера + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); return; } const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG); @@ -275,17 +524,22 @@ class GameInstance { } if (this.checkGameOver()) { - this.broadcastGameStateUpdate(); return; + return; } if (actionValid) { + this.broadcastLogUpdate(); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } else { this.broadcastLogUpdate(); - this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); // Перезапуск таймера + this.turnTimer.start(this.gameState.isPlayerTurn, (this.mode === 'ai' && !this.gameState.isPlayerTurn)); } } switchTurn() { + if (this.isGameEffectivelyPaused()) { + console.log(`[GameInstance ${this.id}] Попытка сменить ход, но игра на паузе. Смена хода отложена.`); + return; + } if (!this.gameState || this.gameState.isGameOver) return; this.turnTimer.clear(); @@ -302,7 +556,9 @@ class GameInstance { 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()) { this.broadcastGameStateUpdate(); return; } + if (this.checkGameOver()) { + return; + } this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; @@ -318,11 +574,16 @@ class GameInstance { } 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'); @@ -333,7 +594,8 @@ class GameInstance { 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()) { this.broadcastGameStateUpdate(); return; } + if (this.checkGameOver()) { return; } + this.broadcastLogUpdate(); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); return; } @@ -347,27 +609,34 @@ class GameInstance { 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); - } // 'pass' уже залогирован в decideAiAction + } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } + if (this.checkGameOver()) { + return; + } + this.broadcastLogUpdate(); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } checkGameOver() { if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true; - if (!this.gameState.player || !this.gameState.opponent?.characterKey) return false; + if (this.mode === 'pvp' && (!this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey)) { + return false; + } + 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(); + 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) { // Убедимся, что данные проигравшего есть + 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); } @@ -390,46 +659,117 @@ class GameInstance { return false; } - endGameDueToDisconnect(disconnectedSocketId, disconnectedPlayerRole, disconnectedCharacterKey) { + endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { if (this.gameState && !this.gameState.isGameOver) { this.gameState.isGameOver = true; this.turnTimer.clear(); + this.clearAllReconnectTimers(); - const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'opponent_disconnected', - disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID, // winner - disconnectedPlayerRole // loser + 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 ); this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); - console.log(`[GameInstance ${this.id}] Игра завершена из-за дисконнекта. Победитель: ${result.winnerRole || 'Нет'}. Отключился: ${disconnectedPlayerRole}.`); + 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 // Ключ того, кто отключился + loserCharacterKey: disconnectedCharacterKey }); this.gameManager._cleanupGame(this.id, result.reason); + } else if (this.gameState && this.gameState.isGameOver) { + console.log(`[GameInstance ${this.id}] Попытка завершить игру из-за дисконнекта, но она уже завершена.`); } } + // --- НАЧАЛО ИЗМЕНЕНИЯ --- + 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"); + } + } + return; + } + + const surrenderedPlayerEntry = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier); + if (!surrenderedPlayerEntry) { + console.error(`[GameInstance ${this.id}] Не найден игрок с identifier ${surrenderingPlayerIdentifier} для обработки сдачи.`); + return; + } + + const surrenderedPlayerRole = surrenderedPlayerEntry.id; + const surrenderedPlayerName = this.gameState[surrenderedPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey || `Игрок (ID: ${surrenderingPlayerIdentifier})`; + const surrenderedPlayerCharKey = this.gameState[surrenderedPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey; + + const winnerRole = surrenderedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const winnerName = this.gameState[winnerRole]?.name || `Оппонент (Роль: ${winnerRole})`; + + this.gameState.isGameOver = true; + this.turnTimer.clear(); + this.clearAllReconnectTimers(); // Также очищаем таймеры реконнекта + + const surrenderMessage = `🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`; + this.addToLog(surrenderMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + console.log(`[GameInstance ${this.id}] ${surrenderMessage}`); + + const reasonForGameOver = "player_surrendered"; + + 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(), + 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!`); + } + } + // --- КОНЕЦ ИЗМЕНЕНИЯ --- + handleTurnTimeout() { if (!this.gameState || this.gameState.isGameOver) return; - // this.turnTimer.clear(); // TurnTimer сам себя очистит при вызове onTimeout 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) { // Если победитель не определен (например, ожидание в PvP) + if (!this.gameState[winnerPlayerRole]?.characterKey) { this._handleCriticalError('timeout_winner_undefined', `Таймаут, но победитель (${winnerPlayerRole}) не определен.`); return; } - - this.gameState.isGameOver = true; // Устанавливаем здесь, т.к. getGameOverResult мог не знать, что игра уже окончена + 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(), @@ -442,6 +782,7 @@ class GameInstance { console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`); if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true; 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}`, @@ -455,18 +796,27 @@ class GameInstance { addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { if (!message) return; - this.logBuffer.push({ message, type, timestamp: Date.now() }); + const logEntry = { message, type, timestamp: Date.now() }; + this.logBuffer.push(logEntry); } consumeLogBuffer() { - const logs = [...this.logBuffer]; this.logBuffer = []; return logs; + const logs = [...this.logBuffer]; + this.logBuffer = []; + return logs; } broadcastGameStateUpdate() { + if (this.isGameEffectivelyPaused()) { + console.log(`[GameInstance ${this.id}] Попытка broadcastGameStateUpdate, но игра на паузе. Обновление не отправлено.`); + return; + } if (!this.gameState) return; - this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); + const logsToSend = this.consumeLogBuffer(); + this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: logsToSend }); } broadcastLogUpdate() { if (this.logBuffer.length > 0) { - this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); + const logsToSend = this.consumeLogBuffer(); + this.io.to(this.id).emit('logUpdate', { log: logsToSend }); } } }