commit 9a8c802b9608062a84d2177bea8939ddfbcf3c64 Author: svoboda200786@gmail.com Date: Mon Jun 9 23:55:07 2025 +0300 first diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8cf526 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB_HOST=localhost +DB_USER=your_mysql_user +DB_PASSWORD=your_mysql_password +DB_NAME=your_game_db +DB_PORT=3306 + +BC_APP_PORT=3200 +BC_APP_HOSTNAME=127.0.0.1 +NODE_ENV=development \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..0b6276a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy Project BC + +on: + push: # Запускать при событии push + branches: + - main # Только для ветки main (или master, или ваша основная ветка) + +jobs: + deploy: + runs-on: ubuntu-latest # Указываем метку раннера, на котором должна выполняться задача. + # Убедитесь, что ваш зарегистрированный раннер имеет эту метку. + # Можно использовать и более специфичную, например, 'self-hosted' или имя вашего сервера, если вы так его пометили. + + steps: + - name: Checkout repository + uses: actions/checkout@v3 # Стандартное действие для скачивания кода репозитория на раннер + + - name: Setup Node.js # Если вам нужно определенная версия Node.js для npm install + uses: actions/setup-node@v3 + with: + node-version: '18' # Укажите нужную вам LTS или другую версию Node.js + + - name: Install Dependencies + run: | # Выполняем команды в рабочей директории раннера (куда был склонирован репозиторий) + echo "Current directory: $(pwd)" + if [ -f package.json ]; then + echo "package.json found. Installing dependencies..." + npm install --omit=dev + else + echo "package.json not found. Skipping npm install." + fi + working-directory: ./ # Указывает, что npm install нужно выполнять в корне склонированного репозитория + # Если package.json для bc.js в подпапке server/, то: + # working-directory: ./server + + - name: Execute Deploy Script on Server + uses: appleboy/ssh-action@master # Популярное действие для выполнения команд по SSH + with: + host: ${{ secrets.DEPLOY_HOST }} # IP или домен вашего сервера, где нужно выполнить деплой + username: ${{ secrets.DEPLOY_USER }} # Имя пользователя для SSH-доступа + key: ${{ secrets.SSH_PRIVATE_KEY }} # Приватный SSH-ключ для доступа + port: ${{ secrets.DEPLOY_PORT || 22 }} # Порт SSH, по умолчанию 22 + script: | + echo "Starting deployment on server for project bc..." + cd /home/nodejs/bc/ # Путь к рабочей копии проекта на сервере + git fetch origin main # Или ваша основная ветка + git reset --hard origin/main + + # Если npm install должен выполняться на сервере деплоя, а не на раннере: + # if [ -f package.json ]; then + # echo "Installing server-side npm dependencies..." + # npm install --omit=dev + # fi + + echo "Restarting PM2 process for bc..." + pm2 restart bc # Имя или ID вашего bc приложения в PM2 + echo "Deployment for bc finished." \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e9ab5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.log +.idea/ +node_modules/ +1.bat \ No newline at end of file diff --git a/bc b/bc new file mode 160000 index 0000000..1cfb69a --- /dev/null +++ b/bc @@ -0,0 +1 @@ +Subproject commit 1cfb69af0a0a5c188661b1ba5fe60d8d4940c7e2 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e1f36c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1552 @@ +{ + "name": "bc", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "bcryptjs": "^3.0.2", + "dotenv": "^16.5.0", + "ejs": "^3.1.10", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.14.1", + "socket.io": "^4.8.1", + "uuid": "^11.1.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", + "integrity": "sha512-nX3d0sxJW41CqQvfOzVG1NCTXfFDrDWIghCZncpHeWlVFd81zxB/DLhg7avFg6eHLCRX7ckBmoIIcqa++upvJA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.15.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", + "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "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", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "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", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", + "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "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", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9c78ea --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "bcryptjs": "^3.0.2", + "dotenv": "^16.5.0", + "ejs": "^3.1.10", + "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/images/almagest_avatar.webp b/public/images/almagest_avatar.webp new file mode 100644 index 0000000..c4ca05e Binary files /dev/null and b/public/images/almagest_avatar.webp differ diff --git a/public/images/balard_avatar.jpg b/public/images/balard_avatar.jpg new file mode 100644 index 0000000..4afb06d Binary files /dev/null and b/public/images/balard_avatar.jpg differ diff --git a/public/images/elena_avatar.gif b/public/images/elena_avatar.gif new file mode 100644 index 0000000..d61cdea Binary files /dev/null and b/public/images/elena_avatar.gif differ diff --git a/public/images/elena_avatar.jpg b/public/images/elena_avatar.jpg new file mode 100644 index 0000000..7163c0e Binary files /dev/null and b/public/images/elena_avatar.jpg differ diff --git a/public/images/elena_avatar.webp b/public/images/elena_avatar.webp new file mode 100644 index 0000000..83290b1 Binary files /dev/null and b/public/images/elena_avatar.webp differ diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..a6ef7d0 --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,212 @@ +// /public/js/auth.js + +// Эта функция будет вызвана из main.js и получит необходимые зависимости +export function initAuth(dependencies) { + console.log('[Auth.js] initAuth called. Dependencies received:', !!dependencies); // <--- ДОБАВЛЕНО + + const { socket, clientState, ui } = dependencies; + const { loginForm, registerForm, logoutButton } = ui.elements; + console.log('[Auth.js DOM Check] loginForm in initAuth:', loginForm); // <--- ДОБАВЛЕНО + console.log('[Auth.js DOM Check] registerForm in initAuth:', registerForm); // <--- ДОБАВЛЕНО + console.log('[Auth.js DOM Check] logoutButton in initAuth:', logoutButton); // <--- ДОБАВЛЕНО + + const getApiUrl = (path) => `${window.location.origin}${base_path}${path}`; + console.log('[Auth.js] API URLs will be relative to:', window.location.origin); // <--- ДОБАВЛЕНО + + const JWT_TOKEN_KEY = 'jwtToken'; + + async function handleAuthResponse(response, formType) { + console.log(`[Auth.js handleAuthResponse] Handling response for form: ${formType}. Response status: ${response.status}`); // <--- ДОБАВЛЕНО + const regButton = registerForm ? registerForm.querySelector('button') : null; + const loginButton = loginForm ? loginForm.querySelector('button') : null; + + try { + const data = await response.json(); + console.log(`[Auth.js handleAuthResponse] Parsed data for ${formType}:`, data); // <--- ДОБАВЛЕНО + + if (response.ok && data.success && data.token) { + console.log(`[Auth.js handleAuthResponse] ${formType} successful. Token received.`); // <--- ДОБАВЛЕНО + localStorage.setItem(JWT_TOKEN_KEY, data.token); + + clientState.isLoggedIn = true; + clientState.loggedInUsername = data.username; + clientState.myUserId = data.userId; + console.log('[Auth.js handleAuthResponse] Client state updated:', JSON.parse(JSON.stringify(clientState))); // <--- ДОБАВЛЕНО + + + ui.setAuthMessage(''); + ui.showGameSelectionScreen(data.username); + + console.log('[Auth.js handleAuthResponse] Disconnecting and reconnecting socket with new token.'); // <--- ДОБАВЛЕНО + if (socket.connected) { + socket.disconnect(); + } + socket.auth = { token: data.token }; + socket.connect(); + + } else { + console.warn(`[Auth.js handleAuthResponse] ${formType} failed or token missing. Message: ${data.message}`); // <--- ДОБАВЛЕНО + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + localStorage.removeItem(JWT_TOKEN_KEY); + ui.setAuthMessage(data.message || 'Ошибка сервера.', true); + } + } catch (error) { + console.error(`[Auth.js handleAuthResponse] Error processing ${formType} response JSON or other:`, error); // <--- ДОБАВЛЕНО + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + localStorage.removeItem(JWT_TOKEN_KEY); + ui.setAuthMessage('Произошла ошибка сети или ответа сервера. Попробуйте снова.', true); + } finally { + console.log(`[Auth.js handleAuthResponse] Re-enabling buttons for ${formType}.`); // <--- ДОБАВЛЕНО + if (regButton) regButton.disabled = false; + if (loginButton) loginButton.disabled = false; + } + } + + + // --- Обработчики событий DOM --- + if (registerForm) { + console.log('[Auth.js] Attaching submit listener to registerForm.'); // <--- ДОБАВЛЕНО + registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + console.log('[Auth.js] Register form submitted.'); // <--- ДОБАВЛЕНО + + const usernameInput = document.getElementById('register-username'); + const passwordInput = document.getElementById('register-password'); + + if (!usernameInput || !passwordInput) { + console.error('[Auth.js] Register form username or password input not found!'); // <--- ДОБАВЛЕНО + return; + } + + const username = usernameInput.value; + const password = passwordInput.value; + console.log(`[Auth.js] Attempting to register with username: "${username}", password length: ${password.length}`); // <--- ДОБАВЛЕНО + + const regButton = registerForm.querySelector('button'); + const loginButton = loginForm ? loginForm.querySelector('button') : null; + if (regButton) regButton.disabled = true; + if (loginButton) loginButton.disabled = true; // Блокируем обе кнопки на время запроса + + ui.setAuthMessage('Регистрация...'); + const apiUrl = getApiUrl('/auth/register'); + console.log('[Auth.js] Sending register request to:', apiUrl); // <--- ДОБАВЛЕНО + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + console.log('[Auth.js] Received response from register request.'); // <--- ДОБАВЛЕНО + await handleAuthResponse(response, 'register'); + if (response.ok && clientState.isLoggedIn && registerForm) { + console.log('[Auth.js] Registration successful, resetting register form.'); // <--- ДОБАВЛЕНО + registerForm.reset(); + } + } catch (error) { + console.error('[Auth.js] Network error during registration fetch:', error); // <--- ДОБАВЛЕНО + ui.setAuthMessage('Ошибка сети при регистрации. Пожалуйста, проверьте ваше подключение.', true); + if (regButton) regButton.disabled = false; + if (loginButton) loginButton.disabled = false; + } + }); + } else { + console.warn('[Auth.js] registerForm element not found, listener not attached.'); // <--- ДОБАВЛЕНО + } + + if (loginForm) { + console.log('[Auth.js] Attaching submit listener to loginForm.'); // <--- ДОБАВЛЕНО + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + console.log('[Auth.js] Login form submitted.'); // <--- ДОБАВЛЕНО + + const usernameInput = document.getElementById('login-username'); + const passwordInput = document.getElementById('login-password'); + + if (!usernameInput || !passwordInput) { + console.error('[Auth.js] Login form username or password input not found!'); // <--- ДОБАВЛЕНО + return; + } + + const username = usernameInput.value; + const password = passwordInput.value; + console.log(`[Auth.js] Attempting to login with username: "${username}", password length: ${password.length}`); // <--- ДОБАВЛЕНО + + const loginButton = loginForm.querySelector('button'); + const regButton = registerForm ? registerForm.querySelector('button') : null; + if (loginButton) loginButton.disabled = true; + if (regButton) regButton.disabled = true; + + ui.setAuthMessage('Вход...'); + const apiUrl = getApiUrl('/auth/login'); + console.log('[Auth.js] Sending login request to:', apiUrl); // <--- ДОБАВЛЕНО + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + console.log('[Auth.js] Received response from login request.'); // <--- ДОБАВЛЕНО + await handleAuthResponse(response, 'login'); + // Форма логина обычно не сбрасывается или перенаправляется немедленно, + // это делает showGameSelectionScreen + } catch (error) { + console.error('[Auth.js] Network error during login fetch:', error); // <--- ДОБАВЛЕНО + ui.setAuthMessage('Ошибка сети при входе. Пожалуйста, проверьте ваше подключение.', true); + if (loginButton) loginButton.disabled = false; + if (regButton) regButton.disabled = false; + } + }); + } else { + console.warn('[Auth.js] loginForm element not found, listener not attached.'); // <--- ДОБАВЛЕНО + } + + if (logoutButton) { + console.log('[Auth.js] Attaching click listener to logoutButton.'); // <--- ДОБАВЛЕНО + logoutButton.addEventListener('click', () => { + console.log('[Auth.js] Logout button clicked.'); // <--- ДОБАВЛЕНО + logoutButton.disabled = true; + + if (clientState.isLoggedIn && clientState.isInGame && clientState.currentGameId) { + if (clientState.currentGameState && + clientState.currentGameState.gameMode === 'pvp' && + !clientState.currentGameState.isGameOver) { + console.log('[Auth.js] Player is in an active PvP game. Emitting playerSurrender.'); + socket.emit('playerSurrender'); + } + else if (clientState.currentGameState && + clientState.currentGameState.gameMode === 'ai' && + !clientState.currentGameState.isGameOver) { + console.log('[Auth.js] Player is in an active AI game. Emitting leaveAiGame.'); + socket.emit('leaveAiGame'); + } + } + + console.log('[Auth.js] Removing JWT token from localStorage.'); // <--- ДОБАВЛЕНО + localStorage.removeItem(JWT_TOKEN_KEY); + + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + console.log('[Auth.js] Client state reset for logout.'); // <--- ДОБАВЛЕНО + + ui.showAuthScreen(); + ui.setAuthMessage("Вы успешно вышли из системы."); + + console.log('[Auth.js] Disconnecting and reconnecting socket after logout.'); // <--- ДОБАВЛЕНО + if (socket.connected) { + socket.disconnect(); + } + socket.auth = { token: null }; + socket.connect(); // Это вызовет 'connect' в main.js, который затем вызовет showAuthScreen + }); + } else { + console.warn('[Auth.js] logoutButton element not found, listener not attached.'); // <--- ДОБАВЛЕНО + } + console.log('[Auth.js] initAuth finished.'); // <--- ДОБАВЛЕНО +} \ No newline at end of file diff --git a/public/js/client_del.js b/public/js/client_del.js new file mode 100644 index 0000000..2b8afef --- /dev/null +++ b/public/js/client_del.js @@ -0,0 +1,583 @@ +// /public/js/client.js + +document.addEventListener('DOMContentLoaded', () => { + const socket = io({ + // Опции Socket.IO, если нужны + // transports: ['websocket'], // Можно попробовать для отладки, если есть проблемы с polling + }); + + // --- Состояние клиента --- + let currentGameState = null; + let myPlayerId = null; // Технический ID слота в игре ('player' или 'opponent') + let myUserId = null; // ID залогиненного пользователя (из БД) + let myCharacterKey = null; + let opponentCharacterKey = null; + let currentGameId = null; + let playerBaseStatsServer = null; + let opponentBaseStatsServer = null; + let playerAbilitiesServer = null; + let opponentAbilitiesServer = null; + let isLoggedIn = false; + let loggedInUsername = ''; + let isInGame = false; + + // --- DOM Элементы --- + const authSection = document.getElementById('auth-section'); + const registerForm = document.getElementById('register-form'); + const loginForm = document.getElementById('login-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'); // Убедитесь, что ID в HTML '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 attackButton = document.getElementById('button-attack'); + const returnToMenuButton = document.getElementById('return-to-menu-button'); + const gameOverScreen = document.getElementById('game-over-screen'); + const abilitiesGrid = document.getElementById('abilities-grid'); + + const turnTimerSpan = document.getElementById('turn-timer'); + const turnTimerContainer = document.getElementById('turn-timer-container'); + + // --- Функции управления UI --- + function showAuthScreen() { + authSection.style.display = 'block'; + userInfoDiv.style.display = 'none'; + gameSetupDiv.style.display = 'none'; + gameWrapper.style.display = 'none'; + hideGameOverModal(); + setAuthMessage("Ожидание подключения к серверу..."); + statusContainer.style.display = 'block'; + isInGame = false; + disableGameControls(); + resetGameVariables(); + 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'; + hideGameOverModal(); + setGameStatusMessage("Выберите режим игры или присоединитесь к существующей."); + statusContainer.style.display = 'block'; + socket.emit('requestPvPGameList'); + updateAvailableGamesList([]); // Очищаем перед запросом + if (gameIdInput) gameIdInput.value = ''; + const elenaRadio = document.getElementById('char-elena'); + if (elenaRadio) elenaRadio.checked = true; + isInGame = false; + disableGameControls(); + resetGameVariables(); // Сбрасываем игровые переменные при выходе в меню + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + enableSetupButtons(); // Включаем кнопки на экране выбора игры + } + + function showGameScreen() { + hideGameOverModal(); + authSection.style.display = 'none'; + userInfoDiv.style.display = 'block'; // Оставляем инфо о пользователе + gameSetupDiv.style.display = 'none'; + gameWrapper.style.display = 'flex'; + setGameStatusMessage(""); // Очищаем статус, т.к. есть индикатор хода + statusContainer.style.display = 'none'; // Скрываем общий статус контейнер + isInGame = true; + disableGameControls(); // Кнопки включатся, когда будет ход игрока + if (turnTimerContainer) turnTimerContainer.style.display = 'block'; // Показываем таймер + if (turnTimerSpan) turnTimerSpan.textContent = '--'; // Начальное значение + } + + function resetGameVariables() { + currentGameId = null; currentGameState = null; myPlayerId = null; + myCharacterKey = null; opponentCharacterKey = null; + playerBaseStatsServer = null; opponentBaseStatsServer = null; + playerAbilitiesServer = null; opponentAbilitiesServer = null; + window.gameState = null; window.gameData = null; window.myPlayerId = null; + } + + function hideGameOverModal() { + const hiddenClass = window.GAME_CONFIG?.CSS_CLASS_HIDDEN || 'hidden'; + if (gameOverScreen && !gameOverScreen.classList.contains(hiddenClass)) { + gameOverScreen.classList.add(hiddenClass); + if (window.gameUI?.uiElements?.gameOver?.modalContent) { + window.gameUI.uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)'; + window.gameUI.uiElements.gameOver.modalContent.style.opacity = '0'; + } + const opponentPanel = window.gameUI?.uiElements?.opponent?.panel; + if (opponentPanel?.classList.contains('dissolving')) { + opponentPanel.classList.remove('dissolving'); + opponentPanel.style.opacity = '1'; opponentPanel.style.transform = 'scale(1) translateY(0)'; + } + } + } + + 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'; + } + + function setGameStatusMessage(message, isError = false) { + if (gameStatusMessage) { + gameStatusMessage.textContent = message; + gameStatusMessage.style.display = message ? 'block' : 'none'; + 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'; + } + + function getSelectedCharacterKey() { + let selectedKey = 'elena'; + if (pvpCharacterRadios) { + pvpCharacterRadios.forEach(radio => { if (radio.checked) selectedKey = radio.value; }); + } + return selectedKey; + } + + function enableGameControls(enableAttack = true, enableAbilities = true) { + if (attackButton) attackButton.disabled = !enableAttack; + if (abilitiesGrid) { + const cls = window.GAME_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; + } + function disableGameControls() { enableGameControls(false, false); } + + function disableSetupButtons() { + if(createAIGameButton) createAIGameButton.disabled = true; + if(createPvPGameButton) createPvPGameButton.disabled = true; + if(joinPvPGameButton) joinPvPGameButton.disabled = true; + if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = true; + if(availableGamesDiv) availableGamesDiv.querySelectorAll('button').forEach(btn => btn.disabled = true); + } + function enableSetupButtons() { + if(createAIGameButton) createAIGameButton.disabled = false; + if(createPvPGameButton) createPvPGameButton.disabled = false; + if(joinPvPGameButton) joinPvPGameButton.disabled = false; + if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false; + // Кнопки в списке игр включаются в updateAvailableGamesList + } + + // --- Инициализация обработчиков событий --- + if (registerForm) registerForm.addEventListener('submit', (e) => { + e.preventDefault(); + const u = document.getElementById('register-username').value; + const p = document.getElementById('register-password').value; + registerForm.querySelector('button').disabled = true; + if(loginForm) loginForm.querySelector('button').disabled = true; + socket.emit('register', { username: u, password: p }); + }); + if (loginForm) loginForm.addEventListener('submit', (e) => { + e.preventDefault(); + const u = document.getElementById('login-username').value; + const p = document.getElementById('login-password').value; + if(registerForm) registerForm.querySelector('button').disabled = true; + loginForm.querySelector('button').disabled = true; + socket.emit('login', { username: u, password: p }); + }); + if (logoutButton) logoutButton.addEventListener('click', () => { + logoutButton.disabled = true; socket.emit('logout'); + isLoggedIn = false; loggedInUsername = ''; myUserId = null; + resetGameVariables(); isInGame = false; disableGameControls(); + showAuthScreen(); setGameStatusMessage("Вы вышли из системы."); + logoutButton.disabled = false; + }); + if (createAIGameButton) createAIGameButton.addEventListener('click', () => { + if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; } + disableSetupButtons(); + socket.emit('createGame', { mode: 'ai', characterKey: 'elena' }); // AI всегда за Елену + setGameStatusMessage("Создание игры против AI..."); + }); + if (createPvPGameButton) createPvPGameButton.addEventListener('click', () => { + if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; } + disableSetupButtons(); + socket.emit('createGame', { mode: 'pvp', characterKey: getSelectedCharacterKey() }); + setGameStatusMessage("Создание PvP игры..."); + }); + if (joinPvPGameButton) joinPvPGameButton.addEventListener('click', () => { // Убедитесь, что ID кнопки 'join-pvp-game' + if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; } + const gameId = gameIdInput.value.trim(); + if (gameId) { + disableSetupButtons(); + socket.emit('joinGame', { gameId: gameId }); + setGameStatusMessage(`Присоединение к игре ${gameId}...`); + } else setGameStatusMessage("Введите ID игры.", true); + }); + if (findRandomPvPGameButton) findRandomPvPGameButton.addEventListener('click', () => { + if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; } + disableSetupButtons(); + socket.emit('findRandomGame', { characterKey: getSelectedCharacterKey() }); + setGameStatusMessage("Поиск случайной PvP игры..."); + }); + if (attackButton) attackButton.addEventListener('click', () => { + if (isLoggedIn && isInGame && currentGameId && currentGameState && !currentGameState.isGameOver) { + socket.emit('playerAction', { actionType: 'attack' }); + } else { /* обработка ошибки/некорректного состояния */ } + }); + function handleAbilityButtonClick(event) { + const abilityId = event.currentTarget.dataset.abilityId; + if (isLoggedIn && isInGame && currentGameId && abilityId && currentGameState && !currentGameState.isGameOver) { + socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId }); + } else { /* обработка ошибки/некорректного состояния */ } + } + if (returnToMenuButton) returnToMenuButton.addEventListener('click', () => { + if (!isLoggedIn) { showAuthScreen(); return; } + returnToMenuButton.disabled = true; + resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal(); + showGameSelectionScreen(loggedInUsername); // Возвращаемся на экран выбора + // Кнопка включится при следующем показе модалки + }); + + function initializeAbilityButtons() { + // ... (код без изменений, как был) + if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) { + if (abilitiesGrid) abilitiesGrid.innerHTML = '

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

'; + return; + } + abilitiesGrid.innerHTML = ''; + const config = window.GAME_CONFIG; + const abilitiesToDisplay = playerAbilitiesServer; + const baseStatsForResource = playerBaseStatsServer; + + if (!abilitiesToDisplay || abilitiesToDisplay.length === 0 || !baseStatsForResource) { + abilitiesGrid.innerHTML = '

Нет доступных способностей.

'; + return; + } + const resourceName = baseStatsForResource.resourceName || "Ресурс"; + const abilityButtonClass = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; + + abilitiesToDisplay.forEach(ability => { + const button = document.createElement('button'); + button.id = `ability-btn-${ability.id}`; + button.classList.add(abilityButtonClass); + button.dataset.abilityId = ability.id; + 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); + button.addEventListener('click', handleAbilityButtonClick); + abilitiesGrid.appendChild(button); + }); + const placeholder = abilitiesGrid.querySelector('.placeholder-text'); + if (placeholder) placeholder.remove(); + } + + function updateAvailableGamesList(games) { + if (!availableGamesDiv) return; + availableGamesDiv.innerHTML = '

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

'; + if (games && games.length > 0) { + const ul = document.createElement('ul'); + games.forEach(game => { + if (game && game.id) { + const li = document.createElement('li'); + li.textContent = `ID: ${game.id.substring(0, 8)}... - ${game.status || 'Ожидает игрока'}`; + const joinBtn = document.createElement('button'); + joinBtn.textContent = 'Присоединиться'; + joinBtn.dataset.gameId = game.id; + + // === ИЗМЕНЕНИЕ: Деактивация кнопки "Присоединиться" для своих игр === + if (isLoggedIn && myUserId && game.ownerIdentifier === myUserId) { + joinBtn.disabled = true; + joinBtn.title = "Вы не можете присоединиться к своей же ожидающей игре."; + } else { + joinBtn.disabled = false; + } + // === КОНЕЦ ИЗМЕНЕНИЯ === + + joinBtn.addEventListener('click', (e) => { + if (!isLoggedIn) { setGameStatusMessage("Пожалуйста, войдите.", true); return; } + if (e.target.disabled) return; // Не обрабатывать клик по отключенной кнопке + disableSetupButtons(); + socket.emit('joinGame', { gameId: e.target.dataset.gameId }); + }); + li.appendChild(joinBtn); + ul.appendChild(li); + } + }); + availableGamesDiv.appendChild(ul); + } else { + availableGamesDiv.innerHTML += '

Нет доступных игр. Создайте свою!

'; + } + enableSetupButtons(); // Включаем основные кнопки создания/поиска + } + + + // --- Обработчики событий Socket.IO --- + socket.on('connect', () => { + console.log('[Client] Socket connected:', socket.id); + if (isLoggedIn && myUserId) { // Проверяем и isLoggedIn и myUserId + socket.emit('requestGameState'); // Запрашиваем состояние, если были залогинены + } else { + showAuthScreen(); // Иначе показываем экран логина + } + }); + + socket.on('registerResponse', (data) => { + setAuthMessage(data.message, !data.success); + if (data.success && registerForm) registerForm.reset(); + if(registerForm) registerForm.querySelector('button').disabled = false; + if(loginForm) loginForm.querySelector('button').disabled = false; + }); + + socket.on('loginResponse', (data) => { + setAuthMessage(data.message, !data.success); + if (data.success) { + isLoggedIn = true; + loggedInUsername = data.username; + myUserId = data.userId; // === ИЗМЕНЕНИЕ: Сохраняем ID пользователя === + setAuthMessage(""); + showGameSelectionScreen(data.username); + } else { + isLoggedIn = false; loggedInUsername = ''; myUserId = null; + if(registerForm) registerForm.querySelector('button').disabled = false; + if(loginForm) loginForm.querySelector('button').disabled = false; + } + }); + + socket.on('gameNotFound', (data) => { + console.log('[Client] Game not found/ended:', data?.message); + resetGameVariables(); isInGame = false; disableGameControls(); hideGameOverModal(); + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + + if (isLoggedIn) { + showGameSelectionScreen(loggedInUsername); + setGameStatusMessage(data?.message || "Активная игровая сессия не найдена."); + } else { + showAuthScreen(); + setAuthMessage(data?.message || "Пожалуйста, войдите."); + } + }); + + socket.on('disconnect', (reason) => { + console.log('[Client] Disconnected:', reason); + setGameStatusMessage(`Отключено: ${reason}. Обновите страницу.`, true); + disableGameControls(); + if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.'; + // Не сбрасываем isLoggedIn, чтобы при переподключении можно было восстановить сессию + }); + + socket.on('gameCreated', (data) => { // Сервер присылает это после успешного createGame + console.log('[Client] Game created by this client:', data); + currentGameId = data.gameId; + myPlayerId = data.yourPlayerId; // Сервер должен прислать роль создателя + // Остальные данные (gameState, baseStats) придут с gameStarted или gameState (если это PvP ожидание) + // Если это PvP и игра ожидает, сервер может прислать waitingForOpponent + }); + + + socket.on('gameStarted', (data) => { + if (!isLoggedIn) return; + console.log('[Client] Game started:', data); + // ... (остальной код gameStarted без изменений, как был) + if (window.gameUI?.uiElements?.opponent?.panel) { + const opponentPanel = window.gameUI.uiElements.opponent.panel; + if (opponentPanel.classList.contains('dissolving')) { + opponentPanel.classList.remove('dissolving'); + opponentPanel.style.opacity = '1'; opponentPanel.style.transform = 'scale(1) translateY(0)'; + } + } + currentGameId = data.gameId; myPlayerId = data.yourPlayerId; currentGameState = data.initialGameState; + playerBaseStatsServer = data.playerBaseStats; opponentBaseStatsServer = data.opponentBaseStats; + playerAbilitiesServer = data.playerAbilities; opponentAbilitiesServer = data.opponentAbilities; + myCharacterKey = playerBaseStatsServer?.characterKey; opponentCharacterKey = opponentBaseStatsServer?.characterKey; + + if (data.clientConfig) window.GAME_CONFIG = { ...data.clientConfig }; + else if (!window.GAME_CONFIG) { + window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; + } + window.gameState = currentGameState; + window.gameData = { playerBaseStats: playerBaseStatsServer, opponentBaseStats: opponentBaseStatsServer, playerAbilities: playerAbilitiesServer, opponentAbilities: opponentAbilitiesServer }; + window.myPlayerId = myPlayerId; + + 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)); + } + requestAnimationFrame(() => { + if (window.gameUI && typeof window.gameUI.updateUI === 'function') { + window.gameUI.updateUI(); + } + }); + hideGameOverModal(); setGameStatusMessage(""); + }); + + // Используется для восстановления состояния уже идущей игры + socket.on('gameState', (data) => { + if (!isLoggedIn) return; + console.log('[Client] Received full gameState (e.g. on reconnect):', data); + // Это событие теперь может дублировать 'gameStarted' для переподключения. + // Убедимся, что логика похожа на gameStarted. + currentGameId = data.gameId; + myPlayerId = data.yourPlayerId; + currentGameState = data.gameState; // Используем gameState вместо initialGameState + playerBaseStatsServer = data.playerBaseStats; + opponentBaseStatsServer = data.opponentBaseStats; + playerAbilitiesServer = data.playerAbilities; + opponentAbilitiesServer = data.opponentAbilities; + myCharacterKey = playerBaseStatsServer?.characterKey; + opponentCharacterKey = opponentBaseStatsServer?.characterKey; + + if (data.clientConfig) window.GAME_CONFIG = { ...data.clientConfig }; + else if (!window.GAME_CONFIG) { + window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; + } + window.gameState = currentGameState; + window.gameData = { playerBaseStats: playerBaseStatsServer, opponentBaseStats: opponentBaseStatsServer, playerAbilities: playerAbilitiesServer, opponentAbilities: opponentAbilitiesServer }; + window.myPlayerId = myPlayerId; + + if (!isInGame) showGameScreen(); // Показываем экран игры, если еще не там + initializeAbilityButtons(); // Переинициализируем кнопки + + // Лог при 'gameState' может быть уже накопленным, добавляем его + if (window.gameUI?.uiElements?.log?.list && data.log) { // Очищаем лог перед добавлением нового при полном обновлении + 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)); + } + + requestAnimationFrame(() => { + if (window.gameUI && typeof window.gameUI.updateUI === 'function') { + window.gameUI.updateUI(); + } + }); + hideGameOverModal(); + // Таймер будет обновлен следующим событием 'turnTimerUpdate' + }); + + + socket.on('gameStateUpdate', (data) => { + if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return; + currentGameState = data.gameState; window.gameState = currentGameState; + if (window.gameUI?.updateUI) window.gameUI.updateUI(); + if (window.gameUI?.addToLog && data.log) { + data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); + } + }); + + socket.on('logUpdate', (data) => { + if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return; + if (window.gameUI?.addToLog && data.log) { + data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); + } + }); + + socket.on('gameOver', (data) => { + // ... (код без изменений, как был) + if (!isLoggedIn || !currentGameId || !window.GAME_CONFIG) { + if (!currentGameId && isLoggedIn) socket.emit('requestGameState'); + else if (!isLoggedIn) showAuthScreen(); + return; + } + const playerWon = data.winnerId === myPlayerId; + currentGameState = data.finalGameState; window.gameState = currentGameState; + if (window.gameUI?.updateUI) 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 = window.gameData?.opponentBaseStats?.characterKey; + window.gameUI.showGameOver(playerWon, data.reason, oppKey, data); + } + if (returnToMenuButton) returnToMenuButton.disabled = false; + setGameStatusMessage("Игра окончена. " + (playerWon ? "Вы победили!" : "Вы проиграли.")); + if (window.gameUI?.updateTurnTimerDisplay) { // Обновляем UI таймера + window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode); // Передаем null, чтобы показать "Конец" или скрыть + } + }); + + socket.on('waitingForOpponent', () => { + if (!isLoggedIn) return; + setGameStatusMessage("Ожидание присоединения оппонента..."); + disableGameControls(); // Боевые кнопки неактивны + disableSetupButtons(); // Кнопки создания/присоединения тоже, пока ждем + if (createPvPGameButton) createPvPGameButton.disabled = false; // Оставляем активной "Создать PvP" для отмены + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, 'pvp'); // Таймер неактивен + } + }); + + socket.on('opponentDisconnected', (data) => { + if (!isLoggedIn || !isInGame || !currentGameId || !window.GAME_CONFIG) return; + const name = data.disconnectedCharacterName || 'Противник'; + if (window.gameUI?.addToLog) window.gameUI.addToLog(`🔌 Противник (${name}) отключился.`, 'system'); + if (currentGameState && !currentGameState.isGameOver) { + setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true); + disableGameControls(); + } + }); + + socket.on('gameError', (data) => { + console.error('[Client] Server error:', data.message); + if (isLoggedIn && isInGame && currentGameState && !currentGameState.isGameOver && window.gameUI?.addToLog) { + window.gameUI.addToLog(`❌ Ошибка игры: ${data.message}`, 'system'); + disableGameControls(); setGameStatusMessage(`Ошибка: ${data.message}.`, true); + } else { + setGameStatusMessage(`❌ Ошибка: ${data.message}`, true); + if (isLoggedIn) enableSetupButtons(); // Если на экране выбора игры, включаем кнопки + else { // Если на экране логина + if(registerForm) registerForm.querySelector('button').disabled = false; + if(loginForm) loginForm.querySelector('button').disabled = false; + } + } + }); + + socket.on('availablePvPGamesList', (games) => { + if (!isLoggedIn) return; + updateAvailableGamesList(games); + }); + + socket.on('noPendingGamesFound', (data) => { // Вызывается, когда создается новая игра после поиска + if (!isLoggedIn) return; + setGameStatusMessage(data.message || "Свободных игр не найдено. Создана новая для вас."); + updateAvailableGamesList([]); // Очищаем список + // currentGameId и myPlayerId должны были прийти с gameCreated + isInGame = false; // Еще не в активной фазе боя + disableGameControls(); + disableSetupButtons(); // Мы в ожидающей игре + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, 'pvp'); + } + }); + + socket.on('turnTimerUpdate', (data) => { + if (!isInGame || !currentGameState || currentGameState.isGameOver) { + if (window.gameUI?.updateTurnTimerDisplay && !currentGameState?.isGameOver) { // Только если не game over + window.gameUI.updateTurnTimerDisplay(null, false, currentGameState?.gameMode); + } + return; + } + if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') { + // Определяем, является ли текущий ход ходом этого клиента + const isMyActualTurn = myPlayerId && currentGameState.isPlayerTurn === (myPlayerId === GAME_CONFIG.PLAYER_ID); + window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyActualTurn, currentGameState.gameMode); + } + }); + + showAuthScreen(); // Начальный экран +}); \ No newline at end of file diff --git a/public/js/gameSetup.js b/public/js/gameSetup.js new file mode 100644 index 0000000..13ead6d --- /dev/null +++ b/public/js/gameSetup.js @@ -0,0 +1,256 @@ +// /public/js/gameSetup.js + +// ПРИМЕРНЫЕ РЕАЛИЗАЦИИ ВСПОМОГАТЕЛЬНЫХ ФУНКЦИЙ (лучше передавать из main.js) +/* +function parseJwtPayloadForValidation(token) { + try { + if (typeof token !== 'string') return null; + const base64Url = token.split('.')[1]; + if (!base64Url) return null; + 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) { + return null; + } +} + +function isTokenValid(token) { + if (!token) { + return false; + } + const decodedToken = parseJwtPayloadForValidation(token); + if (!decodedToken || !decodedToken.exp) { + localStorage.removeItem('jwtToken'); + return false; + } + const currentTimeInSeconds = Math.floor(Date.now() / 1000); + if (decodedToken.exp < currentTimeInSeconds) { + localStorage.removeItem('jwtToken'); + return false; + } + return true; +} +*/ +// Конец примерных реализаций + +export function initGameSetup(dependencies) { + const { socket, clientState, ui, utils } = dependencies; // Предполагаем, что utils.isTokenValid передается + const { + createAIGameButton, createPvPGameButton, joinPvPGameButton, + findRandomPvPGameButton, gameIdInput, availableGamesDiv, pvpCharacterRadios + } = ui.elements; + + // Получаем функцию isTokenValid либо из utils, либо используем локальную, если она определена выше + const checkTokenValidity = utils?.isTokenValid || window.isTokenValidFunction; // window.isTokenValidFunction - если вы определили ее глобально/локально + + if (typeof checkTokenValidity !== 'function') { + console.error("[GameSetup.js] CRITICAL: isTokenValid function is not available. Auth checks will fail."); + // Можно добавить фоллбэк или аварийное поведение + } + + + // --- Вспомогательные функции --- + function getSelectedCharacterKey() { + let selectedKey = 'elena'; // Значение по умолчанию + if (pvpCharacterRadios) { + pvpCharacterRadios.forEach(radio => { + if (radio.checked) { + selectedKey = radio.value; + } + }); + } + return selectedKey; + } + + function updateAvailableGamesList(games) { + if (!availableGamesDiv) return; + availableGamesDiv.innerHTML = '

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

'; + if (games && games.length > 0) { + const ul = document.createElement('ul'); + games.forEach(game => { + if (game && game.id) { + const li = document.createElement('li'); + li.textContent = `ID: ${game.id.substring(0, 8)}... - ${game.status || 'Ожидает игрока'}`; + + const joinBtn = document.createElement('button'); + joinBtn.textContent = 'Присоединиться'; + joinBtn.dataset.gameId = game.id; + + if (clientState.isLoggedIn && clientState.myUserId && game.ownerIdentifier === clientState.myUserId) { + joinBtn.disabled = true; + joinBtn.title = "Вы не можете присоединиться к своей же ожидающей игре."; + } else { + joinBtn.disabled = false; + } + + joinBtn.addEventListener('click', (e) => { + // --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ --- + if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) { + if (typeof ui.redirectToLogin === 'function') { + ui.redirectToLogin('Для присоединения к игре необходимо войти или обновить сессию.'); + } else { + alert('Для присоединения к игре необходимо войти или обновить сессию.'); + window.location.href = '/'; // Фоллбэк + } + return; + } + // --- КОНЕЦ ПРОВЕРКИ ТОКЕНА --- + + if (e.target.disabled) return; + + ui.disableSetupButtons(); + socket.emit('joinGame', { gameId: e.target.dataset.gameId }); + ui.setGameStatusMessage(`Присоединение к игре ${e.target.dataset.gameId.substring(0, 8)}...`); + }); + li.appendChild(joinBtn); + ul.appendChild(li); + } + }); + availableGamesDiv.appendChild(ul); + } else { + availableGamesDiv.innerHTML += '

Нет доступных игр. Создайте свою!

'; + } + ui.enableSetupButtons(); + } + + // --- Обработчики событий DOM --- + if (createAIGameButton) { + createAIGameButton.addEventListener('click', () => { + // --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ --- + if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) { + if (typeof ui.redirectToLogin === 'function') { + ui.redirectToLogin('Для создания игры необходимо войти или обновить сессию.'); + } else { + alert('Для создания игры необходимо войти или обновить сессию.'); + window.location.href = '/'; // Фоллбэк + } + return; + } + // --- КОНЕЦ ПРОВЕРКИ ТОКЕНА --- + + ui.disableSetupButtons(); + socket.emit('createGame', { mode: 'ai', characterKey: 'elena' }); // Персонаж для AI может быть фиксированным + ui.setGameStatusMessage("Создание игры против AI..."); + }); + } + + if (createPvPGameButton) { + createPvPGameButton.addEventListener('click', () => { + // --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ --- + if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) { + if (typeof ui.redirectToLogin === 'function') { + ui.redirectToLogin('Для создания PvP игры необходимо войти или обновить сессию.'); + } else { + alert('Для создания PvP игры необходимо войти или обновить сессию.'); + window.location.href = '/'; // Фоллбэк + } + return; + } + // --- КОНЕЦ ПРОВЕРКИ ТОКЕНА --- + + ui.disableSetupButtons(); + const characterKey = getSelectedCharacterKey(); + socket.emit('createGame', { mode: 'pvp', characterKey: characterKey }); + ui.setGameStatusMessage("Создание PvP игры..."); + }); + } + + if (joinPvPGameButton) { + joinPvPGameButton.addEventListener('click', () => { + // --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ --- + if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) { + if (typeof ui.redirectToLogin === 'function') { + ui.redirectToLogin('Для присоединения к игре необходимо войти или обновить сессию.'); + } else { + alert('Для присоединения к игре необходимо войти или обновить сессию.'); + window.location.href = '/'; // Фоллбэк + } + return; + } + // --- КОНЕЦ ПРОВЕРКИ ТОКЕНА --- + + const gameId = gameIdInput ? gameIdInput.value.trim() : ''; + if (gameId) { + ui.disableSetupButtons(); + socket.emit('joinGame', { gameId: gameId }); + ui.setGameStatusMessage(`Присоединение к игре ${gameId}...`); + } else { + ui.setGameStatusMessage("Введите ID игры, чтобы присоединиться.", true); + } + }); + } + + if (findRandomPvPGameButton) { + findRandomPvPGameButton.addEventListener('click', () => { + // --- ПРОВЕРКА ТОКЕНА ПЕРЕД ДЕЙСТВИЕМ --- + if (typeof checkTokenValidity === 'function' && (!clientState.isLoggedIn || !checkTokenValidity(localStorage.getItem('jwtToken')))) { + if (typeof ui.redirectToLogin === 'function') { + ui.redirectToLogin('Для поиска игры необходимо войти или обновить сессию.'); + } else { + alert('Для поиска игры необходимо войти или обновить сессию.'); + window.location.href = '/'; // Фоллбэк + } + return; + } + // --- КОНЕЦ ПРОВЕРКИ ТОКЕНА --- + + ui.disableSetupButtons(); + const characterKey = getSelectedCharacterKey(); + socket.emit('findRandomGame', { characterKey: characterKey }); + ui.setGameStatusMessage("Поиск случайной PvP игры..."); + }); + } + + // --- Обработчики событий Socket.IO --- + + socket.on('gameCreated', (data) => { + if (!clientState.isLoggedIn) return; + + console.log('[GameSetup] Game created by this client:', data); + clientState.currentGameId = data.gameId; + clientState.myPlayerId = data.yourPlayerId; + ui.updateGlobalWindowVariablesForUI(); + }); + + socket.on('availablePvPGamesList', (games) => { + // Проверяем, залогинен ли пользователь, ПЕРЕД обновлением списка. + // Если пользователь разлогинился, а список пришел, его не нужно показывать на экране логина. + if (!clientState.isLoggedIn) { + if (availableGamesDiv) availableGamesDiv.innerHTML = ''; // Очищаем, если пользователь не залогинен + return; + } + updateAvailableGamesList(games); + }); + + socket.on('noPendingGamesFound', (data) => { + if (!clientState.isLoggedIn) return; + + ui.setGameStatusMessage(data.message || "Свободных игр не найдено. Создана новая для вас. Ожидание оппонента..."); + updateAvailableGamesList([]); + + if (data.gameId) clientState.currentGameId = data.gameId; + if (data.yourPlayerId) clientState.myPlayerId = data.yourPlayerId; + ui.updateGlobalWindowVariablesForUI(); + + clientState.isInGame = false; + ui.disableSetupButtons(); + + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, 'pvp'); + } + }); + + socket.on('waitingForOpponent', () => { + if (!clientState.isLoggedIn) return; + + ui.setGameStatusMessage("Ожидание присоединения оппонента..."); + ui.disableSetupButtons(); + + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, 'pvp'); + } + }); +} \ No newline at end of file diff --git a/public/js/gameplay.js b/public/js/gameplay.js new file mode 100644 index 0000000..95110dd --- /dev/null +++ b/public/js/gameplay.js @@ -0,0 +1,444 @@ +// /public/js/gameplay.js + +export function initGameplay(dependencies) { + const { socket, clientState, ui } = dependencies; + const { returnToMenuButton } = ui.elements; + + const attackButton = document.getElementById('button-attack'); + const abilitiesGrid = document.getElementById('abilities-grid'); + + // Инициализируем флаг в clientState, если он еще не существует (лучше делать в main.js) + if (typeof clientState.isActionInProgress === 'undefined') { + clientState.isActionInProgress = false; + } + + // --- Вспомогательные функции --- + function enableGameControls(enableAttack = true, enableAbilities = true) { + // console.log(`[GP] enableGameControls called. enableAttack: ${enableAttack}, enableAbilities: ${enableAbilities}, isActionInProgress: ${clientState.isActionInProgress}`); + if (clientState.isActionInProgress) { + if (attackButton) attackButton.disabled = true; + if (abilitiesGrid) { + const config = window.GAME_CONFIG || {}; + const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; + abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; }); + } + // console.log(`[GP] Action in progress, controls remain disabled.`); + if (window.gameUI?.updateUI) requestAnimationFrame(() => window.gameUI.updateUI()); + return; + } + + if (attackButton) attackButton.disabled = !enableAttack; + if (abilitiesGrid) { + const config = window.GAME_CONFIG || {}; + const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; + abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = !enableAbilities; }); + } + // console.log(`[GP] Controls set. Attack disabled: ${attackButton ? attackButton.disabled : 'N/A'}`); + if (window.gameUI?.updateUI) { + requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled + } + } + + function disableGameControls() { + // console.log(`[GP] disableGameControls called.`); + if (attackButton) attackButton.disabled = true; + if (abilitiesGrid) { + const config = window.GAME_CONFIG || {}; + const cls = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; + abilitiesGrid.querySelectorAll(`.${cls}`).forEach(b => { b.disabled = true; }); + } + if (window.gameUI?.updateUI) { + requestAnimationFrame(() => window.gameUI.updateUI()); // Обновляем UI, чтобы 반영 반영反映 изменения в disabled + } + } + + function initializeAbilityButtons() { + if (!abilitiesGrid || !window.gameUI || !window.GAME_CONFIG) { + if (abilitiesGrid) abilitiesGrid.innerHTML = '

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

'; + return; + } + abilitiesGrid.innerHTML = ''; + const config = window.GAME_CONFIG; + const abilitiesToDisplay = clientState.playerAbilitiesServer; + const baseStatsForResource = clientState.playerBaseStatsServer; + + if (!abilitiesToDisplay || abilitiesToDisplay.length === 0 || !baseStatsForResource) { + abilitiesGrid.innerHTML = '

Нет доступных способностей.

'; + return; + } + + const resourceName = baseStatsForResource.resourceName || "Ресурс"; + const abilityButtonClass = config.CSS_CLASS_ABILITY_BUTTON || 'ability-button'; + + abilitiesToDisplay.forEach(ability => { + const button = document.createElement('button'); + button.id = `ability-btn-${ability.id}`; + button.classList.add(abilityButtonClass); + button.dataset.abilityId = ability.id; + 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); + button.addEventListener('click', handleAbilityButtonClick); + abilitiesGrid.appendChild(button); + }); + const placeholder = abilitiesGrid.querySelector('.placeholder-text'); + if (placeholder) placeholder.remove(); + } + + function handleAbilityButtonClick(event) { + const abilityId = event.currentTarget.dataset.abilityId; + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] handleAbilityButtonClick. AbilityID: ${abilityId}, isActionInProgress: ${clientState.isActionInProgress}`); + + if (clientState.isLoggedIn && + clientState.isInGame && + clientState.currentGameId && + abilityId && + clientState.currentGameState && + !clientState.currentGameState.isGameOver && + !clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА + + console.log(`[CLIENT ${username}] Emitting playerAction (ability: ${abilityId}). Setting isActionInProgress = true.`); + clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА + disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ + socket.emit('playerAction', { actionType: 'ability', abilityId: abilityId }); + } else { + console.warn(`[CLIENT ${username}] Cannot perform ability action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`); + } + } + + // --- Обработчики событий DOM --- + if (attackButton) { + attackButton.addEventListener('click', () => { + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Attack button clicked. isActionInProgress: ${clientState.isActionInProgress}`); + + if (clientState.isLoggedIn && + clientState.isInGame && + clientState.currentGameId && + clientState.currentGameState && + !clientState.currentGameState.isGameOver && + !clientState.isActionInProgress) { // <--- ПРОВЕРКА ФЛАГА + + console.log(`[CLIENT ${username}] Emitting playerAction (attack). Setting isActionInProgress = true.`); + clientState.isActionInProgress = true; // <--- УСТАНОВКА ФЛАГА + disableGameControls(); // <--- БЛОКИРОВКА СРАЗУ + socket.emit('playerAction', { actionType: 'attack' }); + } else { + console.warn(`[CLIENT ${username}] Cannot perform attack action. Conditions not met or action in progress. InGame: ${clientState.isInGame}, GameOver: ${clientState.currentGameState?.isGameOver}, ActionInProgress: ${clientState.isActionInProgress}`); + } + }); + } + + if (returnToMenuButton) { + returnToMenuButton.addEventListener('click', () => { + if (!clientState.isLoggedIn) { + ui.showAuthScreen(); + return; + } + returnToMenuButton.disabled = true; // Блокируем сразу, чтобы избежать двойных кликов + clientState.isActionInProgress = false; // Сбрасываем на всякий случай, если покидаем игру + clientState.isInGame = false; + disableGameControls(); + ui.showGameSelectionScreen(clientState.loggedInUsername); + }); + } + + + // --- ОБЩИЙ ОБРАБОТЧИК ДЛЯ ЗАПУСКА/ВОССТАНОВЛЕНИЯ ИГРЫ --- + function handleGameDataReceived(data, eventName = "unknown") { + if (!clientState.isLoggedIn) { + console.warn(`[CLIENT] handleGameDataReceived (${eventName}) called, but client not logged in. Ignoring.`); + return; + } + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] handleGameDataReceived from event: ${eventName}. GameID: ${data.gameId}, YourPlayerID: ${data.yourPlayerId}, GS.isPlayerTurn: ${data.initialGameState?.isPlayerTurn || data.gameState?.isPlayerTurn}`); + + clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА при получении нового полного состояния + + clientState.currentGameId = data.gameId; + clientState.myPlayerId = data.yourPlayerId; + 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; + + 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 }; + } else if (!window.GAME_CONFIG) { + window.GAME_CONFIG = { PLAYER_ID: 'player', OPPONENT_ID: 'opponent', CSS_CLASS_HIDDEN: 'hidden' }; // Базовый конфиг + } + ui.updateGlobalWindowVariablesForUI(); + + 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(); + + if (window.gameUI?.uiElements?.log?.list) { + window.gameUI.uiElements.log.list.innerHTML = ''; + } + if (window.gameUI?.addToLog && data.log) { + data.log.forEach(logEntry => { + window.gameUI.addToLog(logEntry.message, logEntry.type); + }); + } + + requestAnimationFrame(() => { + 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)); + + console.log(`[CLIENT ${username}] handleGameDataReceived - Determining controls. isMyActualTurn: ${isMyActualTurn}`); + if (isMyActualTurn) { + enableGameControls(); + } else { + disableGameControls(); + } + } else if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + console.log(`[CLIENT ${username}] handleGameDataReceived - Game is over, disabling controls.`); + disableGameControls(); + } + }); + + if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + // Обработка 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 { + if (clientState.isInGame) { + // Если это просто gameStateUpdate, и игра активна, убедимся, что нет сообщения об ожидании + const statusMsgElement = document.getElementById('game-status-message'); + const currentStatusText = statusMsgElement ? statusMsgElement.textContent : ""; + if (!currentStatusText.toLowerCase().includes("отключился")) { // Не стираем сообщение об отключении оппонента + ui.setGameStatusMessage(""); + } + } + } + if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + if (window.gameUI?.showGameOver && !document.getElementById('game-over-screen').classList.contains(window.GAME_CONFIG?.CSS_CLASS_HIDDEN || '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) { + // Дополнительная логика определения победителя, если winnerId нет (маловероятно при корректной работе сервера) + 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 || data.loserCharacterKey, { 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. GS.isPlayerTurn: ${data.gameState?.isPlayerTurn}`); + + clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА + clientState.currentGameState = data.gameState; + ui.updateGlobalWindowVariablesForUI(); + + 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)); + + console.log(`[CLIENT ${username}] gameStateUpdate - Determining controls. isMyActualTurn: ${isMyActualTurn}`); + if (isMyActualTurn) { + enableGameControls(); + } else { + disableGameControls(); + } + + const statusMsgElement = document.getElementById('game-status-message'); + const currentStatusText = statusMsgElement ? statusMsgElement.textContent : ""; + if (!currentStatusText.toLowerCase().includes("отключился")) { + ui.setGameStatusMessage(""); + } + + } else if (clientState.currentGameState && clientState.currentGameState.isGameOver) { + console.log(`[CLIENT ${username}] gameStateUpdate - Game is over, disabling controls.`); + disableGameControls(); + } + }); + } + + if (window.gameUI?.addToLog && data.log) { + data.log.forEach(log => window.gameUI.addToLog(log.message, log.type)); + } + }); + + 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)); + } + }); + + 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(); + return; + } + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Event: gameOver. WinnerID: ${data.winnerId}, Reason: ${data.reason}`); + + clientState.isActionInProgress = false; // <--- СБРОС ФЛАГА + const playerWon = data.winnerId === clientState.myPlayerId; + clientState.currentGameState = data.finalGameState; + clientState.isInGame = false; + + 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.opponentCharacterKey || data.loserCharacterKey; + window.gameUI.showGameOver(playerWon, data.reason, oppKey, data); + } + if (returnToMenuButton) returnToMenuButton.disabled = false; + + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState?.gameMode); + } + 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. PlayerID: ${data.disconnectedPlayerId}`); + const name = data.disconnectedCharacterName || clientState.opponentBaseStatsServer?.name || 'Противник'; + + if (clientState.currentGameState && !clientState.currentGameState.isGameOver) { + ui.setGameStatusMessage(`Противник (${name}) отключился. Ожидание...`, true); + disableGameControls(); + } + }); + + socket.on('playerReconnected', (data) => { // Обработчик события, что оппонент переподключился + if (!clientState.isLoggedIn || !clientState.isInGame || !clientState.currentGameId || !window.GAME_CONFIG) return; + const username = clientState.loggedInUsername || 'N/A'; + console.log(`[CLIENT ${username}] Event: playerReconnected. PlayerID: ${data.reconnectedPlayerId}, Name: ${data.reconnectedPlayerName}`); + // const name = data.reconnectedPlayerName || clientState.opponentBaseStatsServer?.name || 'Противник'; + + if (clientState.currentGameState && !clientState.currentGameState.isGameOver) { + // Сообщение о переподключении оппонента обычно приходит через 'logUpdate' + // Но если нужно немедленно убрать статус "Ожидание...", можно сделать здесь: + const statusMsgElement = document.getElementById('game-status-message'); + const currentStatusText = statusMsgElement ? statusMsgElement.textContent : ""; + if (currentStatusText.toLowerCase().includes("отключился")) { + ui.setGameStatusMessage(""); // Очищаем сообщение об ожидании + } + // Логика enable/disableGameControls будет вызвана следующим gameStateUpdate или turnTimerUpdate + } + }); + + + socket.on('turnTimerUpdate', (data) => { + 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 (clientState.currentGameState.isGameOver) { + if (window.gameUI?.updateTurnTimerDisplay) { + window.gameUI.updateTurnTimerDisplay(null, false, clientState.currentGameState.gameMode); + } + // disableGameControls() уже должен быть вызван в gameOver + return; + } + // const username = clientState.loggedInUsername || 'N/A'; + // console.log(`[CLIENT ${username}] Event: turnTimerUpdate. Remaining: ${data.remainingTime}, isPlayerTurnForTimer: ${data.isPlayerTurn}, isPaused: ${data.isPaused}`); + + + if (window.gameUI && typeof window.gameUI.updateTurnTimerDisplay === 'function') { + const config = window.GAME_CONFIG; + const isMyTurnForTimer = clientState.myPlayerId && clientState.currentGameState && + ((data.isPlayerTurn && clientState.myPlayerId === config.PLAYER_ID) || // Серверное data.isPlayerTurn здесь авторитетно для таймера + (!data.isPlayerTurn && clientState.myPlayerId === config.OPPONENT_ID)); + + window.gameUI.updateTurnTimerDisplay(data.remainingTime, isMyTurnForTimer, clientState.currentGameState.gameMode); + + // Если игра НЕ на паузе (серверной или клиентской из-за дисконнекта оппонента) + if (!data.isPaused) { + // Управление кнопками должно быть на основе isPlayerTurn из gameState, а не из turnTimerUpdate + // gameStateUpdate обработает это. Здесь только если нужно немедленно реагировать на isPlayerTurn из таймера, + // но это может привести к конфликтам с gameState.isPlayerTurn. + // Лучше положиться на gameStateUpdate. + // Однако, если ТАЙМЕР НЕ ПРИОСТАНОВЛЕН и это МОЙ ХОД по таймеру, то кнопки должны быть активны. + // Это может быть полезно, если gameStateUpdate запаздывает. + if (isMyTurnForTimer && !clientState.currentGameState.isGameOver) { // Дополнительная проверка на GameOver + enableGameControls(); + } else if (!isMyTurnForTimer && !clientState.currentGameState.isGameOver){ // Иначе, если не мой ход + disableGameControls(); + } + + const statusMsgElement = document.getElementById('game-status-message'); + const currentStatusText = statusMsgElement ? statusMsgElement.textContent : ""; + if (!currentStatusText.toLowerCase().includes("отключился") && !clientState.currentGameState.isGameOver) { + // console.log(`[CLIENT ${username}] turnTimerUpdate - Clearing game status message as timer is active and not paused.`); + ui.setGameStatusMessage(""); + } + } else { // Если игра на паузе (по данным таймера) + // console.log(`[CLIENT ${username}] turnTimerUpdate - Game is paused, disabling controls.`); + disableGameControls(); // Отключаем управление, если таймер говорит, что игра на паузе + } + } + }); + + // Начальная деактивация (на всякий случай, хотя showAuthScreen/showGameSelectionScreen должны это делать) + disableGameControls(); +} \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..111dc69 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,482 @@ +// /public/js/main.js + +import { initAuth } from './auth.js'; +import { initGameSetup } from './gameSetup.js'; +import { initGameplay } from './gameplay.js'; +// ui.js загружен глобально и ожидает window.* переменных + +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ РАБОТЫ С JWT (для isTokenValid) --- +function parseJwtPayloadForValidation(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("[Main.js parseJwtPayloadForValidation] Error parsing JWT payload:", e, "Token:", token); + return null; + } +} + +function isTokenValid(token) { + if (!token) { + return false; + } + const decodedToken = parseJwtPayloadForValidation(token); + if (!decodedToken || typeof decodedToken.exp !== 'number') { + localStorage.removeItem('jwtToken'); + return false; + } + const currentTimeInSeconds = Math.floor(Date.now() / 1000); + if (decodedToken.exp < currentTimeInSeconds) { + localStorage.removeItem('jwtToken'); + return false; + } + return true; +} +// --- КОНЕЦ ВСПОМОГАТЕЛЬНЫХ ФУНКЦИЙ ДЛЯ JWT --- + + +document.addEventListener('DOMContentLoaded', () => { + console.log('[Main.js] DOMContentLoaded event fired.'); + + const initialToken = localStorage.getItem('jwtToken'); + console.log('[Main.js] Initial token from localStorage:', initialToken ? 'Exists' : 'Not found'); + + let clientState = { + isLoggedIn: false, + loggedInUsername: '', + myUserId: null, + isInGame: false, + currentGameId: null, + currentGameState: null, + myPlayerId: null, // Роль в текущей игре (player/opponent) + myCharacterKey: null, + opponentCharacterKey: null, + playerBaseStatsServer: null, + opponentBaseStatsServer: null, + playerAbilitiesServer: null, + opponentAbilitiesServer: null, + isActionInProgress: false, // <--- ВАЖНО: Флаг для предотвращения двойных действий + }; + + if (initialToken && isTokenValid(initialToken)) { + const decodedToken = parseJwtPayloadForValidation(initialToken); + if (decodedToken && decodedToken.userId && decodedToken.username) { + console.log("[Main.js] Token found and confirmed valid, pre-populating clientState:", decodedToken); + clientState.isLoggedIn = true; + clientState.myUserId = decodedToken.userId; // Это ID пользователя из БД + clientState.loggedInUsername = decodedToken.username; + } else { + console.warn("[Main.js] Token deemed valid by isTokenValid, but payload incomplete. Clearing."); + localStorage.removeItem('jwtToken'); + } + } else if (initialToken) { + console.warn("[Main.js] Initial token was present but invalid/expired. It has been cleared."); + } else { + console.log("[Main.js] No initial token found in localStorage."); + } + + console.log('[Main.js] Initial clientState after token check:', JSON.parse(JSON.stringify(clientState))); + + console.log('[Main.js] Initializing Socket.IO client...'); + const socket = io({ + path:base_path + "/socket.io", // base_path определяется в HTML + autoConnect: false, // Подключаемся вручную после инициализации всего + auth: { token: localStorage.getItem('jwtToken') } + }); + console.log('[Main.js] Socket.IO client initialized.'); + + // --- DOM Элементы --- + console.log('[Main.js] Getting DOM elements...'); + 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'); // Кнопка в gameOver модальном окне + const turnTimerContainer = document.getElementById('turn-timer-container'); + const turnTimerSpan = document.getElementById('turn-timer'); + + // --- Функции обновления UI и состояния --- + function updateGlobalWindowVariablesForUI() { + window.gameState = clientState.currentGameState; + window.gameData = { + playerBaseStats: clientState.playerBaseStatsServer, + opponentBaseStats: clientState.opponentBaseStatsServer, + playerAbilities: clientState.playerAbilitiesServer, + opponentAbilities: clientState.opponentAbilitiesServer + }; + window.myPlayerId = clientState.myPlayerId; // Роль игрока (player/opponent) + } + + function resetGameVariables() { + console.log("[Main.js resetGameVariables] Resetting game variables. State BEFORE:", JSON.parse(JSON.stringify(clientState))); + clientState.currentGameId = null; + clientState.currentGameState = null; + clientState.myPlayerId = null; + clientState.myCharacterKey = null; + clientState.opponentCharacterKey = null; + clientState.playerBaseStatsServer = null; + clientState.opponentBaseStatsServer = null; + clientState.playerAbilitiesServer = null; + clientState.opponentAbilitiesServer = null; + clientState.isActionInProgress = false; // <--- Сброс флага + updateGlobalWindowVariablesForUI(); + console.log("[Main.js resetGameVariables] Game variables reset. State AFTER:", JSON.parse(JSON.stringify(clientState))); + } + + function explicitlyHideGameOverModal() { + 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'; + } + } + if (messageElement) messageElement.textContent = ''; + } + } + + function showAuthScreen() { + console.log("[Main.js showAuthScreen] Showing Auth Screen. Resetting game state."); + if(authSection) authSection.style.display = 'block'; + if(userInfoDiv) userInfoDiv.style.display = 'none'; + if(gameSetupDiv) gameSetupDiv.style.display = 'none'; + if(gameWrapper) gameWrapper.style.display = 'none'; + explicitlyHideGameOverModal(); + if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений + clientState.isInGame = false; + resetGameVariables(); // Включает сброс isActionInProgress + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false; + if(loginForm && loginForm.querySelector('button')) loginForm.querySelector('button').disabled = false; + if(logoutButton) logoutButton.disabled = true; // Кнопка выхода неактивна на экране логина + } + + function showGameSelectionScreen(username) { + console.log(`[Main.js showGameSelectionScreen] Showing Game Selection Screen for ${username}.`); + if(authSection) authSection.style.display = 'none'; + if(userInfoDiv) userInfoDiv.style.display = 'block'; + if(loggedInUsernameSpan) loggedInUsernameSpan.textContent = username; + if(logoutButton) logoutButton.disabled = false; + if(gameSetupDiv) gameSetupDiv.style.display = 'block'; + if(gameWrapper) gameWrapper.style.display = 'none'; + explicitlyHideGameOverModal(); + setGameStatusMessage("Выберите режим игры или присоединитесь к существующей."); + if(statusContainer) statusContainer.style.display = 'block'; + + if (socket.connected) { + console.log("[Main.js showGameSelectionScreen] Socket connected, requesting PvP game list."); + socket.emit('requestPvPGameList'); + } else { + console.warn("[Main.js showGameSelectionScreen] Socket not connected, cannot request PvP game list yet."); + // Можно попробовать подключить сокет, если он не подключен + // socket.connect(); // Или дождаться авто-реконнекта + } + + if (availableGamesDiv) availableGamesDiv.innerHTML = '

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

Загрузка...

'; + if (gameIdInput) gameIdInput.value = ''; + const elenaRadio = document.getElementById('char-elena'); + if (elenaRadio) elenaRadio.checked = true; // Персонаж по умолчанию + clientState.isInGame = false; + clientState.isActionInProgress = false; // <--- Сброс флага при переходе в меню + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + enableSetupButtons(); // Включаем кнопки настройки игры + if (window.gameUI?.uiElements?.gameOver?.returnToMenuButton) { + window.gameUI.uiElements.gameOver.returnToMenuButton.disabled = false; // Убедимся, что кнопка в модалке gameOver активна + } + } + + function showGameScreen() { + console.log("[Main.js showGameScreen] Showing Game Screen."); + if(authSection) authSection.style.display = 'none'; + if(userInfoDiv) userInfoDiv.style.display = 'block'; // userInfo (имя, выход) остается видимым + if(logoutButton) logoutButton.disabled = false; + if(gameSetupDiv) gameSetupDiv.style.display = 'none'; + if(gameWrapper) gameWrapper.style.display = 'flex'; // Используем flex для game-wrapper + setGameStatusMessage(""); // Очищаем сообщение статуса игры при входе на экран игры + if(statusContainer) statusContainer.style.display = 'none'; // Скрываем общий статус-контейнер на игровом экране + clientState.isInGame = true; + // clientState.isActionInProgress остается false до первого действия игрока + updateGlobalWindowVariablesForUI(); + if (turnTimerContainer) turnTimerContainer.style.display = 'block'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; // Таймер обновится по событию + } + + function setAuthMessage(message, isError = false) { + console.log(`[Main.js setAuthMessage] Message: "${message}", isError: ${isError}`); + if (authMessage) { + authMessage.textContent = message; + authMessage.className = isError ? 'error' : 'success'; + authMessage.style.display = message ? 'block' : 'none'; + } + // Если показываем authMessage, скрываем gameStatusMessage + if (message && gameStatusMessage && gameStatusMessage.style.display !== 'none') { + gameStatusMessage.style.display = 'none'; + } + } + + function setGameStatusMessage(message, isError = false) { + console.log(`[Main.js setGameStatusMessage] Message: "${message}", isError: ${isError}`); + if (gameStatusMessage) { + gameStatusMessage.textContent = message; + gameStatusMessage.style.display = message ? 'block' : 'none'; + gameStatusMessage.style.color = isError ? 'var(--damage-color, red)' : 'var(--turn-color, yellow)'; // или другой цвет для обычных сообщений + // Управляем видимостью общего контейнера статуса + if (statusContainer) statusContainer.style.display = message ? 'block' : 'none'; + } + // Если показываем gameStatusMessage, скрываем authMessage + if (message && authMessage && authMessage.style.display !== 'none') { + authMessage.style.display = 'none'; + } + } + + function disableSetupButtons() { + if(createAIGameButton) createAIGameButton.disabled = true; + if(createPvPGameButton) createPvPGameButton.disabled = true; + if(joinPvPGameButton) joinPvPGameButton.disabled = true; + if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = true; + if(availableGamesDiv) availableGamesDiv.querySelectorAll('button').forEach(btn => btn.disabled = true); + } + function enableSetupButtons() { + if(createAIGameButton) createAIGameButton.disabled = false; + if(createPvPGameButton) createPvPGameButton.disabled = false; + if(joinPvPGameButton) joinPvPGameButton.disabled = false; + if(findRandomPvPGameButton) findRandomPvPGameButton.disabled = false; + // Кнопки в списке доступных игр управляются в updateAvailableGamesList в gameSetup.js + } + + function redirectToLogin(message) { + console.log(`[Main.js redirectToLogin] Redirecting to login. Message: "${message}"`); + clientState.isLoggedIn = false; + clientState.loggedInUsername = ''; + clientState.myUserId = null; + clientState.isInGame = false; + localStorage.removeItem('jwtToken'); + resetGameVariables(); // Сбрасываем все игровые переменные, включая isActionInProgress + + if (socket.auth) socket.auth.token = null; // Обновляем auth объект сокета + if (socket.connected) { + console.log("[Main.js redirectToLogin] Socket connected, disconnecting before showing auth screen."); + socket.disconnect(); + } + + showAuthScreen(); + setAuthMessage(message || "Для продолжения необходимо войти или обновить сессию.", true); + } + + // --- Сборка зависимостей для модулей --- + console.log('[Main.js] Preparing dependencies for modules...'); + const dependencies = { + socket, + clientState, + ui: { + showAuthScreen, + showGameSelectionScreen, + showGameScreen, + setAuthMessage, + setGameStatusMessage, + resetGameVariables, + updateGlobalWindowVariablesForUI, + disableSetupButtons, + enableSetupButtons, + redirectToLogin, + elements: { + loginForm, registerForm, logoutButton, + createAIGameButton, createPvPGameButton, joinPvPGameButton, + findRandomPvPGameButton, gameIdInput, availableGamesDiv, + pvpCharacterRadios, returnToMenuButton, + // Не передаем сюда все элементы из ui.js, так как ui.js сам их менеджит. + // Если какой-то модуль должен напрямую менять что-то из ui.js.uiElements, + // то можно передать ui.js.uiElements целиком или конкретные элементы. + } + }, + utils: { + isTokenValid, + parseJwtPayloadForValidation // На всякий случай, если понадобится где-то еще + } + }; + + console.log('[Main.js] Initializing auth module...'); + initAuth(dependencies); + console.log('[Main.js] Initializing gameSetup module...'); + initGameSetup(dependencies); + console.log('[Main.js] Initializing gameplay module...'); + initGameplay(dependencies); + console.log('[Main.js] All modules initialized.'); + + // --- Обработчики событий Socket.IO --- + socket.on('connect', () => { + const currentToken = localStorage.getItem('jwtToken'); + if (socket.auth) socket.auth.token = currentToken; // Убедимся, что auth объект сокета обновлен + else socket.auth = { token: currentToken }; // Если auth объекта не было + + console.log('[Main.js Socket.IO] Event: connect. Socket ID:', socket.id, 'Auth token associated with this connection attempt:', !!currentToken); + + if (clientState.isLoggedIn && clientState.myUserId && isTokenValid(currentToken)) { + console.log(`[Main.js Socket.IO] Client state indicates logged in as ${clientState.loggedInUsername} (ID: ${clientState.myUserId}) and token is valid. Requesting game state.`); + // Если мы на экране выбора игры, показываем сообщение о восстановлении + if (!clientState.isInGame && (gameSetupDiv.style.display === 'block' || authSection.style.display === 'block')) { + setGameStatusMessage("Восстановление игровой сессии..."); + } + socket.emit('requestGameState'); + } else { + if (clientState.isLoggedIn && !isTokenValid(currentToken)) { + console.warn('[Main.js Socket.IO connect] Client state says logged in, but token is invalid/expired. Redirecting to login.'); + redirectToLogin("Ваша сессия истекла. Пожалуйста, войдите снова."); + } else { + console.log('[Main.js Socket.IO connect] Client state indicates NOT logged in or no valid token. Showing auth screen if not already visible.'); + if (authSection.style.display !== 'block') { + showAuthScreen(); // Показываем экран логина, если еще не на нем + } + setAuthMessage("Пожалуйста, войдите или зарегистрируйтесь."); // Сообщение по умолчанию для экрана логина + } + } + }); + + socket.on('connect_error', (err) => { + console.error('[Main.js Socket.IO] Event: connect_error. Message:', err.message, err.data ? JSON.stringify(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.js Socket.IO connect_error] Authentication error during connection. Redirecting to login.'); + redirectToLogin("Ошибка аутентификации. Пожалуйста, войдите снова."); + } else { + let currentScreenMessageFunc = setAuthMessage; + if (clientState.isLoggedIn && clientState.isInGame) { + currentScreenMessageFunc = setGameStatusMessage; + } else if (clientState.isLoggedIn) { // Если залогинен, но не в игре (на экране выбора) + currentScreenMessageFunc = setGameStatusMessage; + } + currentScreenMessageFunc(`Ошибка подключения: ${err.message}. Попытка переподключения...`, true); + // Если не залогинены и не на экране авторизации, показываем его + if (!clientState.isLoggedIn && authSection.style.display !== 'block') { + showAuthScreen(); + } + } + if (turnTimerSpan) turnTimerSpan.textContent = 'Ошибка'; + }); + + socket.on('disconnect', (reason) => { + console.warn('[Main.js Socket.IO] Event: disconnect. Reason:', reason); + let messageFunc = setAuthMessage; // По умолчанию сообщение для экрана авторизации + if (clientState.isInGame) { + messageFunc = setGameStatusMessage; + } else if (clientState.isLoggedIn && gameSetupDiv.style.display === 'block') { + messageFunc = setGameStatusMessage; + } + + if (reason === 'io server disconnect') { + messageFunc("Соединение разорвано сервером. Пожалуйста, попробуйте войти снова.", true); + redirectToLogin("Соединение разорвано сервером. Пожалуйста, войдите снова."); + } else if (reason === 'io client disconnect') { + // Это преднамеренный дисконнект (например, при logout или смене токена). + // Сообщение уже должно быть установлено функцией, вызвавшей дисконнект. + // Ничего не делаем здесь, чтобы не перезаписать его. + console.log('[Main.js Socket.IO] Disconnect was intentional (io client disconnect). No additional message needed.'); + } else { // Другие причины (например, проблемы с сетью) + messageFunc(`Потеряно соединение: ${reason}. Попытка переподключения...`, true); + } + if (turnTimerSpan) turnTimerSpan.textContent = 'Откл.'; + clientState.isActionInProgress = false; // На всякий случай сбрасываем флаг при дисконнекте + }); + + socket.on('gameError', (data) => { + console.error('[Main.js Socket.IO] Event: gameError. Message:', data.message, 'Data:', JSON.stringify(data)); + clientState.isActionInProgress = false; // Сбрасываем флаг при ошибке сервера + + if (data.message && (data.message.toLowerCase().includes("сессия истекла") || data.message.toLowerCase().includes("необходимо войти"))) { + redirectToLogin(data.message); + return; + } + + if (clientState.isInGame && window.gameUI?.addToLog) { + window.gameUI.addToLog(`❌ Ошибка сервера: ${data.message}`, 'system'); + // Если ошибка произошла в игре, но игра не закончилась, кнопки могут остаться заблокированными. + // Возможно, стоит проверить, чей ход, и разблокировать, если ход игрока и игра не окончена. + // Но это зависит от типа ошибки. Сейчас просто логируем. + } else if (clientState.isLoggedIn) { + setGameStatusMessage(`❌ Ошибка: ${data.message}`, true); + enableSetupButtons(); // Разблокируем кнопки, если ошибка на экране выбора игры + } else { + setAuthMessage(`❌ Ошибка: ${data.message}`, true); + if(registerForm && registerForm.querySelector('button')) registerForm.querySelector('button').disabled = false; + if(loginForm && loginForm.querySelector('button')) loginForm.querySelector('button').disabled = false; + } + }); + + socket.on('gameNotFound', (data) => { + console.log('[Main.js Socket.IO] Event: gameNotFound. Message:', data?.message, 'Data:', JSON.stringify(data)); + clientState.isInGame = false; + resetGameVariables(); // Включает сброс isActionInProgress + explicitlyHideGameOverModal(); + if (turnTimerContainer) turnTimerContainer.style.display = 'none'; + if (turnTimerSpan) turnTimerSpan.textContent = '--'; + + if (clientState.isLoggedIn && isTokenValid(localStorage.getItem('jwtToken'))) { + if (gameSetupDiv.style.display !== 'block') { // Если мы не на экране выбора игры, показываем его + showGameSelectionScreen(clientState.loggedInUsername); + } + setGameStatusMessage(data?.message || "Активная игровая сессия не найдена. Выберите новую игру."); + } else { + redirectToLogin(data?.message || "Пожалуйста, войдите для продолжения."); + } + }); + + // --- Инициализация UI --- + console.log('[Main.js] Initializing UI visibility...'); + if(authSection) authSection.style.display = 'none'; + if(gameSetupDiv) gameSetupDiv.style.display = 'none'; + if(gameWrapper) gameWrapper.style.display = 'none'; + if(userInfoDiv) userInfoDiv.style.display = 'none'; + if(statusContainer) statusContainer.style.display = 'block'; // Показываем общий контейнер для сообщений + + if (clientState.isLoggedIn) { + console.log('[Main.js] Client is considered logged in. Will attempt session recovery on socket connect.'); + // Не показываем экран выбора игры сразу, дожидаемся 'connect' и 'requestGameState' + setAuthMessage("Подключение и восстановление сессии..."); // Используем authMessage для начального сообщения + } else { + console.log('[Main.js] Client is NOT considered logged in. Showing auth screen.'); + showAuthScreen(); + setAuthMessage("Подключение к серверу..."); + } + + console.log('[Main.js] Attempting to connect socket...'); + socket.connect(); // Подключаемся здесь, после всей инициализации + console.log('[Main.js] socket.connect() called.'); +}); \ No newline at end of file diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..3e69e9a --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,534 @@ +// /public/js/ui.js +// Этот файл отвечает за обновление DOM на основе состояния игры, +// полученного от client.js (который, в свою очередь, получает его от сервера). + +(function() { + // --- DOM Элементы --- + const uiElements = { + player: { + panel: document.getElementById('player-panel'), + name: document.getElementById('player-name'), + avatar: document.getElementById('player-panel')?.querySelector('.player-avatar'), + hpFill: document.getElementById('player-hp-fill'), hpText: document.getElementById('player-hp-text'), + resourceFill: document.getElementById('player-resource-fill'), resourceText: document.getElementById('player-resource-text'), + status: document.getElementById('player-status'), + effectsContainer: document.getElementById('player-effects'), + buffsList: document.getElementById('player-effects')?.querySelector('.player-buffs'), + debuffsList: document.getElementById('player-effects')?.querySelector('.player-debuffs') + }, + opponent: { + panel: document.getElementById('opponent-panel'), + name: document.getElementById('opponent-name'), + avatar: document.getElementById('opponent-panel')?.querySelector('.opponent-avatar'), + hpFill: document.getElementById('opponent-hp-fill'), hpText: document.getElementById('opponent-hp-text'), + resourceFill: document.getElementById('opponent-resource-fill'), resourceText: document.getElementById('opponent-resource-text'), + status: document.getElementById('opponent-status'), + effectsContainer: document.getElementById('opponent-effects'), + buffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-buffs'), + debuffsList: document.getElementById('opponent-effects')?.querySelector('.opponent-debuffs') + }, + controls: { + turnIndicator: document.getElementById('turn-indicator'), + buttonAttack: document.getElementById('button-attack'), + buttonBlock: document.getElementById('button-block'), + abilitiesGrid: document.getElementById('abilities-grid'), + turnTimerContainer: document.getElementById('turn-timer-container'), + turnTimerSpan: document.getElementById('turn-timer') + }, + log: { + list: document.getElementById('log-list'), + }, + gameOver: { + screen: document.getElementById('game-over-screen'), + message: document.getElementById('result-message'), + returnToMenuButton: document.getElementById('return-to-menu-button'), + modalContent: document.getElementById('game-over-screen')?.querySelector('.modal-content') + }, + gameHeaderTitle: document.querySelector('.game-header h1'), + playerResourceTypeIcon: document.getElementById('player-resource-bar')?.closest('.stat-bar-container')?.querySelector('.bar-icon i'), + opponentResourceTypeIcon: document.getElementById('opponent-resource-bar')?.closest('.stat-bar-container')?.querySelector('.bar-icon i'), + playerResourceBarContainer: document.getElementById('player-resource-bar')?.closest('.stat-bar-container'), + opponentResourceBarContainer: document.getElementById('opponent-resource-bar')?.closest('.stat-bar-container'), + + // === НОВЫЕ ЭЛЕМЕНТЫ для переключателя панелей === + panelSwitcher: { + controlsContainer: document.querySelector('.panel-switcher-controls'), + showPlayerBtn: document.getElementById('show-player-panel-btn'), + showOpponentBtn: document.getElementById('show-opponent-panel-btn') + }, + battleArenaContainer: document.querySelector('.battle-arena-container') + // === КОНЕЦ НОВЫХ ЭЛЕМЕНТОВ === + }; + + function addToLog(message, type = 'info') { + const logListElement = uiElements.log.list; + if (!logListElement) return; + const li = document.createElement('li'); + li.textContent = message; + const config = window.GAME_CONFIG || {}; + const logTypeClass = config[`LOG_TYPE_${type.toUpperCase()}`] ? `log-${config[`LOG_TYPE_${type.toUpperCase()}`]}` : `log-${type}`; + li.className = logTypeClass; + logListElement.appendChild(li); + requestAnimationFrame(() => { logListElement.scrollTop = logListElement.scrollHeight; }); + } + + function updateFighterPanelUI(panelRole, fighterState, fighterBaseStats, isControlledByThisClient) { + const elements = uiElements[panelRole]; + const config = window.GAME_CONFIG || {}; + + if (!elements || !elements.hpFill || !elements.hpText || !elements.resourceFill || !elements.resourceText || !elements.status || !fighterState || !fighterBaseStats) { + if (elements) { + if(elements.name) elements.name.innerHTML = (panelRole === 'player') ? ' Ожидание данных...' : ' Ожидание игрока...'; + if(elements.hpText) elements.hpText.textContent = 'N/A'; + if(elements.resourceText) elements.resourceText.textContent = 'N/A'; + if(elements.status) elements.status.textContent = 'Неизвестно'; + if(elements.buffsList) elements.buffsList.innerHTML = 'Нет'; + if(elements.debuffsList) elements.debuffsList.innerHTML = 'Нет'; + if(elements.avatar) elements.avatar.src = 'images/default_avatar.png'; + if(panelRole === 'player' && uiElements.playerResourceTypeIcon) uiElements.playerResourceTypeIcon.className = 'fas fa-question'; + if(panelRole === 'opponent' && uiElements.opponentResourceTypeIcon) uiElements.opponentResourceTypeIcon.className = 'fas fa-question'; + if(panelRole === 'player' && uiElements.playerResourceBarContainer) uiElements.playerResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); + if(panelRole === 'opponent' && uiElements.opponentResourceBarContainer) uiElements.opponentResourceBarContainer.classList.remove('mana', 'stamina', 'dark-energy'); + if(elements.panel) elements.panel.style.opacity = '0.5'; + } + return; + } + if (elements.panel) elements.panel.style.opacity = '1'; + + if (elements.name) { + let iconClass = 'fa-question'; + const characterKey = fighterBaseStats.characterKey; + if (characterKey === 'elena') { iconClass = 'fa-hat-wizard icon-elena'; } + else if (characterKey === 'almagest') { iconClass = 'fa-staff-aesculapius icon-almagest'; } + else if (characterKey === 'balard') { iconClass = 'fa-khanda icon-balard'; } + let nameHtml = ` ${fighterBaseStats.name || 'Неизвестно'}`; + if (isControlledByThisClient) nameHtml += " (Вы)"; + elements.name.innerHTML = nameHtml; + } + + if (elements.avatar && fighterBaseStats.avatarPath) { + elements.avatar.src = fighterBaseStats.avatarPath; + elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); + elements.avatar.classList.add(`avatar-${fighterBaseStats.characterKey}`); + } else if (elements.avatar) { + elements.avatar.src = 'images/default_avatar.png'; + elements.avatar.classList.remove('avatar-elena', 'avatar-almagest', 'avatar-balard'); + } + + const maxHp = Math.max(1, fighterBaseStats.maxHp); + const maxRes = Math.max(1, fighterBaseStats.maxResource); + const currentHp = Math.max(0, fighterState.currentHp); + const currentRes = Math.max(0, fighterState.currentResource); + elements.hpFill.style.width = `${(currentHp / maxHp) * 100}%`; + elements.hpText.textContent = `${Math.round(currentHp)} / ${fighterBaseStats.maxHp}`; + elements.resourceFill.style.width = `${(currentRes / maxRes) * 100}%`; + elements.resourceText.textContent = `${currentRes} / ${fighterBaseStats.maxResource}`; + + const resourceBarContainerToUpdate = (panelRole === 'player') ? uiElements.playerResourceBarContainer : uiElements.opponentResourceBarContainer; + const resourceIconElementToUpdate = (panelRole === 'player') ? uiElements.playerResourceTypeIcon : uiElements.opponentResourceTypeIcon; + if (resourceBarContainerToUpdate && resourceIconElementToUpdate) { + resourceBarContainerToUpdate.classList.remove('mana', 'stamina', 'dark-energy'); + let resourceClass = 'mana'; let iconClass = 'fa-flask'; + if (fighterBaseStats.resourceName === 'Ярость') { resourceClass = 'stamina'; iconClass = 'fa-fire-alt'; } + else if (fighterBaseStats.resourceName === 'Темная Энергия') { resourceClass = 'dark-energy'; iconClass = 'fa-skull'; } + resourceBarContainerToUpdate.classList.add(resourceClass); + resourceIconElementToUpdate.className = `fas ${iconClass}`; + } + + const statusText = fighterState.isBlocking ? (config.STATUS_BLOCKING || 'Защищается') : (config.STATUS_READY || 'Готов(а)'); + elements.status.textContent = statusText; + elements.status.classList.toggle(config.CSS_CLASS_BLOCKING || 'blocking', fighterState.isBlocking); + + if (elements.panel) { + let borderColorVar = 'var(--panel-border)'; + elements.panel.classList.remove('panel-elena', 'panel-almagest', 'panel-balard'); + if (fighterBaseStats.characterKey === 'elena') { elements.panel.classList.add('panel-elena'); borderColorVar = 'var(--accent-player)'; } + else if (fighterBaseStats.characterKey === 'almagest') { elements.panel.classList.add('panel-almagest'); borderColorVar = 'var(--accent-almagest)'; } + else if (fighterBaseStats.characterKey === 'balard') { elements.panel.classList.add('panel-balard'); borderColorVar = 'var(--accent-opponent)'; } + let glowColorVar = 'rgba(0, 0, 0, 0.4)'; + if (fighterBaseStats.characterKey === 'elena') glowColorVar = 'var(--panel-glow-player)'; + else if (fighterBaseStats.characterKey === 'almagest') glowColorVar = 'var(--panel-glow-almagest)'; + else if (fighterBaseStats.characterKey === 'balard') glowColorVar = 'var(--panel-glow-opponent)'; + elements.panel.style.borderColor = borderColorVar; + elements.panel.style.boxShadow = `0 0 15px ${glowColorVar}, inset 0 0 10px rgba(0, 0, 0, 0.3)`; + } + } + + function generateEffectsHTML(effectsArray) { + const config = window.GAME_CONFIG || {}; + if (!effectsArray || effectsArray.length === 0) return 'Нет'; + return effectsArray.map(eff => { + let effectClasses = config.CSS_CLASS_EFFECT || 'effect'; + const title = `${eff.name}${eff.description ? ` - ${eff.description}` : ''} (Осталось: ${eff.turnsLeft} х.)`; + const displayText = `${eff.name} (${eff.turnsLeft} х.)`; + if (eff.isFullSilence || eff.id.startsWith('playerSilencedOn_') || eff.type === config.ACTION_TYPE_DISABLE) effectClasses += ' effect-stun'; + else if (eff.grantsBlock) effectClasses += ' effect-block'; + else if (eff.type === config.ACTION_TYPE_DEBUFF) effectClasses += ' effect-debuff'; + else if (eff.type === config.ACTION_TYPE_BUFF || eff.type === config.ACTION_TYPE_HEAL) effectClasses += ' effect-buff'; + else effectClasses += ' effect-info'; + return `${displayText}`; + }).join(' '); + } + + function updateEffectsUI(currentGameState) { + if (!currentGameState || !window.GAME_CONFIG) return; + const mySlotId = window.myPlayerId; + const config = window.GAME_CONFIG; + if (!mySlotId) return; + const opponentSlotId = mySlotId === config.PLAYER_ID ? config.OPPONENT_ID : config.PLAYER_ID; + const myState = currentGameState[mySlotId]; + const opponentState = currentGameState[opponentSlotId]; + const typeOrder = { [config.ACTION_TYPE_BUFF]: 1, grantsBlock: 2, [config.ACTION_TYPE_HEAL]: 3, [config.ACTION_TYPE_DEBUFF]: 4, [config.ACTION_TYPE_DISABLE]: 5 }; + const sortEffects = (a, b) => { + let orderA = typeOrder[a.type] || 99; if (a.grantsBlock) orderA = typeOrder.grantsBlock; if (a.isFullSilence || a.id.startsWith('playerSilencedOn_')) orderA = typeOrder[config.ACTION_TYPE_DISABLE]; + let orderB = typeOrder[b.type] || 99; if (b.grantsBlock) orderB = typeOrder.grantsBlock; if (b.isFullSilence || b.id.startsWith('playerSilencedOn_')) orderB = typeOrder[config.ACTION_TYPE_DISABLE]; + return (orderA || 99) - (orderB || 99); + }; + + if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList && myState && myState.activeEffects) { + const myBuffs = []; const myDebuffs = []; + myState.activeEffects.forEach(e => { + const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; + const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('playerSilencedOn_'); + if (isBuff) myBuffs.push(e); else if (isDebuff) myDebuffs.push(e); else myDebuffs.push(e); + }); + myBuffs.sort(sortEffects); myDebuffs.sort(sortEffects); + uiElements.player.buffsList.innerHTML = generateEffectsHTML(myBuffs); + uiElements.player.debuffsList.innerHTML = generateEffectsHTML(myDebuffs); + } else if (uiElements.player && uiElements.player.buffsList && uiElements.player.debuffsList) { + uiElements.player.buffsList.innerHTML = 'Нет'; uiElements.player.debuffsList.innerHTML = 'Нет'; + } + + if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList && opponentState && opponentState.activeEffects) { + const opponentBuffs = []; const opponentDebuffs = []; + opponentState.activeEffects.forEach(e => { + const isBuff = e.type === config.ACTION_TYPE_BUFF || e.grantsBlock || e.type === config.ACTION_TYPE_HEAL; + const isDebuff = e.type === config.ACTION_TYPE_DEBUFF || e.type === config.ACTION_TYPE_DISABLE || e.isFullSilence || e.id.startsWith('effect_'); + if (isBuff) opponentBuffs.push(e); else if (isDebuff) opponentDebuffs.push(e); else opponentDebuffs.push(e); + }); + opponentBuffs.sort(sortEffects); opponentDebuffs.sort(sortEffects); + uiElements.opponent.buffsList.innerHTML = generateEffectsHTML(opponentBuffs); + uiElements.opponent.debuffsList.innerHTML = generateEffectsHTML(opponentDebuffs); + } else if (uiElements.opponent && uiElements.opponent.buffsList && uiElements.opponent.debuffsList) { + uiElements.opponent.buffsList.innerHTML = 'Нет'; uiElements.opponent.debuffsList.innerHTML = 'Нет'; + } + } + + function updateTurnTimerDisplay(remainingTimeMs, isCurrentPlayerActualTurn, gameMode) { + const timerSpan = uiElements.controls.turnTimerSpan; + const timerContainer = uiElements.controls.turnTimerContainer; + + if (!timerSpan || !timerContainer) return; + + if (window.gameState && window.gameState.isGameOver) { + timerContainer.style.display = 'block'; + timerSpan.textContent = 'Конец'; + timerSpan.classList.remove('low-time'); + return; + } + + if (remainingTimeMs === null || remainingTimeMs === undefined) { + timerContainer.style.display = 'block'; + timerSpan.classList.remove('low-time'); + if (gameMode === 'ai' && !isCurrentPlayerActualTurn) { + timerSpan.textContent = 'Ход ИИ'; + } else if (gameMode === 'pvp' && !isCurrentPlayerActualTurn) { + timerSpan.textContent = 'Ход оппонента'; + } else { + timerSpan.textContent = '--'; + } + } else { + timerContainer.style.display = 'block'; + const seconds = Math.ceil(remainingTimeMs / 1000); + timerSpan.textContent = `0:${seconds < 10 ? '0' : ''}${seconds}`; + + if (seconds <= 10 && isCurrentPlayerActualTurn) { + timerSpan.classList.add('low-time'); + } else { + timerSpan.classList.remove('low-time'); + } + } + } + + + function updateUI() { + const currentGameState = window.gameState; + const gameDataGlobal = window.gameData; + const configGlobal = window.GAME_CONFIG; + const myActualPlayerId = window.myPlayerId; + + if (!currentGameState || !gameDataGlobal || !configGlobal || !myActualPlayerId) { + updateFighterPanelUI('player', null, null, true); + updateFighterPanelUI('opponent', null, null, false); + if(uiElements.gameHeaderTitle) uiElements.gameHeaderTitle.innerHTML = `Ожидание данных...`; + if(uiElements.controls.turnIndicator) uiElements.controls.turnIndicator.textContent = "Ожидание данных..."; + if(uiElements.controls.buttonAttack) uiElements.controls.buttonAttack.disabled = true; + if(uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; + if(uiElements.controls.abilitiesGrid) uiElements.controls.abilitiesGrid.innerHTML = '

Загрузка способностей...

'; + if (uiElements.controls.turnTimerContainer) uiElements.controls.turnTimerContainer.style.display = 'none'; + if (uiElements.controls.turnTimerSpan) { + uiElements.controls.turnTimerSpan.textContent = '--'; + uiElements.controls.turnTimerSpan.classList.remove('low-time'); + } + return; + } + if (!uiElements.player.panel || !uiElements.opponent.panel || !uiElements.controls.turnIndicator || !uiElements.controls.abilitiesGrid || !uiElements.log.list) { + console.warn("updateUI: Некоторые базовые uiElements не найдены."); + return; + } + + const actorSlotWhoseTurnItIs = currentGameState.isPlayerTurn ? configGlobal.PLAYER_ID : configGlobal.OPPONENT_ID; + const opponentActualSlotId = myActualPlayerId === configGlobal.PLAYER_ID ? configGlobal.OPPONENT_ID : configGlobal.PLAYER_ID; + const myStateInGameState = currentGameState[myActualPlayerId]; + const myBaseStatsForUI = gameDataGlobal.playerBaseStats; + if (myStateInGameState && myBaseStatsForUI) updateFighterPanelUI('player', myStateInGameState, myBaseStatsForUI, true); + else updateFighterPanelUI('player', null, null, true); + + const opponentStateInGameState = currentGameState[opponentActualSlotId]; + const opponentBaseStatsForUI = gameDataGlobal.opponentBaseStats; + const isOpponentPanelDissolving = uiElements.opponent.panel?.classList.contains('dissolving'); + if (opponentStateInGameState && opponentBaseStatsForUI) { + if (uiElements.opponent.panel && (uiElements.opponent.panel.style.opacity !== '1' || (uiElements.opponent.panel.classList.contains('dissolving') && currentGameState.isGameOver === false) )) { + const panel = uiElements.opponent.panel; + if (panel.classList.contains('dissolving')) { + panel.classList.remove('dissolving'); panel.style.transition = 'none'; panel.offsetHeight; + panel.style.opacity = '1'; panel.style.transform = 'scale(1) translateY(0)'; panel.style.transition = ''; + } else { panel.style.opacity = '1'; panel.style.transform = 'scale(1) translateY(0)'; } + } else if (uiElements.opponent.panel && !isOpponentPanelDissolving) { + uiElements.opponent.panel.style.opacity = '1'; + } + updateFighterPanelUI('opponent', opponentStateInGameState, opponentBaseStatsForUI, false); + } else { + if (!isOpponentPanelDissolving) updateFighterPanelUI('opponent', null, null, false); + else console.log("[UI UPDATE DEBUG] Opponent panel is dissolving, skipping content update."); + } + + updateEffectsUI(currentGameState); + + if (uiElements.gameHeaderTitle && gameDataGlobal.playerBaseStats && gameDataGlobal.opponentBaseStats) { + const myName = gameDataGlobal.playerBaseStats.name; const opponentName = gameDataGlobal.opponentBaseStats.name; + const myKey = gameDataGlobal.playerBaseStats.characterKey; const opponentKey = gameDataGlobal.opponentBaseStats.characterKey; + let myClass = 'title-player'; let opponentClass = 'title-opponent'; + if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; else if (myKey === 'balard') myClass = 'title-knight'; + if (opponentKey === 'elena') opponentClass = 'title-enchantress'; else if (opponentKey === 'almagest') opponentClass = 'title-sorceress'; else if (opponentKey === 'balard') opponentClass = 'title-knight'; + uiElements.gameHeaderTitle.innerHTML = `${myName} ${opponentName}`; + } else if (uiElements.gameHeaderTitle) { + const myName = gameDataGlobal.playerBaseStats?.name || 'Игрок 1'; const myKey = gameDataGlobal.playerBaseStats?.characterKey; + let myClass = 'title-player'; if (myKey === 'elena') myClass = 'title-enchantress'; else if (myKey === 'almagest') myClass = 'title-sorceress'; + uiElements.gameHeaderTitle.innerHTML = `${myName} Ожидание игрока...`; + } + + const canThisClientAct = actorSlotWhoseTurnItIs === myActualPlayerId; + const isGameActive = !currentGameState.isGameOver; + const myCharacterState = currentGameState[myActualPlayerId]; + + if (uiElements.controls.turnIndicator) { + if (isGameActive) { + const currentTurnActor = currentGameState.isPlayerTurn ? currentGameState.player : currentGameState.opponent; + uiElements.controls.turnIndicator.textContent = `Ход ${currentGameState.turnNumber}: ${currentTurnActor?.name || 'Неизвестно'}`; + uiElements.controls.turnIndicator.style.color = (currentTurnActor?.id === myActualPlayerId) ? 'var(--turn-color)' : 'var(--text-muted)'; + } else { + uiElements.controls.turnIndicator.textContent = "Игра окончена"; + uiElements.controls.turnIndicator.style.color = 'var(--text-muted)'; + } + } + + if (uiElements.controls.buttonAttack) { + uiElements.controls.buttonAttack.disabled = !(canThisClientAct && isGameActive); + const myCharKey = gameDataGlobal.playerBaseStats?.characterKey; + let attackBuffId = null; + if (myCharKey === 'elena') attackBuffId = configGlobal.ABILITY_ID_NATURE_STRENGTH; + else if (myCharKey === 'almagest') attackBuffId = configGlobal.ABILITY_ID_ALMAGEST_BUFF_ATTACK; + if (attackBuffId && myCharacterState && myCharacterState.activeEffects) { + const isAttackBuffReady = myCharacterState.activeEffects.some(eff => (eff.id === attackBuffId || eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && eff.isDelayed && eff.turnsLeft > 0 && !eff.justCast); + uiElements.controls.buttonAttack.classList.toggle(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed', isAttackBuffReady && canThisClientAct && isGameActive); + } else { uiElements.controls.buttonAttack.classList.remove(configGlobal.CSS_CLASS_ATTACK_BUFFED || 'attack-buffed'); } + } + if (uiElements.controls.buttonBlock) uiElements.controls.buttonBlock.disabled = true; + + const actingPlayerState = myCharacterState; + const actingPlayerAbilities = gameDataGlobal.playerAbilities; + const actingPlayerResourceName = gameDataGlobal.playerBaseStats?.resourceName; + const opponentStateForDebuffCheck = currentGameState[opponentActualSlotId]; + + uiElements.controls.abilitiesGrid?.querySelectorAll(`.${configGlobal.CSS_CLASS_ABILITY_BUTTON || 'ability-button'}`).forEach(button => { + const abilityId = button.dataset.abilityId; + const abilityDataFromGameData = actingPlayerAbilities?.find(ab => ab.id === abilityId); + if (!(button instanceof HTMLButtonElement) || !isGameActive || !canThisClientAct || !actingPlayerState || !actingPlayerAbilities || !actingPlayerResourceName || !abilityDataFromGameData) { + if (button instanceof HTMLButtonElement) button.disabled = true; + button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); + const cooldownDisplay = button.querySelector('.ability-cooldown-display'); + if (cooldownDisplay) cooldownDisplay.style.display = 'none'; + return; + } + const hasEnoughResource = actingPlayerState.currentResource >= abilityDataFromGameData.cost; + const isOnCooldown = (actingPlayerState.abilityCooldowns?.[abilityId] || 0) > 0; + const isGenerallySilenced = actingPlayerState.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + const isAbilitySpecificallySilenced = actingPlayerState.disabledAbilities?.some(dis => dis.abilityId === abilityId && dis.turnsLeft > 0); + const isSilenced = isGenerallySilenced || isAbilitySpecificallySilenced; + const silenceTurnsLeft = isAbilitySpecificallySilenced ? (actingPlayerState.disabledAbilities?.find(dis => dis.abilityId === abilityId)?.turnsLeft || 0) : (isGenerallySilenced ? (actingPlayerState.activeEffects.find(eff => eff.isFullSilence)?.turnsLeft || 0) : 0); + const isBuffAlreadyActive = abilityDataFromGameData.type === configGlobal.ACTION_TYPE_BUFF && actingPlayerState.activeEffects?.some(eff => eff.id === abilityId); + const isTargetedDebuffAbility = abilityId === configGlobal.ABILITY_ID_SEAL_OF_WEAKNESS || abilityId === configGlobal.ABILITY_ID_ALMAGEST_DEBUFF; + const effectIdForDebuff = 'effect_' + abilityId; + const isDebuffAlreadyOnTarget = isTargetedDebuffAbility && opponentStateForDebuffCheck && opponentStateForDebuffCheck.activeEffects?.some(e => e.id === effectIdForDebuff); + button.disabled = !hasEnoughResource || isBuffAlreadyActive || isSilenced || isOnCooldown || isDebuffAlreadyOnTarget; + button.classList.remove(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced', configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); + const cooldownDisplay = button.querySelector('.ability-cooldown-display'); + if (isOnCooldown) { + button.classList.add(configGlobal.CSS_CLASS_ABILITY_ON_COOLDOWN||'is-on-cooldown'); + if (cooldownDisplay) { cooldownDisplay.textContent = `КД: ${actingPlayerState.abilityCooldowns[abilityId]}`; cooldownDisplay.style.display = 'block'; } + } else if (isSilenced) { + button.classList.add(configGlobal.CSS_CLASS_ABILITY_SILENCED||'is-silenced'); + if (cooldownDisplay) { const icon = isGenerallySilenced ? '🔕' : '🔇'; cooldownDisplay.textContent = `${icon} ${silenceTurnsLeft}`; cooldownDisplay.style.display = 'block'; } + } else { + if (cooldownDisplay) cooldownDisplay.style.display = 'none'; + if (!isOnCooldown && !isSilenced) { + button.classList.toggle(configGlobal.CSS_CLASS_NOT_ENOUGH_RESOURCE||'not-enough-resource', !hasEnoughResource); + button.classList.toggle(configGlobal.CSS_CLASS_BUFF_IS_ACTIVE||'buff-is-active', isBuffAlreadyActive); + } + } + let titleText = `${abilityDataFromGameData.name} (${abilityDataFromGameData.cost} ${actingPlayerResourceName})`; + let descriptionTextFull = abilityDataFromGameData.description; + if (typeof abilityDataFromGameData.descriptionFunction === 'function') { + const opponentBaseStatsForDesc = gameDataGlobal.opponentBaseStats; + descriptionTextFull = abilityDataFromGameData.descriptionFunction(configGlobal, opponentBaseStatsForDesc); + } + if (descriptionTextFull) titleText += ` - ${descriptionTextFull}`; + let abilityBaseCooldown = abilityDataFromGameData.cooldown; + if (typeof abilityBaseCooldown === 'number' && abilityBaseCooldown > 0) titleText += ` (Исходный КД: ${abilityBaseCooldown} х.)`; + if (isOnCooldown) titleText += ` | На перезарядке! Осталось: ${actingPlayerState.abilityCooldowns[abilityId]} х.`; + if (isSilenced) titleText += ` | Под безмолвием! Осталось: ${silenceTurnsLeft} х.`; + if (isBuffAlreadyActive) { + const activeEffect = actingPlayerState.activeEffects?.find(eff => eff.id === abilityId); + const isDelayedBuffReady = isBuffAlreadyActive && activeEffect && activeEffect.isDelayed && !activeEffect.justCast && activeEffect.turnsLeft > 0; + if (isDelayedBuffReady) titleText += ` | Эффект активен и сработает при следующей базовой атаке (${activeEffect.turnsLeft} х.)`; + else if (isBuffAlreadyActive) titleText += ` | Эффект уже активен${activeEffect ? ` (${activeEffect.turnsLeft} х.)` : ''}. Нельзя применить повторно.`; + } + if (isDebuffAlreadyOnTarget && opponentStateForDebuffCheck) { + const activeDebuff = opponentStateForDebuffCheck.activeEffects?.find(e => e.id === 'effect_' + abilityId); + titleText += ` | Эффект уже наложен на ${gameDataGlobal.opponentBaseStats?.name || 'противника'}${activeDebuff ? ` (${activeDebuff.turnsLeft} х.)` : ''}.`; + } + if (!hasEnoughResource) titleText += ` | Недостаточно ${actingPlayerResourceName} (${actingPlayerState.currentResource}/${abilityDataFromGameData.cost})`; + button.setAttribute('title', titleText); + }); + } + + function showGameOver(playerWon, reason = "", opponentCharacterKeyFromClient = null, data = null) { + const config = window.GAME_CONFIG || {}; + const clientSpecificGameData = window.gameData; + const currentActualGameState = window.gameState; + const gameOverScreenElement = uiElements.gameOver.screen; + + if (!gameOverScreenElement) { return; } + + const resultMsgElement = uiElements.gameOver.message; + const myNameForResult = clientSpecificGameData?.playerBaseStats?.name || "Игрок"; + const opponentNameForResult = clientSpecificGameData?.opponentBaseStats?.name || "Противник"; + + if (resultMsgElement) { + let winText = `Победа! ${myNameForResult} празднует!`; + let loseText = `Поражение! ${opponentNameForResult} оказался(лась) сильнее!`; + if (reason === 'opponent_disconnected') { + let disconnectedName = data?.disconnectedCharacterName || opponentNameForResult; + winText = `${disconnectedName} покинул(а) игру. Победа присуждается вам!`; + } else if (reason === 'turn_timeout') { + if (!playerWon) { + loseText = `Время на ход истекло! Поражение. ${opponentNameForResult} побеждает!`; + } else { + winText = `Время на ход у ${opponentNameForResult} истекло! Победа!`; + } + } + resultMsgElement.textContent = playerWon ? winText : loseText; + resultMsgElement.style.color = playerWon ? 'var(--heal-color)' : 'var(--damage-color)'; + } + + const opponentPanelElement = uiElements.opponent.panel; + if (opponentPanelElement) { + opponentPanelElement.classList.remove('dissolving'); + opponentPanelElement.style.transition = 'none'; opponentPanelElement.offsetHeight; + const loserCharacterKeyForDissolve = data?.loserCharacterKey; + if (currentActualGameState && currentActualGameState.isGameOver === true && playerWon) { + if (loserCharacterKeyForDissolve === 'balard' || loserCharacterKeyForDissolve === 'almagest') { + opponentPanelElement.classList.add('dissolving'); + opponentPanelElement.style.opacity = '0'; + } else { + opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; + } + } else { + opponentPanelElement.style.opacity = '1'; opponentPanelElement.style.transform = 'scale(1) translateY(0)'; + } + opponentPanelElement.style.transition = ''; + } + + setTimeout((finalStateInTimeout) => { + if (gameOverScreenElement && finalStateInTimeout && finalStateInTimeout.isGameOver === true) { + if (gameOverScreenElement.classList.contains(config.CSS_CLASS_HIDDEN || 'hidden')) { + gameOverScreenElement.classList.remove(config.CSS_CLASS_HIDDEN || 'hidden'); + } + if(window.getComputedStyle(gameOverScreenElement).display === 'none') gameOverScreenElement.style.display = 'flex'; + gameOverScreenElement.style.opacity = '0'; + requestAnimationFrame(() => { + gameOverScreenElement.style.opacity = '1'; + if (uiElements.gameOver.modalContent) { + uiElements.gameOver.modalContent.style.transition = 'transform 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), opacity 0.4s ease-out'; + uiElements.gameOver.modalContent.style.transform = 'scale(1) translateY(0)'; + uiElements.gameOver.modalContent.style.opacity = '1'; + } + }); + } else { + if (gameOverScreenElement) { + gameOverScreenElement.style.transition = 'none'; + if (uiElements.gameOver.modalContent) uiElements.gameOver.modalContent.style.transition = 'none'; + gameOverScreenElement.classList.add(config.CSS_CLASS_HIDDEN || 'hidden'); + gameOverScreenElement.style.opacity = '0'; + if (uiElements.gameOver.modalContent) { + uiElements.gameOver.modalContent.style.transform = 'scale(0.8) translateY(30px)'; + uiElements.gameOver.modalContent.style.opacity = '0'; + } + gameOverScreenElement.offsetHeight; + } + } + }, config.DELAY_BEFORE_VICTORY_MODAL || 1500, currentActualGameState); + } + + // === НОВАЯ ФУНКЦИЯ для настройки переключателя панелей === + function setupPanelSwitcher() { + const { showPlayerBtn, showOpponentBtn } = uiElements.panelSwitcher; + const battleArena = uiElements.battleArenaContainer; + + if (showPlayerBtn && showOpponentBtn && battleArena) { + showPlayerBtn.addEventListener('click', () => { + battleArena.classList.remove('show-opponent-panel'); + showPlayerBtn.classList.add('active'); + showOpponentBtn.classList.remove('active'); + }); + + showOpponentBtn.addEventListener('click', () => { + battleArena.classList.add('show-opponent-panel'); + showOpponentBtn.classList.add('active'); + showPlayerBtn.classList.remove('active'); + }); + + // По умолчанию при загрузке (если кнопки видимы) панель игрока активна + // CSS уже должен это обеспечивать, но для надежности можно убедиться + if (window.getComputedStyle(uiElements.panelSwitcher.controlsContainer).display !== 'none') { + battleArena.classList.remove('show-opponent-panel'); + showPlayerBtn.classList.add('active'); + showOpponentBtn.classList.remove('active'); + } + } + } + // === КОНЕЦ НОВОЙ ФУНКЦИИ === + + window.gameUI = { + uiElements, + addToLog, + updateUI, + showGameOver, + updateTurnTimerDisplay + }; + + // Настраиваем переключатель панелей при загрузке скрипта + setupPanelSwitcher(); + +})(); \ No newline at end of file diff --git a/public/style_alt.css b/public/style_alt.css new file mode 100644 index 0000000..ba69ff7 --- /dev/null +++ b/public/style_alt.css @@ -0,0 +1,1399 @@ +/* === style_alt.css (Изменения для user-info и game-header) === */ +@import url('https://fonts.googleapis.com/css2?family=MedievalSharp&family=Roboto:wght@300;400;700&display=swap'); +@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'); + +:root { + /* --- Переменные цветов и шрифтов (из локальной версии) --- */ + --font-main: 'Roboto', sans-serif; + --font-fancy: 'MedievalSharp', cursive; + + --bg-gradient-dark: linear-gradient(160deg, #1f243a, #10121c); + --panel-bg: rgba(16, 18, 28, 0.8); + --panel-border: #4a5072; + + --panel-glow-player: rgba(80, 150, 255, 0.3); + --panel-glow-opponent: rgba(255, 80, 80, 0.3); + --panel-glow-almagest: rgba(199, 108, 255, 0.3); + + + --text-light: #e8effc; + --text-muted: #9badce; + --text-heading: #ffffff; + + --accent-player: #6c95ff; + --accent-opponent: #ff6c6c; + --accent-almagest: #c76cff; + + --hp-color: #de4b4b; + --mana-color: #58a8d0; + --stamina-color: #ffb347; + --dark-energy-color: #ab47bc; + --bar-bg: #252a44; + + --button-bg: linear-gradient(145deg, #556190, #3f4a70); + --button-hover-bg: linear-gradient(145deg, #6a79b0, #556190); + --button-text: var(--text-light); + + --button-ability-bg: linear-gradient(145deg, #305a5e, #1f4043); + --button-ability-hover-bg: linear-gradient(145deg, #407a7e, #305a5e); + --button-ability-border: #4db0b5; + + --button-disabled-bg: #333950; + --button-disabled-text: #6b7491; + + --log-bg: rgba(10, 12, 20, 0.85); + --log-border: var(--panel-border); + --log-text: var(--text-muted); + + --icon-color: var(--text-muted); + --damage-color: #ff8080; + --heal-color: #90ee90; + --block-color: #add8e6; + --effect-color: #d8bfd8; + --turn-color: #ffd700; + --system-color: #7fffd4; + + --modal-bg: rgba(16, 18, 28, 0.97); + --modal-content-bg: #2a2f45; + + --scrollbar-thumb: #4a5072; + --scrollbar-track: #10121c; + + --shake-duration: 0.4s; + --cast-duration: 0.6s; + --dissolve-duration: 6.0s; + + --log-panel-fixed-height: 280px; + + --timer-text-color: var(--turn-color); + --timer-icon-color: #b0c4de; + --timer-low-time-color: var(--damage-color); + + /* === Переменные для переключателя панелей (мобильный вид) - ИЗ СЕРВЕРНОЙ ВЕРСИИ === */ + --panel-switcher-bg: rgba(10, 12, 20, 0.9); + --panel-switcher-border: var(--panel-border); + --panel-switcher-button-bg: var(--button-bg); + --panel-switcher-button-text: var(--button-text); + --panel-switcher-button-active-bg: var(--accent-player); + --panel-switcher-button-active-text: #fff; +} + +/* --- Базовые Стили и Сброс --- */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + height: 100%; +} + +body { + font-family: var(--font-main); + background: var(--bg-gradient-dark) fixed; + color: var(--text-light); + line-height: 1.5; + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10px; +} + +h1, h2, h3, h4 { + font-family: var(--font-fancy); + color: var(--text-heading); + margin-bottom: 0.75em; + font-weight: normal; +} + +button { + font-family: var(--font-main); +} + +i { + margin-right: 6px; + color: var(--icon-color); + width: 1.2em; + text-align: center; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +*::-webkit-scrollbar { + width: 8px; +} + +*::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border-radius: 4px; + border: 2px solid var(--scrollbar-track); +} + + +/* === Стили для Экранов Аутентификации и Настройки Игры (из локальной версии) === */ +.auth-game-setup-wrapper { + width: 100%; + max-width: 700px; + margin: 20px auto; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 10px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); + color: var(--text-light); + text-align: center; + max-height: calc(100vh - 40px); + overflow-y: hidden; /* Сохраняем из локальной */ + position: relative; /* <<< Добавлено для позиционирования #user-info (из локальной) */ +} + +.auth-game-setup-wrapper h2, +.auth-game-setup-wrapper h3 { + font-family: var(--font-fancy); + color: var(--text-heading); + margin-bottom: 1em; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 0.5em; +} + +.auth-game-setup-wrapper h3 { + font-size: 1.2em; + margin-top: 1.5em; +} + +.auth-game-setup-wrapper button, +#auth-section form button { + font-family: var(--font-main); + background: var(--button-bg); + color: var(--button-text); + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 6px; + padding: 10px 18px; + margin: 8px 5px; + cursor: pointer; + transition: all 0.15s ease; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + outline: none; +} + +.auth-game-setup-wrapper button:hover:enabled, +#auth-section form button:hover:enabled { + background: var(--button-hover-bg); + transform: translateY(-2px) scale(1.02); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.auth-game-setup-wrapper button:active:enabled, +#auth-section form button:active:enabled { + transform: translateY(0px) scale(1); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.auth-game-setup-wrapper button:disabled, +#auth-section form button:disabled { + background: var(--button-disabled-bg) !important; + color: var(--button-disabled-text) !important; + border-color: transparent !important; + cursor: not-allowed !important; + opacity: 0.7; + transform: none !important; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4) !important; + filter: grayscale(50%); +} + +.auth-game-setup-wrapper input[type="text"], +#auth-section input[type="text"], +#auth-section input[type="password"] { + padding: 10px; + border-radius: 5px; + border: 1px solid var(--panel-border); + background-color: var(--bar-bg); + color: var(--text-light); + margin: 5px 5px 10px 5px; + font-size: 0.9em; + width: calc(100% - 22px); + max-width: 300px; + box-sizing: border-box; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.auth-game-setup-wrapper input[type="text"]:focus, +#auth-section input[type="text"]:focus, +#auth-section input[type="password"]:focus { + border-color: var(--accent-player); + box-shadow: 0 0 8px rgba(108, 149, 255, 0.4); +} + +#available-games-list { + margin-top: 20px; + text-align: left; + max-height: 250px; + height: 100px; /* Сохраняем из локальной */ + overflow-y: scroll; + padding: 10px 15px; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid var(--log-border); + border-radius: 6px; +} + +#available-games-list h3 { + margin-top: 0; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px dashed rgba(255, 255, 255, 0.1); +} + +#available-games-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +#available-games-list li { + padding: 10px; + border-bottom: 1px solid rgba(74, 80, 114, 0.5); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9em; +} + +#available-games-list li:last-child { + border-bottom: none; +} + +#available-games-list li button { + padding: 6px 10px; + font-size: 0.8em; + margin-left: 10px; + flex-shrink: 0; +} + +#status-container { + height: 40px; /* Сохраняем из локальной */ +} + +#auth-message, +#game-status-message { + font-weight: bold; + font-size: 1.1em; + padding: 5px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + display: block; + margin-bottom: 5px; + text-align: center; +} + +#auth-message.success { + color: var(--heal-color); +} + +#auth-message.error { + color: var(--damage-color); +} + +#game-status-message { + color: var(--turn-color); +} + + +#auth-section form { + margin-bottom: 20px; +} + +/* === ИЗМЕНЕНИЕ: Стили для #user-info (из локальной версии) === */ +#user-info { + position: absolute; + top: 10px; /* Отступ сверху */ + right: 15px; /* Отступ справа */ + line-height: 1.5; + text-align: right; /* Выравнивание текста и кнопки вправо */ + z-index: 10; /* Чтобы был поверх другого контента в .auth-game-setup-wrapper */ +} + +#user-info p { + margin: 0 10px 0 0; /* Уменьшен нижний отступ */ + font-size: 0.9em; /* Уменьшен шрифт приветствия */ + color: var(--text-muted); + line-height: 2.5; +} + +#user-info p #logged-in-username { + font-weight: bold; + color: var(--text-light); +} + +#user-info div { + display: flex; + flex-direction: row; +} + +#logout-button { + background: linear-gradient(145deg, #6e3c3c, #502626) !important; /* Более темный красный */ + color: #f0d0d0 !important; /* Светло-розовый текст */ + padding: 6px 12px !important; /* Уменьшены паддинги */ + font-size: 0.8em !important; /* Уменьшен шрифт */ + margin: 0 !important; /* Убираем внешние отступы */ + letter-spacing: 0.2px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important; + border: 1px solid #422020 !important; /* Темная рамка */ +} + +#logout-button:hover:enabled { + background: linear-gradient(145deg, #834545, #6e3c3c) !important; + transform: translateY(-1px) !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4) !important; +} +#logout-button i { + margin-right: 4px; /* Уменьшен отступ иконки */ +} +/* === КОНЕЦ ИЗМЕНЕНИЯ === */ + + +.character-selection { + margin-top: 15px; + margin-bottom: 15px; + padding: 15px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 6px; + border: 1px solid rgba(74, 80, 114, 0.5); +} + +.character-selection h4 { + font-size: 1.1em; + color: var(--text-muted); + margin-bottom: 10px; + border: none; + padding: 0; + text-align: center; +} + +.character-selection label { + display: inline-block; + margin: 0 15px; + cursor: pointer; + font-size: 1.05em; + padding: 5px 10px; + border-radius: 4px; + transition: background-color 0.2s ease, color 0.2s ease; + user-select: none; +} + +.character-selection input[type="radio"] { + display: none; +} + +.character-selection input[type="radio"]:checked + label { + color: #fff; + font-weight: bold; +} + +.character-selection input[type="radio"][value="elena"]:checked + label { + background-color: var(--accent-player); + box-shadow: 0 0 8px rgba(108, 149, 255, 0.5); +} + +.character-selection input[type="radio"][value="almagest"]:checked + label { + background-color: var(--accent-almagest); + box-shadow: 0 0 8px rgba(199, 108, 255, 0.5); +} + +.character-selection label:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.character-selection label i { + margin-right: 8px; + vertical-align: middle; +} + +label[for="char-elena"] i { + color: var(--accent-player); +} + +label[for="char-almagest"] i { + color: var(--accent-almagest); +} + +/* --- Основная Структура Игры (.game-wrapper) --- */ +.game-wrapper { + width: 100%; + height: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +/* === ИЗМЕНЕНИЕ: .game-header удален, стили для него больше не нужны (из локальной версии) === */ + +/* Глобальные стили для кнопок переключения панелей - ИЗ СЕРВЕРНОЙ ВЕРСИИ */ +.panel-switcher-controls { + display: none; /* Скрыт по умолчанию для десктопа */ + flex-shrink: 0; + padding: 8px 5px; + background: var(--panel-switcher-bg); + border-bottom: 1px solid var(--panel-switcher-border); + gap: 10px; +} +.panel-switch-button { + flex: 1; + padding: 8px 10px; + font-size: 0.9em; + font-weight: bold; + text-transform: uppercase; + background: var(--panel-switcher-button-bg); + color: var(--panel-switcher-button-text); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s, color 0.2s, transform 0.1s; + display: flex; + align-items: center; + justify-content: center; +} +.panel-switch-button i { margin-right: 8px; } +.panel-switch-button:hover { filter: brightness(1.1); } +.panel-switch-button.active { + background: var(--panel-switcher-button-active-bg); + color: var(--panel-switcher-button-active-text); + box-shadow: 0 0 8px rgba(255,255,255,0.3); +} + +.battle-arena-container { + flex-grow: 1; + display: flex; + gap: 10px; + overflow: hidden; + /* === ИЗМЕНЕНИЕ: Добавляем верхний отступ, если .game-header был убран, а .game-wrapper виден (из локальной версии) === */ + /* margin-top: 10px; /* или padding-top: 10px; на .game-wrapper, если нужно */ + /* === Изменения из серверной для работы переключения панелей === */ + position: relative; + min-height: 0; +} + +.player-column, +.opponent-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + overflow: hidden; +} + +/* Остальные стили панелей, кнопок, лога и т.д. из локальной версии */ +.fighter-panel, +.controls-panel-new, +.battle-log-new { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 8px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.4), inset 0 0 10px rgba(0, 0, 0, 0.3); + padding: 15px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: box-shadow 0.3s ease, border-color 0.3s ease, opacity 0.3s ease-out, transform 0.3s ease-out; +} + +.fighter-panel.panel-elena { + border-color: var(--accent-player); +} +.fighter-panel.panel-almagest { + border-color: var(--accent-almagest); +} +.fighter-panel.panel-balard { + border-color: var(--accent-opponent); +} + + +.panel-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 0; +} + +.fighter-name { + font-size: 1.6em; + margin: 0; + flex-grow: 1; + text-align: left; +} + +.fighter-name .icon-elena { color: var(--accent-player); } +.fighter-name .icon-almagest { color: var(--accent-almagest); } +.fighter-name .icon-balard { color: var(--accent-opponent); } + + +.character-visual { + flex-shrink: 0; + margin-bottom: 0; +} + +.avatar-image { + display: block; + max-width: 50px; + height: auto; + border-radius: 50%; + border: 2px solid var(--panel-border); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); +} + +.avatar-image.avatar-elena { border-color: var(--accent-player); } +.avatar-image.avatar-almagest { border-color: var(--accent-almagest); } +.avatar-image.avatar-balard { border-color: var(--accent-opponent); } + + +.panel-content { + flex-grow: 1; + overflow-y: auto; + padding-right: 5px; + display: flex; + flex-direction: column; + gap: 10px; /* Добавлено из серверной версии для консистентности */ + min-height: 0; + padding-top: 10px; + margin-top: 0; +} + +.stat-bar-container { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.stat-bar-container .bar-icon { + flex-shrink: 0; + font-size: 1.4em; +} +.stat-bar-container.health .bar-icon { color: var(--hp-color); } +.stat-bar-container.mana .bar-icon { color: var(--mana-color); } +.stat-bar-container.stamina .bar-icon { color: var(--stamina-color); } +.stat-bar-container.dark-energy .bar-icon { color: var(--dark-energy-color); } + + +.bar-wrapper { + flex-grow: 1; +} + +.bar { + border-radius: 4px; + height: 20px; + border: 1px solid rgba(0, 0, 0, 0.5); + overflow: hidden; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5); + position: relative; + background-color: var(--bar-bg); +} + +.bar-fill { + display: block; + height: 100%; + border-radius: 3px; + position: relative; + z-index: 2; + transition: width 0.4s ease-out; +} + +.bar-text { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 3; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.75em; + font-weight: bold; + color: #fff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9); + padding: 0 5px; + white-space: nowrap; + pointer-events: none; +} +.health .bar-fill { background-color: var(--hp-color); } +.mana .bar-fill { background-color: var(--mana-color); } +.stamina .bar-fill { background-color: var(--stamina-color); } +.dark-energy .bar-fill { background-color: var(--dark-energy-color); } + + +.status-area { + font-size: 0.9em; + display: flex; + align-items: baseline; + gap: 5px; + flex-shrink: 0; + min-height: 1.5em; +} + +.status-area .icon-status { + font-size: 1em; + flex-shrink: 0; + margin-top: 0.1em; +} + +.status-area strong { + color: var(--text-muted); + font-weight: normal; + flex-shrink: 0; + margin-right: 3px; +} + +.status-area span { + font-weight: bold; +} + +.status-area span.blocking { + color: var(--block-color); + font-style: italic; +} + +.effects-area { + font-size: 0.9em; + display: flex; + flex-direction: column; + gap: 8px; /* Добавлено из серверной версии для консистентности */ + flex-shrink: 0; + min-height: 3em; +} + +.effect-category { + display: flex; + align-items: baseline; + gap: 5px; +} + +.effect-category strong { + color: var(--text-muted); + font-weight: normal; + font-family: var(--font-main); + font-size: 0.9em; + flex-shrink: 0; + margin-right: 3px; +} + +.effect-category .icon-effects-buff, +.effect-category .icon-effects-debuff { + font-size: 1em; + flex-shrink: 0; + margin-top: 0.1em; + width: 1.2em; + text-align: center; +} + +.effect-category .icon-effects-buff { color: var(--heal-color); } +.effect-category .icon-effects-debuff { color: var(--damage-color); } + +.effect-list { + display: inline; + line-height: 1.4; + min-width: 0; + font-weight: bold; +} + +.effect { + display: inline-block; + margin: 2px 3px 2px 0; + padding: 1px 6px; + font-size: 0.8em; + border-radius: 10px; + border: 1px solid; + cursor: default; + font-weight: 600; + background-color: rgba(0, 0, 0, 0.2); + white-space: nowrap; + vertical-align: baseline; +} +.effect-buff { border-color: var(--heal-color); color: var(--heal-color); } +.effect-debuff { border-color: var(--damage-color); color: var(--damage-color); } +.effect-stun { border-color: var(--turn-color); color: var(--turn-color); } +.effect-block { border-color: var(--block-color); color: var(--block-color); } +.effect-info { border-color: var(--text-muted); color: var(--text-muted); } + +.controls-panel-new { + flex-grow: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +#turn-indicator { + flex-shrink: 0; + text-align: center; + font-size: 1.4em; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: color 0.3s ease; +} + +.turn-timer-display { + flex-shrink: 0; + text-align: center; + font-size: 0.9em; + color: var(--timer-text-color); + margin-top: -5px; + margin-bottom: 10px; + padding: 5px; + background-color: rgba(0,0,0,0.15); + border-radius: 4px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.turn-timer-display i { + color: var(--timer-icon-color); + margin-right: 8px; +} + +#turn-timer { + font-weight: bold; + font-size: 1.1em; + min-width: 35px; + display: inline-block; + text-align: left; +} + +#turn-timer.low-time { + color: var(--timer-low-time-color); + animation: pulse-timer-warning 1s infinite ease-in-out; +} + +.controls-layout { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + min-height: 0; +} + +.control-group { + flex-shrink: 0; +} + +.control-group h4 { + font-size: 0.9em; + color: var(--text-muted); + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px dashed var(--panel-border); + text-transform: uppercase; + letter-spacing: 1px; +} + +.basic-actions { + display: flex; + gap: 10px; +} + +.action-button.basic { + flex: 1; + padding: 8px 5px; + font-size: 0.85em; + font-weight: bold; + background: var(--button-bg); + color: var(--button-text); + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 5px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + outline: none; +} + +.action-button.basic:hover:enabled { + background: var(--button-hover-bg); + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.5); +} + +.action-button.basic:active:enabled { + transform: translateY(0px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +#button-attack.attack-buffed:enabled { + border: 2px solid var(--heal-color); + box-shadow: 0 0 10px 2px rgba(144, 238, 144, 0.6), 0 3px 6px rgba(0, 0, 0, 0.5); + background: linear-gradient(145deg, #70c070, #5a9a5a); + transform: translateY(-1px); +} + + +.ability-list { + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.ability-list h4 { + flex-shrink: 0; +} + +.abilities-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(75px, 1fr)); + gap: 8px; + padding: 8px; + padding-bottom: 12px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.3); + flex-grow: 1; + position: relative; +} + +.abilities-grid::after { + content: ''; + display: block; + height: 10px; + width: 100%; +} + +.abilities-grid .placeholder-text { + grid-column: 1 / -1; + text-align: center; + color: var(--text-muted); + align-self: center; + font-size: 0.9em; + padding: 15px 0; +} + +.ability-button { + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 5px; + border-radius: 6px; + background: var(--button-ability-bg); + border: 1px solid var(--button-ability-border); + color: #fff; + text-align: center; + line-height: 1.15; + cursor: pointer; + transition: all 0.2s ease-out; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6); + position: relative; + overflow: hidden; + outline: none; +} + +.ability-button .ability-name { + font-size: 0.75em; + font-weight: bold; + margin-bottom: 2px; + display: block; + width: 95%; +} + +.ability-button .ability-desc { + font-size: 0.65em; + font-weight: normal; + color: #aaccce; + opacity: 0.8; + text-shadow: none; + max-height: 2em; + overflow: hidden; + width: 95%; + display: block; + margin-top: auto; +} + +.ability-button:hover:enabled { + transform: scale(1.03) translateY(-1px); + background: var(--button-ability-hover-bg); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5), 0 0 8px rgba(77, 176, 181, 0.4); + border-color: #77d9dd; +} + +.ability-button:active:enabled { + transform: scale(1) translateY(0); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.3); + filter: brightness(0.9); +} + +.ability-button:disabled, +.action-button.basic:disabled { + background: var(--button-disabled-bg) !important; + border-color: transparent !important; + color: var(--button-disabled-text) !important; + cursor: not-allowed !important; + transform: none !important; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4) !important; + opacity: 0.7; + text-shadow: none !important; + filter: grayscale(50%); +} + +.ability-button.not-enough-resource { + border: 2px dashed var(--damage-color); + animation: pulse-red-border 1s infinite ease-in-out; +} +.ability-button.not-enough-resource:disabled { + border-color: var(--damage-color); + box-shadow: inset 0 0 8px rgba(255, 80, 80, 0.2), 0 3px 6px rgba(0, 0, 0, 0.2), inset 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.ability-button.buff-is-active { + border: 2px solid var(--heal-color); + box-shadow: 0 0 8px rgba(144, 238, 144, 0.5); +} + +.ability-button.buff-is-active:disabled { + border-color: var(--heal-color); +} + +.ability-button.is-on-cooldown, +.ability-button.is-silenced { + filter: grayscale(70%) brightness(0.8); +} + +.ability-button.is-on-cooldown:disabled, +.ability-button.is-silenced:disabled { + filter: grayscale(70%) brightness(0.7); +} + +.ability-button.is-on-cooldown .ability-name, +.ability-button.is-silenced .ability-name, +.ability-button.is-on-cooldown .ability-desc, +.ability-button.is-silenced .ability-desc { + opacity: 0.6; +} + +.ability-button.is-on-cooldown .ability-desc, +.ability-button.is-silenced .ability-desc { + display: none; +} + +.ability-cooldown-display { + position: absolute; + bottom: 5px; + left: 0; + width: 100%; + text-align: center; + font-size: 0.75em; + font-weight: bold; + color: var(--turn-color); + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.7); + pointer-events: none; + display: none; + line-height: 1; +} + +.ability-button.is-on-cooldown .ability-cooldown-display, +.ability-button.is-silenced .ability-cooldown-display { + display: block !important; +} + + +.battle-log-new { + height: var(--log-panel-fixed-height); + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.battle-log-new h3 { + flex-shrink: 0; + font-size: 1.4em; + margin-bottom: 10px; + text-align: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 8px; +} + +#log-list { + list-style: none; + flex-grow: 1; + overflow-y: auto; + background-color: var(--log-bg); + border: 1px solid var(--log-border); + font-size: 0.85em; + border-radius: 6px; + color: var(--log-text); + padding: 10px; + min-height: 0; + word-wrap: break-word; +} + +#log-list li { + padding: 4px 8px; + border-bottom: 1px solid rgba(74, 80, 114, 0.5); + line-height: 1.35; + transition: background-color 0.3s; +} + +#log-list li:last-child { + border-bottom: none; +} + +#log-list li:hover { + background-color: rgba(255, 255, 255, 0.03); +} +.log-damage { color: var(--damage-color); font-weight: 500; } +.log-heal { color: var(--heal-color); font-weight: 500; } +.log-block { color: var(--block-color); font-style: italic; } +.log-info { color: #b0c4de; } +.log-turn { + font-weight: bold; + color: var(--turn-color); + margin-top: 6px; + border-top: 1px solid rgba(255, 215, 0, 0.3); + padding-top: 6px; + font-size: 1.05em; + display: block; +} +.log-system { + font-weight: bold; + color: var(--system-color); + font-style: italic; + opacity: 0.8; +} +.log-effect { + font-style: italic; + color: var(--effect-color); +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--modal-bg); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(4px) brightness(0.7); + opacity: 0; + pointer-events: none; + transition: opacity 0.4s ease-out; +} + +.modal.hidden { + display: none !important; +} + +.modal:not(.hidden) { + opacity: 1; + pointer-events: auto; +} + +.modal-content { + background: var(--modal-content-bg); + padding: 40px 50px; + border-radius: 10px; + text-align: center; + border: 1px solid var(--panel-border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); + color: var(--text-light); + transform: scale(0.8) translateY(30px); + opacity: 0; + transition: transform 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), opacity 0.4s ease-out; +} + +.modal:not(.hidden) .modal-content { + transform: scale(1) translateY(0); + opacity: 1; +} + +.modal-content h2#result-message { + margin-bottom: 25px; + font-family: var(--font-fancy); + font-size: 2.5em; + line-height: 1.2; +} + +.modal-action-button { + padding: 12px 30px; + font-size: 1.1em; + cursor: pointer; + background: var(--button-bg); + color: var(--button-text); + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 6px; + margin-top: 20px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.2s ease; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); + outline: none; +} + +.modal-action-button:hover:enabled { + background: var(--button-hover-bg); + transform: scale(1.05) translateY(-1px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); +} + +.modal-action-button:active:enabled { + transform: scale(1) translateY(0); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4); +} + +.modal-action-button:disabled { + background: var(--button-disabled-bg); + color: var(--button-disabled-text); + cursor: not-allowed; + opacity: 0.7; +} + +.modal-action-button i { + margin-right: 8px; +} + +@keyframes pulse-red-border { + 0%, 100% { border-color: var(--damage-color); } + 50% { border-color: #ffb3b3; } +} + +@keyframes pulse-timer-warning { + 0%, 100% { color: var(--timer-low-time-color); transform: scale(1); } + 50% { color: #ff6347; transform: scale(1.05); } +} + +@keyframes flash-effect { + 0%, 100% { + box-shadow: var(--initial-box-shadow, 0 0 15px rgba(0, 0, 0, 0.4), inset 0 0 10px rgba(0, 0, 0, 0.3)); + border-color: var(--initial-border-color, var(--panel-border)); + transform: scale(1); + } + 50% { + box-shadow: 0 0 25px 10px var(--flash-color-outer, rgba(255, 255, 255, 0.7)), + inset 0 0 15px var(--flash-color-inner, rgba(255, 255, 255, 0.4)), + 0 0 15px rgba(0, 0, 0, 0.4); + border-color: var(--flash-border-color, #ffffff); + transform: scale(1.005); + } +} + +[class*="is-casting-"] { + animation: flash-effect var(--cast-duration) ease-out; +} + +#player-panel.is-casting-heal, #opponent-panel.is-casting-heal { + --flash-color-outer: rgba(144, 238, 144, 0.7); --flash-color-inner: rgba(144, 238, 144, 0.4); + --flash-border-color: var(--heal-color); +} +#player-panel.is-casting-fireball, #opponent-panel.is-casting-fireball { + --flash-color-outer: rgba(255, 100, 100, 0.7); --flash-color-inner: rgba(255, 100, 100, 0.4); + --flash-border-color: var(--damage-color); +} +#player-panel.is-casting-shadowBolt, #opponent-panel.is-casting-shadowBolt { + --flash-color-outer: rgba(138, 43, 226, 0.6); --flash-color-inner: rgba(138, 43, 226, 0.3); + --flash-border-color: var(--dark-energy-color); +} + +@keyframes shake-opponent { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-4px) rotate(-0.5deg); } + 20%, 40%, 60%, 80% { transform: translateX(4px) rotate(0.5deg); } +} + +#opponent-panel.is-shaking { + animation: shake-opponent var(--shake-duration) cubic-bezier(.36, .07, .19, .97) both; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + perspective: 1000px; +} + +#opponent-panel.dissolving { + opacity: 0; + transform: scale(0.9) translateY(20px); + transition: opacity var(--dissolve-duration) ease-in, transform var(--dissolve-duration) ease-in; + pointer-events: none; +} + +@keyframes shake-short { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-3px); } + 50% { transform: translateX(3px); } + 75% { transform: translateX(-3px); } +} + +.shake-short { + animation: shake-short 0.3s ease-in-out; +} + +/* --- Отзывчивость (Медиа-запросы) --- */ +@media (max-width: 900px) { + body { + height: auto; min-height: 100vh; /* Из серверной, чтобы обеспечить высоту */ + overflow-y: auto; + padding: 5px 0; font-size: 15px; + justify-content: flex-start; + } + .auth-game-setup-wrapper { + max-height: none; + padding-top: 60px; /* Отступ для #user-info из локальной */ + } + /* === ИЗМЕНЕНИЕ: Адаптация #user-info (из локальной версии) === */ + #user-info { top: 5px; right: 10px; } + #user-info p { font-size: 0.85em; } + #logout-button { padding: 5px 10px !important; font-size: 0.75em !important; } + /* === КОНЕЦ ИЗМЕНЕНИЯ === */ + + .game-wrapper { padding: 5px; gap: 5px; height: auto; min-height: calc(100vh - 10px); width: 100%; } /* min-height и width из серверной */ + /* === ИЗМЕНЕНИЕ: game-header удален (из локальной версии) === */ + + /* Показываем кнопки переключения на мобильных - ИЗ СЕРВЕРНОЙ ВЕРСИИ */ + .panel-switcher-controls { + display: flex; + } + + .battle-arena-container { + /* flex-direction: column; height: auto; overflow: visible; - из локальной версии заменяется логикой ниже */ + gap: 0; /* Убираем отступ между колонками, т.к. они будут накладываться - ИЗ СЕРВЕРНОЙ ВЕРСИИ */ + /* position: relative; overflow: hidden; flex-grow: 1; min-height: 350px; - Эти стили уже есть глобально, но тут подтверждаем */ + } + + /* Стили для колонок при переключении - ИЗ СЕРВЕРНОЙ ВЕРСИИ */ + .player-column, + .opponent-column { + /* width: 100%; height: auto; overflow: visible; - из локальной версии заменяется логикой ниже */ + position: absolute; /* Для наложения */ + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-y: auto; /* Прокрутка содержимого колонки */ + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; + padding: 5px; /* Добавлено для отступов внутри колонок на мобильных */ + gap: 8px; /* Добавлено для отступов между панелями внутри колонок */ + } + + .player-column { transform: translateX(0); opacity: 1; z-index: 10; pointer-events: auto; } + .opponent-column { transform: translateX(100%); opacity: 0; z-index: 5; pointer-events: none; } + + .battle-arena-container.show-opponent-panel .player-column { transform: translateX(-100%); opacity: 0; z-index: 5; pointer-events: none; } + .battle-arena-container.show-opponent-panel .opponent-column { transform: translateX(0); opacity: 1; z-index: 10; pointer-events: auto; } + + + .fighter-panel, .controls-panel-new, .battle-log-new { + min-height: auto; /* Высота по контенту */ + height: auto; + padding: 10px; + flex-grow: 0; /* Локальное */ + flex-shrink: 1; /* Локальное */ + } + .fighter-panel { flex-shrink: 0; } /* Из серверной для panel-switcher */ + .fighter-panel .panel-content { flex-grow: 1; min-height: 0; } /* Из серверной для panel-switcher */ + + .controls-panel-new { min-height: 200px; flex-shrink: 0; } /* flex-shrink из серверной */ + .battle-log-new { height: auto; min-height: 150px; flex-shrink: 0; } /* flex-shrink из серверной */ + + #log-list { max-height: 200px; } + .abilities-grid { max-height: none; overflow-y: visible; padding-bottom: 8px; } + .abilities-grid::after { display: none; } + .ability-list, .controls-layout { overflow: visible; } /* Локальное */ + .fighter-name { font-size: 1.3em; } + .panel-content { margin-top: 10px; /* Локальное, но теперь panel-content изменен для серверного panel-switcher, возможно, не нужно */ } + .stat-bar-container .bar-icon { font-size: 1.2em; } + .bar { height: 18px; } + .effects-area, .effect { font-size: 0.85em; } + #turn-indicator { font-size: 1.2em; margin-bottom: 8px; } + .turn-timer-display { font-size: 0.85em; margin-bottom: 8px; padding: 4px; } + #turn-timer { font-size: 1em; } + .action-button.basic { font-size: 0.8em; padding: 8px 4px; } + .abilities-grid { grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 8px; padding: 8px; } + .ability-button { font-size: 0.75em; padding: 5px; } + .ability-button .ability-name { margin-bottom: 2px; } + .ability-button .ability-desc { font-size: 0.65em; } + .modal-content { padding: 25px 30px; width: 90%; max-width: 400px; } + .modal-content h2#result-message { font-size: 1.8em; } + .modal-action-button { font-size: 1em; padding: 10px 20px; } + #game-setup { max-width: 95%; padding: 15px; } + #game-setup h2 { font-size: 1.6em; } + #game-setup h3 { font-size: 1.1em; } + #game-setup button { padding: 8px 12px; font-size: 0.9em; } + #game-setup input[type="text"] { width: calc(100% - 90px); max-width: 200px; padding: 8px; } + #available-games-list { max-height: 180px; } + .character-selection label { margin: 0 10px; font-size: 1em; } +} + +@media (max-width: 480px) { + body { font-size: 14px; } + /* === ИЗМЕНЕНИЕ: Адаптация #user-info для мобильных (из локальной версии) === */ + .auth-game-setup-wrapper { padding-top: 50px; /* Еще немного места сверху */ } + #user-info { + top: 5px; + right: 5px; + display: flex; /* В одну строку */ + flex-direction: row; + align-items: center; + gap: 8px; + } + #user-info p { margin-bottom: 0; font-size: 0.8em; } + #logout-button { padding: 4px 8px !important; font-size: 0.7em !important; } + #logout-button i { margin-right: 3px; } + /* === КОНЕЦ ИЗМЕНЕНИЯ === */ + + /* Стили для panel-switcher на очень маленьких экранах - ИЗ СЕРВЕРНОЙ ВЕРСИИ */ + .panel-switch-button .button-text { display: none; } /* Скрываем текст, оставляем иконки */ + .panel-switch-button i { margin-right: 0; font-size: 1.2em; } + .panel-switch-button { padding: 6px 8px; } + + /* Локальные изменения */ + .fighter-name { font-size: 1.2em; } + .avatar-image { max-width: 40px; } /* Из серверной, но не противоречит */ + .abilities-grid { grid-template-columns: repeat(auto-fit, minmax(65px, 1fr)); gap: 5px; padding: 5px; padding-bottom: 10px; } + .ability-button { font-size: 0.7em; padding: 4px; } + .ability-button .ability-name { margin-bottom: 1px; } + .ability-button .ability-desc { display: none; } + #log-list { font-size: 0.8em; max-height: 150px; } + .modal-content { padding: 20px; } + .modal-content h2#result-message { font-size: 1.6em; } + .modal-action-button { font-size: 0.9em; padding: 8px 16px; } + .auth-game-setup-wrapper { padding-left: 15px; padding-right: 15px; } + #game-setup { padding: 10px; } + #game-setup h2 { font-size: 1.4em; } + #game-setup button { padding: 7px 10px; font-size: 0.85em; margin: 5px; } + #game-setup input[type="text"], + #game-setup button { display: block; width: 100%; margin-left: 0; margin-right: 0; } + #game-setup input[type="text"] { max-width: none; margin-bottom: 10px; } + #game-setup div>input[type="text"]+button { margin-top: 5px; } + #available-games-list { max-height: 120px; } + #available-games-list li button { font-size: 0.75em; padding: 5px 8px; } + .character-selection { padding: 10px; } + .character-selection label { margin: 0 5px 5px 5px; font-size: 0.9em; display: block; } + .character-selection label i { margin-right: 5px; } + #turn-indicator { font-size: 1.1em; } + .turn-timer-display { font-size: 0.8em; margin-top: -3px; margin-bottom: 6px; } + #turn-timer { font-size: 0.95em; } +} \ No newline at end of file diff --git a/server/auth/authService.js b/server/auth/authService.js new file mode 100644 index 0000000..99325c2 --- /dev/null +++ b/server/auth/authService.js @@ -0,0 +1,153 @@ +// /server/auth/authService.js +const bcrypt = require('bcryptjs'); // Для хеширования паролей +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, token?: string, userId?: number, username?: string } + */ +async function registerUser(username, password) { + console.log(`[AuthService DEBUG] registerUser called with username: "${username}"`); + + if (!username || !password) { + console.warn('[AuthService DEBUG] Validation failed: Username or password empty.'); + return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' }; + } + if (password.length < 6) { + console.warn(`[AuthService DEBUG] Validation failed for "${username}": Password too short.`); + return { success: false, message: 'Пароль должен содержать не менее 6 символов.' }; + } + + try { + // Этап A: Проверка существующего пользователя + console.log(`[AuthService DEBUG] Stage A: Checking if user "${username}" exists...`); + // Предполагаем, что db.query возвращает массив, где первый элемент - это массив строк (результатов) + const [existingUsers] = await db.query('SELECT id FROM users WHERE username = ?', [username]); + console.log(`[AuthService DEBUG] Stage A: existingUsers query result length: ${existingUsers.length}`); + + if (existingUsers.length > 0) { + console.warn(`[AuthService DEBUG] Registration declined for "${username}": Username already taken.`); + return { success: false, message: 'Это имя пользователя уже занято.' }; + } + console.log(`[AuthService DEBUG] Stage A: Username "${username}" is available.`); + + // Этап B: Хеширование пароля + console.log(`[AuthService DEBUG] Stage B: Hashing password for user "${username}"...`); + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + console.log(`[AuthService DEBUG] Stage B: Password for "${username}" hashed successfully.`); + + // Этап C: Сохранение пользователя в БД + console.log(`[AuthService DEBUG] Stage C: Attempting to insert user "${username}" into DB...`); + // Предполагаем, что db.query для INSERT возвращает объект результата с insertId + const [result] = await db.query( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + [username, hashedPassword] + ); + console.log(`[AuthService DEBUG] Stage C: DB insert result for "${username}":`, result); + + if (result && 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: 'Регистрация прошла успешно! Вы вошли в систему.', + token: token, // <<< ВОЗВРАЩАЕМ ТОКЕН + userId: userId, + username: username // Возвращаем и имя пользователя + }; + } else { + console.error(`[AuthService] Ошибка БД при регистрации пользователя "${username}": Запись не была вставлена или insertId отсутствует. Result:`, result); + return { success: false, message: 'Ошибка сервера при регистрации (данные не сохранены). Попробуйте позже.' }; + } + + } catch (error) { + console.error(`[AuthService] КРИТИЧЕСКАЯ ОШИБКА (catch block) при регистрации пользователя "${username}":`, error); + if (error.sqlMessage) { + console.error(`[AuthService] MySQL Error Message: ${error.sqlMessage}`); + console.error(`[AuthService] MySQL Error Code: ${error.code}`); + console.error(`[AuthService] MySQL Errno: ${error.errno}`); + } + return { success: false, message: 'Внутренняя ошибка сервера при регистрации.' }; + } +} + +/** + * Выполняет вход пользователя и генерирует JWT. + * @param {string} username - Имя пользователя. + * @param {string} password - Пароль пользователя. + * @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}"`); + + if (!username || !password) { + console.warn('[AuthService DEBUG] Login validation failed: Username or password empty.'); + return { success: false, message: 'Имя пользователя и пароль не могут быть пустыми.' }; + } + + try { + console.log(`[AuthService DEBUG] Searching for user "${username}" in DB...`); + const [users] = await db.query('SELECT id, username, password_hash FROM users WHERE username = ?', [username]); + console.log(`[AuthService DEBUG] DB query result for user "${username}" (length): ${users.length}`); + + if (users.length === 0) { + console.warn(`[AuthService DEBUG] Login failed: User "${username}" not found.`); + return { success: false, message: 'Неверное имя пользователя или пароль.' }; + } + + const user = users[0]; + console.log(`[AuthService DEBUG] User "${username}" found. ID: ${user.id}. Comparing password...`); + + const passwordMatch = await bcrypt.compare(password, user.password_hash); + console.log(`[AuthService DEBUG] Password comparison result for "${username}": ${passwordMatch}`); + + if (passwordMatch) { + // Генерируем 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 // Возвращаем имя пользователя + }; + } else { + console.warn(`[AuthService DEBUG] Login failed for user "${user.username}": Incorrect password.`); + return { success: false, message: 'Неверное имя пользователя или пароль.' }; + } + + } catch (error) { + console.error(`[AuthService] КРИТИЧЕСКАЯ ОШИБКА (catch block) при входе пользователя "${username}":`, error); + if (error.sqlMessage) { + console.error(`[AuthService] MySQL Error Message: ${error.sqlMessage}`); + console.error(`[AuthService] MySQL Error Code: ${error.code}`); + console.error(`[AuthService] MySQL Errno: ${error.errno}`); + } + return { success: false, message: 'Внутренняя ошибка сервера при входе.' }; + } +} + +module.exports = { + registerUser, + loginUser +}; \ No newline at end of file diff --git a/server/bc.js b/server/bc.js new file mode 100644 index 0000000..5a79ddb --- /dev/null +++ b/server/bc.js @@ -0,0 +1,393 @@ +// /server/bc.js - Главный файл сервера Battle Club + +require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') }); + +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const path = require('path'); +const APP_BASE_PATH = process.env.APP_BASE_PATH || ""; +const jwt = require('jsonwebtoken'); +const cors = require('cors'); +// const cookieParser = require('cookie-parser'); // Раскомментируйте, если решите использовать куки для токена напрямую + +const authService = require('./auth/authService'); +const GameManager = require('./game/GameManager'); +const db = require('./core/db'); // Используется для auth, не для игр в этом варианте +const GAME_CONFIG = require('./core/config'); + +const app = express(); +const server = http.createServer(app); + +// --- НАСТРОЙКА EXPRESS --- +console.log(`[BC.JS CONFIG] Reading environment variables for Express CORS...`); +console.log(`[BC.JS CONFIG] NODE_ENV: ${process.env.NODE_ENV}`); +console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_CLIENT: ${process.env.CORS_ORIGIN_CLIENT}`); +const clientOrigin = process.env.CORS_ORIGIN_CLIENT || (process.env.NODE_ENV === 'development' ? '*' : undefined); +console.log(`[BC.JS CONFIG] Effective clientOrigin for HTTP CORS: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET (CORS will likely fail if not development)'}`); + +if (!clientOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) { + console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_CLIENT is not set for a non-development and non-undefined NODE_ENV. HTTP API requests from browsers might be blocked by CORS."); +} +app.use(cors({ + origin: clientOrigin, + methods: ["GET", "POST"], + credentials: true // Важно для работы с куками, если они используются для токенов +})); + +app.use(express.json()); +// app.use(cookieParser()); // Раскомментируйте, если JWT будет передаваться через httpOnly cookie + +const publicPath = path.join(__dirname, '..', 'public'); +console.log(`[BC.JS CONFIG] Serving static files from: ${publicPath}`); +app.use(express.static(publicPath)); + +// --- НАСТРОЙКА EJS --- +app.set('view engine', 'ejs'); +// Указываем, где лежат шаблоны. Папка 'views' рядом с bc.js (т.е. server/views) +app.set('views', path.join(__dirname, 'views')); +console.log(`[BC.JS CONFIG] EJS view engine configured. Views directory: ${app.get('views')}`); + + +// --- HTTP МАРШРУТЫ --- + +// Главная страница, рендеринг EJS шаблона +app.get('/', (req, res) => { + // Попытка извлечь токен из localStorage (недоступно на сервере напрямую) + // или из cookie (если настроено). + // Для EJS на сервере нам нужно определить состояние пользователя ДО рендеринга. + // Это обычно делается через проверку сессии или токена в cookie. + // Так как ваш клиент хранит токен в localStorage, при первом GET запросе + // на сервер токен не будет доступен в заголовках или куках автоматически. + // + // Вариант 1: Клиент делает AJAX-запрос для проверки токена после загрузки, + // а сервер отдает базовый HTML, который потом обновляется. (Текущий подход с main.js) + // + // Вариант 2: Сервер отдает базовый HTML, и клиент сам решает, что показывать, + // основываясь на токене в localStorage. (Текущий подход с main.js) + // + // Вариант 3: Передавать токен в cookie (httpOnly для безопасности), + // тогда сервер сможет его читать при GET запросе. + // + // Для простоты демонстрации EJS, предположим, что мы хотим передать + // некоторую базовую информацию, а клиентская логика main.js все равно отработает. + // Мы не можем здесь напрямую прочитать localStorage клиента. + + res.render('index', { // Рендерим server/views/index.ejs + title: 'Battle Club RPG', // Передаем заголовок страницы + base_path:APP_BASE_PATH, + // Можно передать базовую структуру HTML, а main.js заполнит остальное + // Либо, если бы токен был в куках, мы могли бы здесь сделать: + // const userData = authService.verifyTokenFromCookie(req.cookies.jwtToken); + // isLoggedIn: !!userData, loggedInUsername: userData ? userData.username : '', ... + }); +}); + + +// --- HTTP МАРШРУТЫ АУТЕНТИФИКАЦИИ --- +app.post('/auth/register', async (req, res) => { + const { username, password } = req.body; + console.log(`[BC HTTP /auth/register] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`); + if (!username || !password) { + console.warn('[BC HTTP /auth/register] Bad request: Username or password missing.'); + return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' }); + } + const result = await authService.registerUser(username, password); + if (result.success) { + console.log(`[BC HTTP /auth/register] Success for "${username}".`); + // Если вы используете куки для токена: + // res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); + res.status(201).json(result); + } else { + console.warn(`[BC HTTP /auth/register] Failed for "${username}": ${result.message}`); + res.status(400).json(result); + } +}); + +app.post('/auth/login', async (req, res) => { + const { username, password } = req.body; + console.log(`[BC HTTP /auth/login] Attempt for username: "${username}" from IP: ${req.ip}. Origin: ${req.headers.origin}`); + if (!username || !password) { + console.warn('[BC HTTP /auth/login] Bad request: Username or password missing.'); + return res.status(400).json({ success: false, message: 'Имя пользователя и пароль обязательны.' }); + } + const result = await authService.loginUser(username, password); + if (result.success) { + console.log(`[BC HTTP /auth/login] Success for "${username}".`); + // Если вы используете куки для токена: + // res.cookie('jwtToken', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); + res.json(result); + } else { + console.warn(`[BC HTTP /auth/login] Failed for "${username}": ${result.message}`); + res.status(401).json(result); + } +}); + +// --- НАСТРОЙКА SOCKET.IO --- +console.log(`[BC.JS CONFIG] Reading environment variables for Socket.IO CORS...`); +console.log(`[BC.JS CONFIG] process.env.CORS_ORIGIN_SOCKET: ${process.env.CORS_ORIGIN_SOCKET}`); +const socketCorsOrigin = process.env.CORS_ORIGIN_SOCKET || (process.env.NODE_ENV === 'development' ? '*' : undefined); +console.log(`[BC.JS CONFIG] Effective socketCorsOrigin for Socket.IO CORS: ${socketCorsOrigin === '*' ? "'*'" : socketCorsOrigin || 'NOT SET (Socket.IO CORS will likely fail if not development)'}`); + +if (!socketCorsOrigin && process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== undefined) { + console.warn("[BC.JS CONFIG WARNING] CORS_ORIGIN_SOCKET is not set for a non-development and non-undefined NODE_ENV. Socket.IO connections from browsers might be blocked by CORS."); +} + +const io = new Server(server, { + path: '/socket.io/', + cors: { + origin: socketCorsOrigin, + methods: ["GET", "POST"], + credentials: true + }, +}); +console.log(`[BC.JS CONFIG] Socket.IO server configured with path: ${io.path()} and effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`); + +const gameManager = new GameManager(io); +const loggedInUsersBySocketId = {}; // Этот объект используется только для логирования в bc.js, основная логика в GameManager + +// --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO --- +io.use(async (socket, next) => { + const token = socket.handshake.auth.token; + const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address; + const originHeader = socket.handshake.headers.origin; + const socketPath = socket.nsp.name; + + console.log(`[BC Socket.IO Middleware] Auth attempt for socket ${socket.id} from IP ${clientIp}. Token ${token ? 'present' : 'absent'}. Origin: ${originHeader}. Path: ${socketPath}`); + + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + socket.userData = { userId: decoded.userId, username: decoded.username }; + console.log(`[BC Socket.IO Middleware] Socket ${socket.id} authenticated for user ${decoded.username} (ID: ${decoded.userId}).`); + return next(); + } catch (err) { + console.warn(`[BC Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}. Proceeding as unauthenticated.`); + // Не вызываем next(new Error(...)) чтобы не отключать сокет сразу, + // а позволить клиенту обработать это (например, показать экран логина). + // Однако, если бы мы хотели строго запретить неаутентифицированные сокеты: + // return next(new Error('Authentication error: Invalid token')); + } + } else { + console.log(`[BC Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`); + } + // Если токена нет или он невалиден, все равно вызываем next() без ошибки, + // чтобы соединение установилось, но socket.userData не будет установлен. + // Логика на стороне сервера должна будет проверять наличие socket.userData. + next(); +}); + +// --- ОБРАБОТЧИКИ СОБЫТИЙ SOCKET.IO --- +io.on('connection', (socket) => { + const clientIp = socket.handshake.headers['x-forwarded-for']?.split(',')[0].trim() || socket.handshake.address; + const originHeader = socket.handshake.headers.origin; + const socketPath = socket.nsp.name; + + if (socket.userData && socket.userData.userId) { + console.log(`[BC Socket.IO Connection] Authenticated user ${socket.userData.username} (ID: ${socket.userData.userId}) connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}`); + loggedInUsersBySocketId[socket.id] = socket.userData; // Для логирования здесь + + if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') { + console.log(`[BC Socket.IO Connection] Sending initial available PvP games list to authenticated user ${socket.userData.username} (Socket: ${socket.id})`); + const availableGames = gameManager.getAvailablePvPGamesListForClient(); + socket.emit('availablePvPGamesList', availableGames); + } else { + console.error("[BC Socket.IO Connection] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found for sending initial list!"); + } + + if (gameManager && typeof gameManager.handleRequestGameState === 'function') { + gameManager.handleRequestGameState(socket, socket.userData.userId); + } else { + console.error("[BC Socket.IO Connection] CRITICAL: gameManager or handleRequestGameState not found for authenticated user!"); + } + } else { + console.log(`[BC Socket.IO Connection] Unauthenticated user connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}.`); + // Неаутентифицированные пользователи не должны иметь доступа к игровым функциям, + // но могут получать базовую информацию, если это предусмотрено. + // Например, список игр, если он публичный (в данном проекте он для залогиненных). + // socket.emit('authRequired', { message: 'Please login to access game features.' }); // Можно отправить такое сообщение + } + + socket.on('logout', () => { // Это событие инициируется клиентом, когда он нажимает "Выйти" + const username = socket.userData?.username || 'UnknownUserOnLogout'; + const userId = socket.userData?.userId; + console.log(`[BC Socket.IO 'logout' event] User: ${username} (ID: ${userId || 'N/A'}, Socket: ${socket.id}). Performing server-side cleanup for logout.`); + + // Здесь важно не просто удалить данные из loggedInUsersBySocketId (это локальный объект для логов), + // а также убедиться, что GameManager корректно обрабатывает выход игрока из игры, если он там был. + // GameManager.handleDisconnect должен вызываться автоматически при socket.disconnect() со стороны клиента. + // Дополнительно, если logout это не просто disconnect, а явное действие: + if (userId && gameManager) { + // Если игрок был в игре, GameManager.handleDisconnect должен был отработать при последующем socket.disconnect(). + // Если нужно специфическое действие для logout перед disconnect: + // gameManager.handleExplicitLogout(userId, socket.id); // (потребовало бы добавить такой метод в GameManager) + } + + if (loggedInUsersBySocketId[socket.id]) { + delete loggedInUsersBySocketId[socket.id]; + } + socket.userData = null; // Очищаем данные пользователя на сокете + // Клиент сам вызовет socket.disconnect() и socket.connect() с новым (null) токеном. + console.log(`[BC Socket.IO 'logout' event] Session data for socket ${socket.id} cleared on server. Client is expected to disconnect and reconnect.`); + }); + + + socket.on('playerSurrender', () => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'playerSurrender'] Denied for unauthenticated socket ${socket.id}.`); + socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы сдаться в игре.' }); + return; + } + const identifier = socket.userData.userId; + const username = socket.userData.username; + console.log(`[BC Socket.IO 'playerSurrender'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`); + if (gameManager && typeof gameManager.handlePlayerSurrender === 'function') { + gameManager.handlePlayerSurrender(identifier); + } else { + console.error("[BC Socket.IO 'playerSurrender'] CRITICAL: gameManager or handlePlayerSurrender method not found!"); + socket.emit('gameError', { message: 'Ошибка сервера при обработке сдачи игры.' }); + } + }); + + socket.on('leaveAiGame', () => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'leaveAiGame'] Denied for unauthenticated socket ${socket.id}.`); + socket.emit('gameError', { message: 'Необходимо войти в систему, чтобы покинуть AI игру.' }); + return; + } + const identifier = socket.userData.userId; + const username = socket.userData.username; + console.log(`[BC Socket.IO 'leaveAiGame'] Request from user ${username} (ID: ${identifier}, Socket: ${socket.id})`); + + if (gameManager && typeof gameManager.handleLeaveAiGame === 'function') { + gameManager.handleLeaveAiGame(identifier, socket); // Передаем сокет, если он нужен для ответа + } else { + console.error("[BC Socket.IO 'leaveAiGame'] CRITICAL: gameManager or handleLeaveAiGame method not found!"); + socket.emit('gameError', { message: 'Ошибка сервера при выходе из AI игры.' }); + } + }); + + socket.on('createGame', (data) => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'createGame'] Denied for unauthenticated socket ${socket.id}.`); + socket.emit('gameError', { message: 'Необходимо войти в систему для создания игры.' }); + return; + } + const identifier = socket.userData.userId; + const mode = data?.mode || 'ai'; + const charKey = data?.characterKey; + console.log(`[BC Socket.IO 'createGame'] Request from ${socket.userData.username} (ID: ${identifier}). Mode: ${mode}, Char: ${charKey}`); + gameManager.createGame(socket, mode, charKey, identifier); + }); + + socket.on('joinGame', (data) => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'joinGame'] Denied for unauthenticated socket ${socket.id}.`); + socket.emit('gameError', { message: 'Необходимо войти для присоединения к PvP игре.' }); + return; + } + const gameId = data?.gameId; + const userId = socket.userData.userId; // Используем userId из socket.userData + const charKey = data?.characterKey; // Клиент может предлагать персонажа при присоединении + console.log(`[BC Socket.IO 'joinGame'] Request from ${socket.userData.username} (ID: ${userId}). GameID: ${gameId}, Char: ${charKey}`); + gameManager.joinGame(socket, gameId, userId, charKey); + }); + + socket.on('findRandomGame', (data) => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'findRandomGame'] Denied for unauthenticated socket ${socket.id}.`); + socket.emit('gameError', { message: 'Необходимо войти для поиска случайной PvP игры.' }); + return; + } + const userId = socket.userData.userId; + const charKey = data?.characterKey; + console.log(`[BC Socket.IO 'findRandomGame'] Request from ${socket.userData.username} (ID: ${userId}). PrefChar: ${charKey}`); + gameManager.findAndJoinRandomPvPGame(socket, charKey, userId); + }); + + socket.on('requestPvPGameList', () => { + // Этот запрос может приходить и от неаутентифицированных, если дизайн это позволяет. + // В текущей логике GameManager, список игр формируется для залогиненных. + // Если не залогинен, можно отправить пустой список или специальное сообщение. + console.log(`[BC Socket.IO 'requestPvPGameList'] Request from socket ${socket.id} (User: ${socket.userData?.username || 'Unauth'}).`); + if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') { + const availableGames = gameManager.getAvailablePvPGamesListForClient(); // GameManager сам решит, что вернуть + socket.emit('availablePvPGamesList', availableGames); + } else { + console.error("[BC Socket.IO 'requestPvPGameList'] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found!"); + socket.emit('availablePvPGamesList', []); + } + }); + + socket.on('requestGameState', () => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'requestGameState'] Denied for unauthenticated socket ${socket.id}.`); + // Важно! Клиент main.js ожидает gameNotFound, чтобы показать экран логина + socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' }); + return; + } + const userId = socket.userData.userId; + console.log(`[BC Socket.IO 'requestGameState'] Request from ${socket.userData.username} (ID: ${userId}, Socket: ${socket.id})`); + gameManager.handleRequestGameState(socket, userId); + }); + + socket.on('playerAction', (actionData) => { + if (!socket.userData?.userId) { + console.warn(`[BC Socket.IO 'playerAction'] Denied for unauthenticated socket ${socket.id}. Action: ${actionData?.actionType}`); + socket.emit('gameError', { message: 'Действие не разрешено: пользователь не аутентифицирован.' }); + return; + } + const identifier = socket.userData.userId; + console.log(`[BC Socket.IO 'playerAction'] Action from ${socket.userData.username} (ID: ${identifier}). Type: ${actionData?.actionType}, Details: ${JSON.stringify(actionData)}`); + gameManager.handlePlayerAction(identifier, actionData); + }); + + socket.on('disconnect', (reason) => { + const identifier = socket.userData?.userId; // Получаем из socket.userData, если был аутентифицирован + const username = socket.userData?.username || loggedInUsersBySocketId[socket.id]?.username || 'UnauthenticatedOrUnknown'; + + console.log(`[BC Socket.IO Disconnect] User ${username} (ID: ${identifier || 'N/A'}, Socket: ${socket.id}) disconnected. Reason: ${reason}.`); + if (identifier && gameManager) { // Если пользователь был аутентифицирован + gameManager.handleDisconnect(socket.id, identifier); + } + if (loggedInUsersBySocketId[socket.id]) { // Очистка из локального объекта для логов + delete loggedInUsersBySocketId[socket.id]; + } + // socket.userData автоматически очищается при дисконнекте самого объекта сокета + }); +}); + +// --- ЗАПУСК СЕРВЕРА --- +const PORT = parseInt(process.env.BC_APP_PORT || '3200', 10); +const HOSTNAME = process.env.BC_APP_HOSTNAME || '127.0.0.1'; + +if (isNaN(PORT)) { + console.error(`[BC Server FATAL] Invalid BC_APP_PORT: "${process.env.BC_APP_PORT}". Expected a number.`); + process.exit(1); +} + +server.listen(PORT, HOSTNAME, () => { + console.log(`[BC Server Startup] Battle Club HTTP Application Server running at http://${HOSTNAME}:${PORT}`); + if (HOSTNAME === '127.0.0.1') { + console.log(`[BC Server Startup] Server is listening on localhost only.`); + } else if (HOSTNAME === '0.0.0.0') { + console.log(`[BC Server Startup] Server is listening on all available network interfaces.`); + } else { + console.log(`[BC Server Startup] Server is listening on a specific interface: ${HOSTNAME}.`); + } + console.log(`[BC Server Startup] Static files served from: ${publicPath}`); + console.log(`[BC.JS Startup] EJS views directory: ${app.get('views')}`); + console.log(`[BC.JS Startup] Socket.IO server effective path: ${io.path()}`); + console.log(`[BC.JS Startup] HTTP API effective CORS origin: ${clientOrigin === '*' ? "'*'" : clientOrigin || 'NOT SET'}`); + console.log(`[BC.JS Startup] Socket.IO effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('[BC Server FATAL UnhandledRejection] Reason:', reason, 'Promise:', promise); + // process.exit(1); // Можно раскомментировать для падения сервера при неперехваченных промисах +}); + +process.on('uncaughtException', (err) => { + console.error('[BC Server FATAL UncaughtException] Error:', err); + process.exit(1); // Критические ошибки должны приводить к перезапуску через process manager +}); \ No newline at end of file diff --git a/server/core/config.js b/server/core/config.js new file mode 100644 index 0000000..267a85e --- /dev/null +++ b/server/core/config.js @@ -0,0 +1,112 @@ +// /server/core/config.js + +const GAME_CONFIG = { + // --- Баланс Игры --- + BLOCK_DAMAGE_REDUCTION: 0.5, // Множитель урона при блоке (0.5 = 50% снижение) + DAMAGE_VARIATION_MIN: 0.9, // Минимальный множитель урона (0.9 = 90%) + DAMAGE_VARIATION_RANGE: 0.2, // Диапазон вариации урона (0.2 = от 90% до 110%) + HEAL_VARIATION_MIN: 0.8, // Минимальный множитель лечения (0.8 = 80%) + HEAL_VARIATION_RANGE: 0.4, // Диапазон вариации лечения (0.4 = от 80% до 120%) + NATURE_STRENGTH_MANA_REGEN: 10, // Количество маны, восстанавливаемое "Силой природы" (и ее аналогом) + + // --- Условия ИИ и Игрока --- + OPPONENT_HEAL_THRESHOLD_PERCENT: 50, // Процент HP Баларда, НИЖЕ которого он будет пытаться лечиться (для AI) + PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT: 60, // Процент HP Баларда, НИЖЕ которого Елена использует "доминирующие" насмешки (для AI/текстов) + PLAYER_HP_BLEED_THRESHOLD_PERCENT: 60, // % HP Елены, НИЖЕ которого Балард предпочитает Кровотечение Безмолвию (для AI) + BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD: 60, // % Маны Елены, ВЫШЕ которого Балард может использовать "Похищение Света" (для AI) + + // --- Способности Баларда (AI) - Конфигурация --- + SILENCE_DURATION: 3, // Длительность Безмолвия в ходах Елены (после хода Баларда) + SILENCE_SUCCESS_RATE: 0.7, // Шанс успеха наложения Безмолвия (70%) + BALARD_SILENCE_ABILITY_COST: 15, // Стоимость "Эха Безмолвия" в Ярости + BALARD_SILENCE_INTERNAL_COOLDOWN: 5, // К-во ходов Баларда КД ПОСЛЕ успешного использования Безмолвия + // BALARD_BLEED_COST: 15, // Если будете добавлять способность Кровотечение + // BALARD_BLEED_POWER: 5, + // BALARD_BLEED_DURATION: 2, + // BALARD_BLEED_COOLDOWN: 3, + + // --- Таймер Хода --- + TURN_DURATION_SECONDS: 60, // Длительность хода в секундах + TURN_DURATION_MS: 60 * 1000, // Длительность хода в миллисекундах + TIMER_UPDATE_INTERVAL_MS: 1000, // Интервал обновления таймера на клиенте (в мс) + RECONNECT_TIMEOUT_MS: 30000, + + // --- Идентификаторы и Типы --- + PLAYER_ID: 'player', // Технический идентификатор для слота 'Игрок 1' + OPPONENT_ID: 'opponent', // Технический идентификатор для слота 'Игрок 2' / 'Противник' + ACTION_TYPE_HEAL: 'heal', + ACTION_TYPE_DAMAGE: 'damage', + ACTION_TYPE_BUFF: 'buff', + ACTION_TYPE_DISABLE: 'disable', // Тип для контроля (безмолвие, стан и т.п.) + ACTION_TYPE_DEBUFF: 'debuff', // Тип для ослаблений, DoT и т.п. + ACTION_TYPE_DRAIN: 'drain', // Тип для Похищения Света + + // --- Строки для UI (могут быть полезны и на сервере для логов) --- + STATUS_READY: 'Готов(а)', // Сделал универсальным + STATUS_BLOCKING: 'Защищается', + + // --- Типы Логов (для CSS классов на клиенте, и для структурирования на сервере) --- + LOG_TYPE_INFO: 'info', + LOG_TYPE_DAMAGE: 'damage', + LOG_TYPE_HEAL: 'heal', + LOG_TYPE_TURN: 'turn', + LOG_TYPE_SYSTEM: 'system', + LOG_TYPE_BLOCK: 'block', + LOG_TYPE_EFFECT: 'effect', + + // --- CSS Классы (в основном для клиента, но константы могут быть полезны для согласованности) --- + CSS_CLASS_BLOCKING: 'blocking', + CSS_CLASS_NOT_ENOUGH_RESOURCE: 'not-enough-resource', + CSS_CLASS_BUFF_IS_ACTIVE: 'buff-is-active', + CSS_CLASS_ATTACK_BUFFED: 'attack-buffed', + CSS_CLASS_SHAKING: 'is-shaking', + CSS_CLASS_CASTING_PREFIX: 'is-casting-', // Например: is-casting-fireball + CSS_CLASS_HIDDEN: 'hidden', + CSS_CLASS_ABILITY_BUTTON: 'ability-button', + CSS_CLASS_ABILITY_SILENCED: 'is-silenced', + CSS_CLASS_ABILITY_ON_COOLDOWN: 'is-on-cooldown', // Для отображения кулдауна + + // --- Задержки (в миллисекундах) --- + // Эти задержки теперь в основном будут управляться сервером при отправке событий или планировании AI ходов + DELAY_OPPONENT_TURN: 1200, // Задержка перед ходом AI + DELAY_AFTER_PLAYER_ACTION: 500, // Сервер может использовать это для паузы перед следующим событием + // DELAY_AFTER_BLOCK: 500, // Менее релевантно для сервера напрямую + DELAY_INIT: 100, // Для клиентской инициализации, если нужна + DELAY_BEFORE_VICTORY_MODAL: 1500, // Для клиента, после получения gameOver + MODAL_TRANSITION_DELAY: 10, // Для анимации модалки на клиенте + + // --- Длительности анимаций (в миллисекундах, в основном для клиента, но сервер может знать для таймингов) --- + ANIMATION_SHAKE_DURATION: 400, + ANIMATION_CAST_DURATION: 600, + ANIMATION_DISSOLVE_DURATION: 6000, // var(--dissolve-duration) из CSS + + // --- Внутренние ID способностей (для удобства в логике, нужны и на сервере, и на клиенте) --- + // Игрока (Елена) + ABILITY_ID_HEAL: 'heal', // Малое Исцеление + ABILITY_ID_FIREBALL: 'fireball', // Огненный Шар + ABILITY_ID_NATURE_STRENGTH: 'naturesStrength', // Сила Природы + ABILITY_ID_DEFENSE_AURA: 'defenseAura', // Аура Защиты + ABILITY_ID_HYPNOTIC_GAZE: 'hypnoticGaze', // Гипнотический взгляд + ABILITY_ID_SEAL_OF_WEAKNESS: 'sealOfWeakness', // Печать Слабости + + // Противника (Балард - AI) + ABILITY_ID_BALARD_HEAL: 'darkPatronage', // Покровительство Тьмы + ABILITY_ID_BALARD_SILENCE: 'echoesOfSilence', // Эхо Безмолвия + ABILITY_ID_BALARD_MANA_DRAIN: 'manaDrainHeal', // Похищение Света + // ABILITY_ID_BALARD_BLEED: 'balardBleed', // Если будете добавлять + + // Противника (Альмагест - PvP - зеркало Елены) + ABILITY_ID_ALMAGEST_HEAL: 'darkHeal', // Темное Восстановление (Аналог heal) + ABILITY_ID_ALMAGEST_DAMAGE: 'shadowBolt', // Теневой Сгусток (Аналог fireball) + ABILITY_ID_ALMAGEST_BUFF_ATTACK: 'shadowEmpowerment', // Усиление Тьмой (Аналог naturesStrength) + ABILITY_ID_ALMAGEST_BUFF_DEFENSE: 'voidShield', // Щит Пустоты (Аналог defenseAura) + ABILITY_ID_ALMAGEST_DISABLE: 'mindShatter', // Раскол Разума (Аналог hypnoticGaze) + ABILITY_ID_ALMAGEST_DEBUFF: 'curseOfDecay', // Проклятие Увядания (Аналог sealOfWeakness) +}; + +// Для использования в Node.js модулях +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = GAME_CONFIG; +} + +// console.log("config.js loaded from server/core/ and GAME_CONFIG object created/exported."); \ No newline at end of file diff --git a/server/core/db.js b/server/core/db.js new file mode 100644 index 0000000..eb9e4b6 --- /dev/null +++ b/server/core/db.js @@ -0,0 +1,94 @@ +// /server/core/db.js +require('dotenv').config({ path: require('node:path').resolve(process.cwd(), '.env') }); // Загружаем переменные из .env в process.env +const mysql = require('mysql2'); // Используем mysql2 для поддержки промисов и улучшенной производительности + +// Конфигурация подключения к вашей базе данных MySQL +// Значения теперь берутся из переменных окружения (файла .env) +const dbConfig = { + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER, // Обязательно должно быть задано в .env + password: process.env.DB_PASSWORD, // Обязательно должно быть задано в .env + database: process.env.DB_NAME, // Обязательно должно быть задано в .env + port: parseInt(process.env.DB_PORT || '3306', 10), // Порт по умолчанию 3306, если не указан + waitForConnections: process.env.DB_WAIT_FOR_CONNECTIONS ? (process.env.DB_WAIT_FOR_CONNECTIONS === 'true') : true, + connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10', 10), + queueLimit: parseInt(process.env.DB_QUEUE_LIMIT || '0', 10) +}; + +// Проверка, что все обязательные переменные окружения для БД заданы +if (!dbConfig.user || !dbConfig.password || !dbConfig.database || !dbConfig.host) { + console.error('[DB FATAL] Не все обязательные переменные окружения для БД заданы!'); + console.error('Убедитесь, что у вас есть файл .env в корне проекта и он содержит как минимум:'); + console.error('DB_HOST, DB_USER, DB_PASSWORD, DB_NAME'); + console.error('Текущие загруженные (некоторые могут быть undefined):'); + console.error(` DB_HOST: ${process.env.DB_HOST}`); + console.error(` DB_USER: ${process.env.DB_USER}`); + console.error(` DB_PASSWORD: ${process.env.DB_PASSWORD ? '****** (задано)' : 'undefined'}`); // Не выводим пароль в лог + console.error(` DB_NAME: ${process.env.DB_NAME}`); + console.error(` DB_PORT: ${process.env.DB_PORT}`); + process.exit(1); // Завершаем приложение, так как без БД оно не сможет работать корректно. +} + +// Создаем пул соединений. +let pool; +try { + pool = mysql.createPool(dbConfig); + console.log('[DB] Пул соединений MySQL успешно создан с конфигурацией из переменных окружения.'); +} catch (error) { + console.error('[DB FATAL] Не удалось создать пул соединений MySQL. Проверьте конфигурацию и переменные окружения. Ошибка:', error); + process.exit(1); +} + +// Обертка для выполнения запросов с использованием промисов из пула +const promisePool = pool.promise(); + +// Проверка соединения (опционально, но полезно для отладки при запуске) +if (promisePool) { + promisePool.getConnection() + .then(connection => { + console.log(`[DB] Успешно подключено к базе данных MySQL (${dbConfig.database}) на ${dbConfig.host}:${dbConfig.port} и получено соединение из пула.`); + connection.release(); + console.log('[DB] Соединение возвращено в пул.'); + }) + .catch(err => { + console.error('[DB] Ошибка при попытке получить соединение из пула или при подключении к MySQL:', err.message); + // Выводим полный объект ошибки для диагностики, если это не просто ошибка конфигурации + if (err.code !== 'ER_ACCESS_DENIED_ERROR' && err.code !== 'ER_BAD_DB_ERROR' && err.code !== 'ECONNREFUSED') { + console.error('[DB] Полные детали ошибки:', err); + } + + if (err.code === 'PROTOCOL_CONNECTION_LOST') { + console.error('[DB] Соединение с БД было потеряно.'); + } else if (err.code === 'ER_CON_COUNT_ERROR') { + console.error('[DB] В БД слишком много соединений.'); + } else if (err.code === 'ECONNREFUSED') { + console.error(`[DB] Соединение с БД было отклонено. Убедитесь, что сервер MySQL запущен и доступен по адресу ${dbConfig.host}:${dbConfig.port}.`); + } else if (err.code === 'ER_ACCESS_DENIED_ERROR') { + console.error(`[DB] Доступ к БД запрещен для пользователя '${dbConfig.user}'. Проверьте имя пользователя и пароль в вашем файле .env.`); + } else if (err.code === 'ER_BAD_DB_ERROR') { + console.error(`[DB] База данных "${dbConfig.database}" не найдена. Убедитесь, что она создана на сервере MySQL и указана верно в .env (DB_NAME).`); + } else { + console.error(`[DB] Неизвестная ошибка подключения к MySQL. Код: ${err.code}`); + } + // process.exit(1); // Раскомментируйте, если хотите падать при ошибке подключения + }); +} else { + console.error('[DB FATAL] promisePool не был создан. Это не должно было случиться.'); + process.exit(1); +} + +// Экспортируем пул с промисами +module.exports = promisePool; + +/* +Пример SQL для создания таблицы пользователей (если ее еще нет): + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +*/ \ No newline at end of file diff --git a/server/core/logger.js b/server/core/logger.js new file mode 100644 index 0000000..1a8485d --- /dev/null +++ b/server/core/logger.js @@ -0,0 +1,93 @@ +// /server/core/logger.js + +/** + * Простой логгер-обертка. + * В будущем можно заменить на более продвинутое решение (Winston, Pino), + * сохранив этот же интерфейс. + */ + +const LOG_LEVELS = { + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + FATAL: 'FATAL' +}; + +// Можно установить минимальный уровень логирования из переменной окружения или конфига +const CURRENT_LOG_LEVEL = process.env.LOG_LEVEL || LOG_LEVELS.INFO; + +function shouldLog(level) { + const levelsOrder = [LOG_LEVELS.DEBUG, LOG_LEVELS.INFO, LOG_LEVELS.WARN, LOG_LEVELS.ERROR, LOG_LEVELS.FATAL]; + return levelsOrder.indexOf(level) >= levelsOrder.indexOf(CURRENT_LOG_LEVEL); +} + +function formatMessage(level, moduleName, message, ...optionalParams) { + const timestamp = new Date().toISOString(); + let formattedMessage = `${timestamp} [${level}]`; + if (moduleName) { + formattedMessage += ` [${moduleName}]`; + } + formattedMessage += `: ${message}`; + + // Обработка дополнительных параметров (например, объектов ошибок) + const paramsString = optionalParams.map(param => { + if (param instanceof Error) { + return `\n${param.stack || param.message}`; + } + if (typeof param === 'object') { + try { + return `\n${JSON.stringify(param, null, 2)}`; + } catch (e) { + return '\n[Unserializable Object]'; + } + } + return param; + }).join(' '); + + return `${formattedMessage}${paramsString ? ' ' + paramsString : ''}`; +} + +const logger = { + debug: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.DEBUG)) { + console.debug(formatMessage(LOG_LEVELS.DEBUG, moduleName, message, ...optionalParams)); + } + }, + info: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.INFO)) { + console.info(formatMessage(LOG_LEVELS.INFO, moduleName, message, ...optionalParams)); + } + }, + warn: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.WARN)) { + console.warn(formatMessage(LOG_LEVELS.WARN, moduleName, message, ...optionalParams)); + } + }, + error: (moduleName, message, ...optionalParams) => { + if (shouldLog(LOG_LEVELS.ERROR)) { + console.error(formatMessage(LOG_LEVELS.ERROR, moduleName, message, ...optionalParams)); + } + }, + fatal: (moduleName, message, ...optionalParams) => { // Fatal обычно означает, что приложение не может продолжать работу + if (shouldLog(LOG_LEVELS.FATAL)) { + console.error(formatMessage(LOG_LEVELS.FATAL, moduleName, message, ...optionalParams)); + // В реальном приложении здесь может быть process.exit(1) после логирования + } + }, + // Generic log function if needed, defaults to INFO + log: (moduleName, message, ...optionalParams) => { + logger.info(moduleName, message, ...optionalParams); + } +}; + +module.exports = logger; + +/* +Пример использования в другом файле: +const logger = require('../core/logger'); // Путь зависит от местоположения + +logger.info('GameManager', 'Новая игра создана', { gameId: '123', mode: 'pvp' }); +logger.error('AuthService', 'Ошибка аутентификации пользователя', new Error('Пароль неверный')); +logger.debug('GameInstance', 'Состояние игрока обновлено:', playerStateObject); +*/ \ No newline at end of file diff --git a/server/data/characterAbilities.js b/server/data/characterAbilities.js new file mode 100644 index 0000000..c90b926 --- /dev/null +++ b/server/data/characterAbilities.js @@ -0,0 +1,178 @@ +// /server/data/characterAbilities.js + +const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/ + +// Способности Игрока (Елена) +const elenaAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_HEAL, + name: 'Малое Исцеление', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 30, + description: 'Восстанавливает ~30 HP' + }, + { + id: GAME_CONFIG.ABILITY_ID_FIREBALL, + name: 'Огненный Шар', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DAMAGE, + power: 25, + description: 'Наносит ~25 урона врагу' + }, + { + id: GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH, + name: 'Сила Природы', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 4, // Общая длительность эффекта + // Описание теперь может использовать configToUse (который будет GAME_CONFIG) + descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} маны при след. атаке. Эффект длится ${4 - 1} хода после применения.`, + isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки, а не сразу + }, + { + id: GAME_CONFIG.ABILITY_ID_DEFENSE_AURA, + name: 'Аура Защиты', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 3, + grantsBlock: true, // Дает эффект блока на время действия + descriptionFunction: (configToUse, opponentBaseStats) => `Снижает урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` + }, + { + id: GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE, + name: 'Гипнотический взгляд', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + effectDuration: 2, // Длительность безмолвия в ходах противника + cooldown: 6, + power: 5, // Урон в ход от взгляда + description: 'Накладывает на противника полное безмолвие на 2 хода и наносит 5 урона каждый его ход. КД: 6 х.' + }, + { + id: GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS, + name: 'Печать Слабости', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DEBUFF, + effectDuration: 3, // Длительность дебаффа + power: 10, // Количество ресурса противника, сжигаемое каждый ход + cooldown: 5, + // Описание теперь может адаптироваться к ресурсу оппонента + descriptionFunction: (configToUse, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.` + } +]; + +// Способности Противника (Балард - AI) +const balardAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_BALARD_HEAL, + name: 'Покровительство Тьмы', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 25, + successRate: 0.60, // Шанс успеха + description: 'Исцеляет ~25 HP с 60% шансом', + // Условие для AI: HP ниже порога + condition: (opSt, plSt, currentGameState, configToUse) => { + return (opSt.currentHp / opSt.maxHp) * 100 < configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT; + } + }, + { + id: GAME_CONFIG.ABILITY_ID_BALARD_SILENCE, + name: 'Эхо Безмолвия', + cost: GAME_CONFIG.BALARD_SILENCE_ABILITY_COST, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + descriptionFunction: (configToUse, opponentBaseStats) => `Шанс ${configToUse.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${configToUse.SILENCE_DURATION} х.`, + condition: (opSt, plSt, currentGameState, configToUse) => { + const hpPercent = (opSt.currentHp / opSt.maxHp) * 100; + const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 || + currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_')); // Проверяем и специфичное, и общее безмолвие на цели + const isElenaFullySilenced = currentGameState?.player.activeEffects?.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + + return hpPercent >= configToUse.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && !isElenaFullySilenced && (opSt.silenceCooldownTurns === undefined || opSt.silenceCooldownTurns <= 0); + }, + successRateFromConfig: 'SILENCE_SUCCESS_RATE', + durationFromConfig: 'SILENCE_DURATION', + internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN' + }, + { + id: GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN, + name: 'Похищение Света', + cost: 10, + type: GAME_CONFIG.ACTION_TYPE_DRAIN, + powerManaDrain: 5, + powerDamage: 5, + powerHealthGainFactor: 1.0, + description: `Вытягивает 5 Маны у Елены, наносит 5 урона и восстанавливает себе здоровье (100% от украденного).`, + condition: (opSt, plSt, currentGameState, configToUse) => { + const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100; + const playerHasHighMana = playerManaPercent > (configToUse.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60); + return playerHasHighMana && (opSt.manaDrainCooldownTurns === undefined || opSt.manaDrainCooldownTurns <= 0); + }, + internalCooldownValue: 3 + } +]; + +// Способности Альмагест (PvP - зеркало Елены) +const almagestAbilities = [ + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL, + name: 'Темное Восстановление', + cost: 20, + type: GAME_CONFIG.ACTION_TYPE_HEAL, + power: 30, + description: 'Поглощает жизненные тени, восстанавливая ~30 HP' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE, + name: 'Теневой Сгусток', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DAMAGE, + power: 25, + description: 'Запускает сгусток чистой тьмы, нанося ~25 урона врагу' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK, + name: 'Усиление Тьмой', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 4, + descriptionFunction: (configToUse, opponentBaseStats) => `Восст. ${configToUse.NATURE_STRENGTH_MANA_REGEN} Темной Энергии при след. атаке. Эффект длится ${4 - 1} хода после применения.`, + isDelayed: true + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE, + name: 'Щит Пустоты', + cost: 15, + type: GAME_CONFIG.ACTION_TYPE_BUFF, + duration: 3, + grantsBlock: true, + descriptionFunction: (configToUse, opponentBaseStats) => `Создает щит, снижающий урон на ${configToUse.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE, + name: 'Раскол Разума', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DISABLE, + effectDuration: 2, + cooldown: 6, + power: 5, + description: 'Вторгается в разум противника, накладывая полное безмолвие на 2 хода и нанося 5 урона каждый его ход. КД: 6 х.' + }, + { + id: GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF, + name: 'Проклятие Увядания', + cost: 30, + type: GAME_CONFIG.ACTION_TYPE_DEBUFF, + effectDuration: 3, + power: 10, + cooldown: 5, + descriptionFunction: (configToUse, oppStats) => `Накладывает проклятие, истощающее 10 ${oppStats ? oppStats.resourceName : 'ресурса'} противника каждый его ход в течение 3 ходов. КД: 5 х.` + } +]; + +module.exports = { + elenaAbilities, + balardAbilities, + almagestAbilities +}; \ No newline at end of file diff --git a/server/data/characterStats.js b/server/data/characterStats.js new file mode 100644 index 0000000..3695bc4 --- /dev/null +++ b/server/data/characterStats.js @@ -0,0 +1,47 @@ +// /server/data/characterStats.js + +const GAME_CONFIG = require('../core/config'); // Путь к конфигу из server/data/ в server/core/ + +// --- Базовые Статы Персонажей --- + +const elenaBaseStats = { + id: GAME_CONFIG.PLAYER_ID, // Технический ID слота (может быть player или opponent в PvP) + characterKey: 'elena', // Уникальный ключ персонажа + name: "Елена", + maxHp: 120, + maxResource: 150, + attackPower: 15, + resourceName: "Мана", + avatarPath: 'images/elena_avatar.webp' // Путь к аватару +}; + +const balardBaseStats = { // Балард (для AI и, возможно, PvP) + id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (обычно opponent) + characterKey: 'balard', // Уникальный ключ персонажа + name: "Балард", + maxHp: 140, + maxResource: 100, + attackPower: 20, + resourceName: "Ярость", + avatarPath: 'images/balard_avatar.jpg' // Путь к аватару +}; + +const almagestBaseStats = { // Альмагест (для PvP) + id: GAME_CONFIG.OPPONENT_ID, // Технический ID слота (может быть player или opponent в PvP) + characterKey: 'almagest', // Уникальный ключ персонажа + name: "Альмагест", + maxHp: 120, // Статы как у Елены для зеркальности + maxResource: 150, + attackPower: 15, + resourceName: "Темная Энергия", + avatarPath: 'images/almagest_avatar.webp' // Путь к аватару +}; + +// Можно добавить других персонажей здесь, если потребуется + +module.exports = { + elenaBaseStats, + balardBaseStats, + almagestBaseStats + // ...и другие персонажи +}; \ No newline at end of file diff --git a/server/data/dataUtils.js b/server/data/dataUtils.js new file mode 100644 index 0000000..e9f0c9b --- /dev/null +++ b/server/data/dataUtils.js @@ -0,0 +1,72 @@ +// /server/data/dataUtils.js + +// Импортируем непосредственно определенные статы и способности +const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats'); +const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities'); +// const { tauntSystem } = require('./taunts'); // Если нужны утилиты для насмешек + +/** + * Получает полный набор данных для персонажа по его ключу. + * Включает базовые статы и список способностей. + * @param {string} characterKey - Ключ персонажа ('elena', 'balard', 'almagest'). + * @returns {{baseStats: object, abilities: Array}|null} Объект с данными или null, если ключ неизвестен. + */ +function getCharacterData(characterKey) { + if (!characterKey) { + console.warn("[DataUtils] getCharacterData_called_with_null_or_undefined_key"); + return null; + } + switch (characterKey.toLowerCase()) { // Приводим к нижнему регистру для надежности + case 'elena': + return { baseStats: elenaBaseStats, abilities: elenaAbilities }; + case 'balard': + return { baseStats: balardBaseStats, abilities: balardAbilities }; + case 'almagest': + return { baseStats: almagestBaseStats, abilities: almagestAbilities }; + default: + console.error(`[DataUtils] getCharacterData: Unknown character key "${characterKey}"`); + return null; + } +} + +/** + * Получает только базовые статы для персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {object|null} Объект базовых статов или null. + */ +function getCharacterBaseStats(characterKey) { + const charData = getCharacterData(characterKey); + return charData ? charData.baseStats : null; +} + +/** + * Получает только список способностей для персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {Array|null} Массив способностей или null. + */ +function getCharacterAbilities(characterKey) { + const charData = getCharacterData(characterKey); + return charData ? charData.abilities : null; +} + +/** + * Получает имя персонажа по его ключу. + * @param {string} characterKey - Ключ персонажа. + * @returns {string|null} Имя персонажа или null. + */ +function getCharacterName(characterKey) { + const baseStats = getCharacterBaseStats(characterKey); + return baseStats ? baseStats.name : null; +} + +// Можно добавить другие утилитарные функции по мере необходимости, +// например, для поиска конкретной способности по ID у персонажа, +// или для получения данных для инициализации gameState и т.д. + +module.exports = { + getCharacterData, + getCharacterBaseStats, + getCharacterAbilities, + getCharacterName + // ...другие экспортируемые утилиты +}; \ No newline at end of file diff --git a/server/data/index.js b/server/data/index.js new file mode 100644 index 0000000..b126ba9 --- /dev/null +++ b/server/data/index.js @@ -0,0 +1,75 @@ +// /server/data/index.js + +// Импортируем отдельные части игровых данных +const { elenaBaseStats, balardBaseStats, almagestBaseStats } = require('./characterStats'); +const { elenaAbilities, balardAbilities, almagestAbilities } = require('./characterAbilities'); +const { tauntSystem } = require('./taunts'); // Предполагается, что taunts.js экспортирует объект tauntSystem + +// Собираем все данные в один объект gameData, +// который будет использоваться в других частях серверной логики (например, gameLogic, GameInstance). +// Эта структура аналогична той, что была в вашем исходном большом файле data.js. +const gameData = { + // Базовые статы персонажей по их ключам для удобного доступа + // (хотя dataUtils.js теперь предоставляет функции для этого, + // можно оставить и такую структуру для обратной совместимости или прямого доступа, если нужно) + baseStats: { + elena: elenaBaseStats, + balard: balardBaseStats, + almagest: almagestBaseStats + }, + + // Способности персонажей по их ключам + abilities: { + elena: elenaAbilities, + balard: balardAbilities, + almagest: almagestAbilities + }, + + // Система насмешек + tauntSystem: tauntSystem, + + + // Если вы хотите сохранить оригинальную структуру вашего предыдущего data.js, + // где были прямые ссылки на playerBaseStats, opponentBaseStats и т.д., + // вы можете добавить их сюда. Однако, с новой структурой dataUtils.js + // это становится менее необходимым, так как dataUtils предоставляет + // функции для получения данных по characterKey. + // Для примера, если бы playerBaseStats всегда был Елена, а opponentBaseStats всегда Балард: + // playerBaseStats: elenaBaseStats, // Обычно Елена + // opponentBaseStats: balardBaseStats, // Обычно Балард (AI) + // almagestBaseStats: almagestBaseStats, // Для Альмагест (PvP) + // playerAbilities: elenaAbilities, + // opponentAbilities: balardAbilities, // Способности Баларда (AI) + // almagestAbilities: almagestAbilities, + + // Рекомендуемый подход: экспортировать данные, сгруппированные по персонажам, + // а для получения данных конкретного "игрока" или "оппонента" в игре + // использовать dataUtils.getCharacterData(characterKey) в GameInstance/GameManager. + // Это более гибко, так как в PvP Елена может быть оппонентом, а Альмагест - игроком. +}; + +// Экспортируем собранный объект gameData +module.exports = gameData; + +/* +Примечание: +В GameInstance, GameManager, gameLogic и других модулях, где раньше был: +const gameData = require('./data'); // или другой путь к старому data.js + +Теперь будет: +const gameData = require('../data'); // или '../data/index.js' - Node.js поймет и так +или +const dataUtils = require('../data/dataUtils'); + +И если вы используете gameData напрямую: +const elenaStats = gameData.baseStats.elena; +const balardAbils = gameData.abilities.balard; + +Если используете dataUtils: +const elenaFullData = dataUtils.getCharacterData('elena'); +const balardAbils = dataUtils.getCharacterAbilities('balard'); + +Выбор зависит от того, насколько гранулированный доступ вам нужен в каждом конкретном месте. +Объект gameData, экспортируемый этим файлом, может быть полезен для gameLogic, +где функции могут ожидать всю структуру данных сразу. +*/ \ No newline at end of file diff --git a/server/data/taunts.js b/server/data/taunts.js new file mode 100644 index 0000000..16dea67 --- /dev/null +++ b/server/data/taunts.js @@ -0,0 +1,118 @@ +// /server/data/taunts.js + +// Предполагается, что GAME_CONFIG будет доступен в контексте, где используются эти насмешки, +// обычно он передается в функции игровой логики (например, serverGameLogic.getRandomTaunt). +// Если вы хотите использовать GAME_CONFIG.ABILITY_ID_... прямо здесь, вам нужно его импортировать: +const GAME_CONFIG = require('../core/config'); // Путь к конфигу + +const tauntSystem = { + elena: { // Насмешки Елены + balard: { // Против Баларда (AI) + // Триггер: Елена использует СВОЮ способность + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине."], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", "Твой разум - в моей власти." ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", "Твоя сила угасает." ] + }, + // Триггер: Противник (Балард) совершает действие + onOpponentAction: { + [GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?" ], + [GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: { // Реакция на "Эхо Безмолвия" Баларда + success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!" ], // Если Балард успешно заглушил Елену + fail: [ "Твой шепот Тьмы слаб против Света Истины!" ] // Если попытка Баларда провалилась + }, + [GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!" ], + // Эти два триггера используются, когда АТАКА ОППОНЕНТА (Баларда) попадает по Елене или блокируется Еленой + attackBlocked: [ "Твои удары тщетны перед щитом Порядка." ], // Елена блокирует атаку Баларда + attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве." ] // Атака Баларда попадает по Елене + }, + // Триггер: Базовая атака Елены + basicAttack: { + // 'merciful' и 'dominating' используются в gameLogic.getRandomTaunt в зависимости от HP Баларда + merciful: [ "Балард, прошу, остановись. Еще не поздно.", "Подумай о том, что потерял." ], + dominating: [ + "Глина не спорит с гончаром, Балард!", + "Ты ИЗБРАЛ эту гниль! Получай возмездие!", + "Самый страшный грех - грех неблагодарности!", + "Я сотру тебя с лика этой земли!" + ], + general: [ // Общие фразы, если специфичные не подходят (например, если PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT не используется) + "Свет покарает тебя, Балард!", + "За все свои деяния ты ответишь!" + ] + }, + // Триггер: Изменение состояния боя + onBattleState: { + start: [ "Балард, есть ли еще путь назад?" ], // Начало AI боя с Балардом + opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу." ] // Балард почти побежден + } + }, + almagest: { // Против Альмагест (PvP) + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Я исцеляюсь Светом, который ты отвергла.", "Жизнь восторжествует над твоей некромантией!", "Мое сияние не померкнет." ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Очищающий огонь для твоей тьмы!", "Почувствуй гнев праведного Света!", "Это пламя ярче твоих теней!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Природа дает мне силу, а тебе - лишь презрение.", "Я черпаю из источника жизни, ты - из могилы." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Мой щит отразит твою злобу.", "Свет - лучшая защита.", "Твои темные чары не пройдут!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри в глаза Истине, колдунья!", "Твои лживые речи умолкнут!", "Хватит прятаться за иллюзиями!" ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя темная сила иссякнет!", "Я ослабляю твою связь с бездной!", "Почувствуй, как тает твоя энергия!" ] + }, + onOpponentAction: { // Реакции Елены на действия Альмагест + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", "Твоя магия несет лишь порчу, даже исцеляя." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Твоя тень лишь царапает, не ранит.", "Слабый удар! Тьма делает тебя немощной." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Черпаешь силы из бездны? Она поглотит и тебя.", "Твое усиление - лишь агония искаженной энергии." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Щит из теней? Он рассыпется прахом!", "Твоя защита иллюзорна, как и твоя сила." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "(Сдавленно) Твои ментальные атаки отвратительны!", "Тьма в моей голове... я вырвусь!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Истощаешь мою силу? Я восстановлю ее Светом!", "Твое проклятие слабо." ], + attackBlocked: [ "Твоя атака разбилась о мой щит Света!", "Предсказуемо и слабо, Альмагест." ], + attackHits: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ] + }, + basicAttack: { + general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ] + }, + onBattleState: { + start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ], + opponentNearDefeat: [ "Твоя тьма иссякает, колдунья!", "Сдавайся, пока Свет не испепелил тебя!", "Конец твоим злодеяниям близок!" ] + } + } + }, + almagest: { // Насмешки Альмагест + elena: { // Против Елены (PvP) + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Я питаюсь слабостью, Елена!", "Тьма дает мне силу!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Почувствуй холод бездны!", "Твой Свет померкнет перед моей тенью!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Силы Бездны со мной!", "Моя тень становится гуще!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Мой щит выкован из самой тьмы!", "Попробуй пробить это, служительница Света!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "Твой разум сломлен!", "Умолкни, Светлая!", "Я владею твоими мыслями!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Твоя сила тает!", "Почувствуй гниль!", "Я истощаю твой Свет!" ] + }, + onOpponentAction: { // Реакции Альмагест на действия Елены + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Исцеляешься? Твои раны слишком глубоки!" ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Жалкое пламя! Мои тени поглотят его!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сила земли? Смешно! Бездну ничто не остановит." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Твой щит из Света не спасет тебя от Тьмы!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "(Сдавленно, затем смех) Попытка управлять моим разумом? Жалко!", "Ты пытаешься заглянуть в Бездну?!" ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Моя энергия вечна, дура!", "Это лишь раздражение!" ], + attackBlocked: [ "Твой блок не спасет тебя вечно, Елена!", "Это лишь задержка." ], + attackHits: [ "Ха! Чувствуешь силу Тьмы?", "Это только начало!", "Слабость!" ] + }, + basicAttack: { + general: [ "Почувствуй мою силу!", "Тени атакуют!", "Я наношу удар!" ] + }, + onBattleState: { + start: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ], + opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ] + } + } + // Можно добавить секцию для Альмагест против Баларда, если такой бой возможен и нужен + // balard: { ... } + } + // Балард пока не имеет своей системы насмешек (он AI и его "реплики" могут быть частью логов его действий) + // Если Балард станет играбельным PvP персонажем, сюда можно будет добавить секцию balard: { elena: {...}, almagest: {...} } +}; + +module.exports = { + tauntSystem +}; \ No newline at end of file diff --git a/server/game/GameManager.js b/server/game/GameManager.js new file mode 100644 index 0000000..f16b30b --- /dev/null +++ b/server/game/GameManager.js @@ -0,0 +1,559 @@ +// /server/game/GameManager.js +const { v4: uuidv4 } = require('uuid'); +const GameInstance = require('./instance/GameInstance'); +const dataUtils = require('../data/dataUtils'); +const GAME_CONFIG = require('../core/config'); + +class GameManager { + constructor(io) { + this.io = io; + this.games = {}; // { gameId: GameInstance } + this.userIdentifierToGameId = {}; // { userId: gameId } + this.pendingPvPGames = []; // Массив gameId ожидающих PvP игр + console.log("[GameManager] Инициализирован."); + } + + _cleanupPreviousPendingGameForUser(identifier, reasonSuffix = 'unknown_cleanup_reason') { + const oldPendingGameId = this.userIdentifierToGameId[identifier]; + if (oldPendingGameId && this.games[oldPendingGameId]) { + const gameToRemove = this.games[oldPendingGameId]; + // Убеждаемся, что это именно ожидающая PvP игра этого пользователя + if (gameToRemove.mode === 'pvp' && + gameToRemove.ownerIdentifier === identifier && // Он владелец + gameToRemove.playerCount === 1 && // В игре только он + this.pendingPvPGames.includes(oldPendingGameId) && // Игра в списке ожидающих + (!gameToRemove.gameState || !gameToRemove.gameState.isGameOver) // И она не завершена + ) { + console.log(`[GameManager._cleanupPreviousPendingGameForUser] Пользователь ${identifier} имеет существующую ожидающую PvP игру ${oldPendingGameId}. Удаление. Причина: ${reasonSuffix}`); + this._cleanupGame(oldPendingGameId, `owner_action_removed_pending_pvp_game_${reasonSuffix}`); + return true; + } + } + return false; + } + + createGame(socket, mode = 'ai', chosenCharacterKey = null, identifier) { + console.log(`[GameManager.createGame] Пользователь: ${identifier} (Socket: ${socket.id}), Режим: ${mode}, Персонаж: ${chosenCharacterKey || 'По умолчанию'}`); + + const existingGameIdForUser = this.userIdentifierToGameId[identifier]; + + if (existingGameIdForUser && this.games[existingGameIdForUser]) { + const existingGame = this.games[existingGameIdForUser]; + if (existingGame.gameState && existingGame.gameState.isGameOver) { + console.warn(`[GameManager.createGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка перед созданием новой.`); + this._cleanupGame(existingGameIdForUser, `stale_finished_on_create_${identifier}`); + } else { + const isHisOwnPendingPvp = existingGame.mode === 'pvp' && + existingGame.ownerIdentifier === identifier && + existingGame.playerCount === 1 && + this.pendingPvPGames.includes(existingGameIdForUser); + + if (!isHisOwnPendingPvp) { + console.warn(`[GameManager.createGame] Пользователь ${identifier} уже в активной игре ${existingGameIdForUser} (режим: ${existingGame.mode}, владелец: ${existingGame.ownerIdentifier}). Невозможно создать новую.`); + socket.emit('gameError', { message: 'Вы уже находитесь в активной игре.' }); + this.handleRequestGameState(socket, identifier); + return; + } + } + } + + this._cleanupPreviousPendingGameForUser(identifier, `creating_new_game_mode_${mode}`); + console.log(`[GameManager.createGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`); + + const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier]; + if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) { + console.error(`[GameManager.createGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Создание отклонено.`); + socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию.' }); + this.handleRequestGameState(socket, identifier); + return; + } + + const gameId = uuidv4(); + console.log(`[GameManager.createGame] Новый GameID: ${gameId}`); + const game = new GameInstance(gameId, this.io, mode, this); + this.games[gameId] = game; + + const charKeyForPlayer = mode === 'ai' ? (chosenCharacterKey || 'elena') : (chosenCharacterKey || 'elena'); + + if (game.addPlayer(socket, charKeyForPlayer, identifier)) { + this.userIdentifierToGameId[identifier] = gameId; + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + const assignedPlayerId = playerInfo?.id; + const actualCharacterKey = playerInfo?.chosenCharacterKey; + + if (!assignedPlayerId || !actualCharacterKey) { + console.error(`[GameManager.createGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier} в игре ${gameId}. Очистка.`); + this._cleanupGame(gameId, 'player_info_missing_after_add_on_create'); + socket.emit('gameError', { message: 'Ошибка сервера при создании роли в игре.' }); + return; + } + + console.log(`[GameManager.createGame] Игрок ${identifier} добавлен в игру ${gameId} как ${assignedPlayerId}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`); + socket.emit('gameCreated', { + gameId: gameId, + mode: mode, + yourPlayerId: assignedPlayerId, + chosenCharacterKey: actualCharacterKey + }); + + if (mode === 'ai') { + if (game.initializeGame()) { + console.log(`[GameManager.createGame] AI игра ${gameId} инициализирована GameManager, запуск...`); + game.startGame(); + } else { + console.error(`[GameManager.createGame] Инициализация AI игры ${gameId} не удалась в GameManager. Очистка.`); + this._cleanupGame(gameId, 'init_fail_ai_create_gm'); + } + } else if (mode === 'pvp') { + if (game.initializeGame()) { + if (!this.pendingPvPGames.includes(gameId)) { + this.pendingPvPGames.push(gameId); + } + socket.emit('waitingForOpponent'); + this.broadcastAvailablePvPGames(); + } else { + console.error(`[GameManager.createGame] Инициализация PvP игры ${gameId} (один игрок) не удалась. Очистка.`); + this._cleanupGame(gameId, 'init_fail_pvp_create_gm_single_player'); + } + } + } else { + console.error(`[GameManager.createGame] game.addPlayer не удалось для ${identifier} в ${gameId}. Очистка.`); + this._cleanupGame(gameId, 'player_add_failed_in_instance_gm_on_create'); + } + } + + joinGame(socket, gameIdToJoin, identifier, chosenCharacterKey = null) { + console.log(`[GameManager.joinGame] Пользователь: ${identifier} (Socket: ${socket.id}) пытается присоединиться к ${gameIdToJoin} с персонажем ${chosenCharacterKey || 'По умолчанию'}`); + const gameToJoin = this.games[gameIdToJoin]; + + if (!gameToJoin) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } + if (gameToJoin.gameState?.isGameOver) { socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this._cleanupGame(gameIdToJoin, `attempt_join_finished_game_${identifier}`); return; } + if (gameToJoin.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться (не PvP).' }); return; } + + const playerInfoInTargetGame = Object.values(gameToJoin.players).find(p => p.identifier === identifier); + if (gameToJoin.playerCount >= 2 && !playerInfoInTargetGame?.isTemporarilyDisconnected) { + socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; + } + // Запрещаем владельцу "присоединяться" к своей ожидающей игре как новый игрок, если он не был временно отключен. + // Если он хочет вернуться, он должен использовать requestGameState. + if (gameToJoin.ownerIdentifier === identifier && !playerInfoInTargetGame?.isTemporarilyDisconnected) { + console.warn(`[GameManager.joinGame] Пользователь ${identifier} пытается присоединиться к своей игре ${gameIdToJoin}, где он владелец и не отключен. Обработка как запрос на переподключение.`); + this.handleRequestGameState(socket, identifier); + return; + } + + const currentActiveGameIdUserIsIn = this.userIdentifierToGameId[identifier]; + if (currentActiveGameIdUserIsIn && this.games[currentActiveGameIdUserIsIn] && this.games[currentActiveGameIdUserIsIn].gameState?.isGameOver) { + console.warn(`[GameManager.joinGame] Пользователь ${identifier} был в завершенной игре ${currentActiveGameIdUserIsIn} при попытке присоединиться к ${gameIdToJoin}. Очистка старой.`); + this._cleanupGame(currentActiveGameIdUserIsIn, `stale_finished_on_join_attempt_${identifier}`); + } + + const stillExistingGameIdForUser = this.userIdentifierToGameId[identifier]; + if (stillExistingGameIdForUser && stillExistingGameIdForUser !== gameIdToJoin && this.games[stillExistingGameIdForUser] && !this.games[stillExistingGameIdForUser].gameState?.isGameOver) { + const usersCurrentGame = this.games[stillExistingGameIdForUser]; + const isHisOwnPendingPvp = usersCurrentGame.mode === 'pvp' && + usersCurrentGame.ownerIdentifier === identifier && + usersCurrentGame.playerCount === 1 && + this.pendingPvPGames.includes(stillExistingGameIdForUser); + + if (isHisOwnPendingPvp) { + console.log(`[GameManager.joinGame] Пользователь ${identifier} является владельцем ожидающей игры ${stillExistingGameIdForUser}, но хочет присоединиться к ${gameIdToJoin}. Очистка старой игры.`); + this._cleanupPreviousPendingGameForUser(identifier, `joining_another_game_${gameIdToJoin}`); + } else { + console.warn(`[GameManager.joinGame] Пользователь ${identifier} находится в другой активной игре ${stillExistingGameIdForUser}. Невозможно присоединиться к ${gameIdToJoin}.`); + socket.emit('gameError', { message: 'Вы уже находитесь в другой активной игре.' }); + this.handleRequestGameState(socket, identifier); + return; + } + } + console.log(`[GameManager.joinGame] После возможной очистки перед присоединением, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`); + + const charKeyForJoin = chosenCharacterKey || 'elena'; + if (gameToJoin.addPlayer(socket, charKeyForJoin, identifier)) { + this.userIdentifierToGameId[identifier] = gameIdToJoin; + const joinedPlayerInfo = Object.values(gameToJoin.players).find(p => p.identifier === identifier); + + if (!joinedPlayerInfo || !joinedPlayerInfo.id || !joinedPlayerInfo.chosenCharacterKey) { + console.error(`[GameManager.joinGame] КРИТИЧЕСКИ: Не удалось получить роль/ключ персонажа после addPlayer для ${identifier}, присоединяющегося к ${gameIdToJoin}.`); + socket.emit('gameError', { message: 'Ошибка сервера при назначении роли в игре.' }); + if (this.userIdentifierToGameId[identifier] === gameIdToJoin) delete this.userIdentifierToGameId[identifier]; + return; + } + console.log(`[GameManager.joinGame] Игрок ${identifier} добавлен/переподключен к ${gameIdToJoin} как ${joinedPlayerInfo.id}. Карта пользователя обновлена. Текущая карта для ${identifier}: ${this.userIdentifierToGameId[identifier]}`); + socket.emit('gameCreated', { + gameId: gameIdToJoin, + mode: gameToJoin.mode, + yourPlayerId: joinedPlayerInfo.id, + chosenCharacterKey: joinedPlayerInfo.chosenCharacterKey + }); + + if (gameToJoin.playerCount === 2) { + console.log(`[GameManager.joinGame] Игра ${gameIdToJoin} теперь заполнена. Инициализация и запуск.`); + // Важно! Инициализация может обновить ключи персонажей, если они были одинаковыми. + if (gameToJoin.initializeGame()) { + gameToJoin.startGame(); + } else { + this._cleanupGame(gameIdToJoin, 'full_init_fail_pvp_join_gm'); return; + } + const idx = this.pendingPvPGames.indexOf(gameIdToJoin); + if (idx > -1) this.pendingPvPGames.splice(idx, 1); + this.broadcastAvailablePvPGames(); + } + } else { + console.warn(`[GameManager.joinGame] gameToJoin.addPlayer вернул false для пользователя ${identifier} в игре ${gameIdToJoin}.`); + } + } + + findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { + console.log(`[GameManager.findRandomPvPGame] Пользователь: ${identifier} (Socket: ${socket.id}), Персонаж для создания: ${chosenCharacterKeyForCreation}`); + + const existingGameIdForUser = this.userIdentifierToGameId[identifier]; + if (existingGameIdForUser && this.games[existingGameIdForUser]) { + const existingGame = this.games[existingGameIdForUser]; + if (existingGame.gameState && existingGame.gameState.isGameOver) { + console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} был в завершенной игре ${existingGameIdForUser}. Очистка.`); + this._cleanupGame(existingGameIdForUser, `stale_finished_on_find_random_${identifier}`); + } else { + console.warn(`[GameManager.findRandomPvPGame] Пользователь ${identifier} уже в активной/ожидающей игре ${existingGameIdForUser}. Невозможно найти случайную.`); + socket.emit('gameError', { message: 'Вы уже в активной или ожидающей игре.' }); + this.handleRequestGameState(socket, identifier); return; + } + } + + this._cleanupPreviousPendingGameForUser(identifier, `finding_random_game`); + console.log(`[GameManager.findRandomPvPGame] После возможной очистки, пользователь ${identifier} сопоставлен с: ${this.userIdentifierToGameId[identifier]}`); + + const stillExistingGameIdAfterCleanup = this.userIdentifierToGameId[identifier]; + if (stillExistingGameIdAfterCleanup && this.games[stillExistingGameIdAfterCleanup] && !this.games[stillExistingGameIdAfterCleanup].gameState?.isGameOver) { + console.error(`[GameManager.findRandomPvPGame] КРИТИЧЕСКАЯ ОШИБКА ЛОГИКИ: Пользователь ${identifier} все еще сопоставлен с активной игрой ${stillExistingGameIdAfterCleanup} после попытки очистки. Поиск случайной игры отклонен.`); + socket.emit('gameError', { message: 'Ошибка: не удалось освободить предыдущую игровую сессию для поиска.' }); + this.handleRequestGameState(socket, identifier); + return; + } + + let gameIdToJoin = null; + for (const id of [...this.pendingPvPGames]) { + const pendingGame = this.games[id]; + if (pendingGame && pendingGame.mode === 'pvp' && + pendingGame.playerCount === 1 && + pendingGame.ownerIdentifier !== identifier && + (!pendingGame.gameState || !pendingGame.gameState.isGameOver)) { + gameIdToJoin = id; break; + } else if (!pendingGame || (pendingGame?.gameState && pendingGame.gameState.isGameOver)) { + console.warn(`[GameManager.findRandomPvPGame] Найдена устаревшая/завершенная ожидающая игра ${id}. Очистка.`); + this._cleanupGame(id, `stale_finished_pending_on_find_random`); + } + } + + if (gameIdToJoin) { + console.log(`[GameManager.findRandomPvPGame] Найдена ожидающая игра ${gameIdToJoin} для ${identifier}. Присоединение...`); + const randomJoinCharKey = ['elena', 'almagest', 'balard'][Math.floor(Math.random() * 3)]; + this.joinGame(socket, gameIdToJoin, identifier, randomJoinCharKey); + } else { + console.log(`[GameManager.findRandomPvPGame] Подходящая ожидающая игра не найдена. Создание новой PvP игры для ${identifier}.`); + this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); + } + } + + handlePlayerAction(identifier, actionData) { + const gameId = this.userIdentifierToGameId[identifier]; + const game = this.games[gameId]; + if (game) { + if (game.gameState?.isGameOver) { + const playerSocket = Object.values(game.players).find(p => p.identifier === identifier)?.socket; + if (playerSocket) { + console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, но игра завершена. Запрос состояния.`); + this.handleRequestGameState(playerSocket, identifier); + } else { + console.warn(`[GameManager.handlePlayerAction] Действие от ${identifier} для игры ${gameId}, игра завершена, но сокет для пользователя не найден.`); + this._cleanupGame(gameId, `action_on_over_no_socket_gm_${identifier}`); + } + return; + } + game.processPlayerAction(identifier, actionData); + } else { + console.warn(`[GameManager.handlePlayerAction] Игра для пользователя ${identifier} не найдена (сопоставлена с игрой ${gameId}). Очистка записи в карте.`); + delete this.userIdentifierToGameId[identifier]; + const clientSocket = this._findClientSocketByIdentifier(identifier); + if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена при совершении действия.' }); + } + } + + handlePlayerSurrender(identifier) { + const gameId = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handlePlayerSurrender] Пользователь: ${identifier} сдался. GameID из карты: ${gameId}`); + const game = this.games[gameId]; + if (game) { + if (game.gameState?.isGameOver) { + console.warn(`[GameManager.handlePlayerSurrender] Пользователь ${identifier} в игре ${gameId} сдается, но игра УЖЕ ЗАВЕРШЕНА.`); + return; + } + if (typeof game.playerDidSurrender === 'function') game.playerDidSurrender(identifier); + else { console.error(`[GameManager.handlePlayerSurrender] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method_gm"); } + } else { + console.warn(`[GameManager.handlePlayerSurrender] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`); + if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; + } + } + + handleLeaveAiGame(identifier) { + const gameId = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handleLeaveAiGame] Пользователь: ${identifier} покидает AI игру. GameID из карты: ${gameId}`); + const game = this.games[gameId]; + if (game) { + if (game.gameState?.isGameOver) { + console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} в игре ${gameId} выходит, но игра УЖЕ ЗАВЕРШЕНА.`); + return; + } + if (game.mode === 'ai') { + if (typeof game.playerExplicitlyLeftAiGame === 'function') { + game.playerExplicitlyLeftAiGame(identifier); + } else { + console.error(`[GameManager.handleLeaveAiGame] КРИТИЧЕСКИ: GameInstance ${gameId} отсутствует playerExplicitlyLeftAiGame! Прямая очистка.`); + this._cleanupGame(gameId, "leave_ai_missing_method_gm"); + } + } else { + console.warn(`[GameManager.handleLeaveAiGame] Пользователь ${identifier} отправил leaveAiGame, но игра ${gameId} не в режиме AI (${game.mode}).`); + const clientSocket = this._findClientSocketByIdentifier(identifier); + if(clientSocket) clientSocket.emit('gameError', { message: 'Вы не в AI игре.' }); + } + } else { + console.warn(`[GameManager.handleLeaveAiGame] Игра для пользователя ${identifier} не найдена. Очистка записи в карте.`); + if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier]; + const clientSocket = this._findClientSocketByIdentifier(identifier); + if(clientSocket) clientSocket.emit('gameNotFound', { message: 'AI игра не найдена для выхода.' }); + } + } + + _findClientSocketByIdentifier(identifier) { + for (const s of this.io.sockets.sockets.values()) { + if (s && s.userData && s.userData.userId === identifier && s.connected) return s; + } + return null; + } + + handleDisconnect(socketId, identifier) { + const gameIdFromMap = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, Пользователь: ${identifier}, GameID из карты: ${gameIdFromMap}`); + const game = gameIdFromMap ? this.games[gameIdFromMap] : null; + + if (game) { + if (game.gameState?.isGameOver) { + console.log(`[GameManager.handleDisconnect] Игра ${gameIdFromMap} для пользователя ${identifier} (сокет ${socketId}) УЖЕ ЗАВЕРШЕНА. Игра будет очищена своей собственной логикой или следующим релевантным действием.`); + return; + } + + const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); + + if (playerInfoInGame) { // Игрок существует в этой игре + console.log(`[GameManager.handleDisconnect] Отключающийся сокет ${socketId} для пользователя ${identifier} (Роль: ${playerInfoInGame.id}) в игре ${gameIdFromMap}. Уведомление GameInstance.`); + if (typeof game.handlePlayerPotentiallyLeft === 'function') { + // Передаем фактический socketId, который отключился. PCH определит, устарел ли он. + game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey, socketId); + } else { + console.error(`[GameManager.handleDisconnect] КРИТИЧЕСКИ: GameInstance ${gameIdFromMap} отсутствует handlePlayerPotentiallyLeft!`); + this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_on_disconnect_gm"); + } + } else { + console.warn(`[GameManager.handleDisconnect] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но не найден в game.players. Это может указывать на устаревшую запись userIdentifierToGameId. Очистка карты для этого пользователя.`); + if (this.userIdentifierToGameId[identifier] === gameIdFromMap) { + delete this.userIdentifierToGameId[identifier]; + } + } + } else { + if (this.userIdentifierToGameId[identifier]) { + console.warn(`[GameManager.handleDisconnect] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`); + delete this.userIdentifierToGameId[identifier]; + } + } + } + + _cleanupGame(gameId, reason = 'unknown') { + console.log(`[GameManager._cleanupGame] Попытка очистки для GameID: ${gameId}, Причина: ${reason}`); + const game = this.games[gameId]; + + if (!game) { + console.warn(`[GameManager._cleanupGame] Экземпляр игры для ${gameId} не найден в this.games. Очистка связанных записей.`); + const pendingIdx = this.pendingPvPGames.indexOf(gameId); + if (pendingIdx > -1) { + this.pendingPvPGames.splice(pendingIdx, 1); + console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`); + } + Object.keys(this.userIdentifierToGameId).forEach(idKey => { + if (this.userIdentifierToGameId[idKey] === gameId) { + delete this.userIdentifierToGameId[idKey]; + console.log(`[GameManager._cleanupGame] Удалено сопоставление для пользователя ${idKey} с игрой ${gameId}.`); + } + }); + this.broadcastAvailablePvPGames(); + return false; + } + + console.log(`[GameManager._cleanupGame] Очистка игры ${game.id}. Владелец: ${game.ownerIdentifier}. Причина: ${reason}. Игроков в игре: ${game.playerCount}`); + if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear(); + if (typeof game.clearAllReconnectTimers === 'function') game.clearAllReconnectTimers(); + + if (game.gameState && !game.gameState.isGameOver) { + console.log(`[GameManager._cleanupGame] Пометка игры ${game.id} как завершенной, так как она очищается во время активности.`); + game.gameState.isGameOver = true; + // game.io.to(game.id).emit('gameOver', { winnerId: null, reason: `game_cleanup_${reason}`, finalGameState: game.gameState, log: game.consumeLogBuffer() }); + } + + Object.values(game.players).forEach(pInfo => { + if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) { + delete this.userIdentifierToGameId[pInfo.identifier]; + console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для игрока ${pInfo.identifier}.`); + } + }); + // Дополнительная проверка для владельца, если он не был в списке игроков (маловероятно, но для полноты) + if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId) { + if (!Object.values(game.players).some(p => p.identifier === game.ownerIdentifier)) { + delete this.userIdentifierToGameId[game.ownerIdentifier]; + console.log(`[GameManager._cleanupGame] Очищено userIdentifierToGameId для владельца ${game.ownerIdentifier} (не был в списке игроков).`); + } + } + + + const pendingIdx = this.pendingPvPGames.indexOf(gameId); + if (pendingIdx > -1) { + this.pendingPvPGames.splice(pendingIdx, 1); + console.log(`[GameManager._cleanupGame] ${gameId} удален из pendingPvPGames.`); + } + + delete this.games[gameId]; + console.log(`[GameManager._cleanupGame] Экземпляр игры ${gameId} удален. Осталось игр: ${Object.keys(this.games).length}. Ожидающих: ${this.pendingPvPGames.length}. Размер карты пользователей: ${Object.keys(this.userIdentifierToGameId).length}`); + this.broadcastAvailablePvPGames(); + 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 p1Entry = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); + let p1Username = 'Игрок'; + let p1CharName = 'Неизвестный'; + const ownerId = game.ownerIdentifier; + + if (p1Entry) { // Используем данные из p1Entry, если он есть (более надежно) + p1Username = p1Entry.socket?.userData?.username || `User#${String(p1Entry.identifier).substring(0,4)}`; + const charData = dataUtils.getCharacterBaseStats(p1Entry.chosenCharacterKey); + p1CharName = charData?.name || p1Entry.chosenCharacterKey || 'Не выбран'; + } else if (ownerId){ // Резервный вариант, если p1Entry почему-то нет + const ownerSocket = this._findClientSocketByIdentifier(ownerId); + p1Username = ownerSocket?.userData?.username || `Owner#${String(ownerId).substring(0,4)}`; + const ownerCharKey = game.playerCharacterKey; + const charData = ownerCharKey ? dataUtils.getCharacterBaseStats(ownerCharKey) : null; + p1CharName = charData?.name || ownerCharKey || 'Не выбран'; + } + return { id: gameId, status: `Ожидает (${p1Username} за ${p1CharName})`, ownerIdentifier: ownerId }; + } else if (game && (game.playerCount !== 1 || game.gameState?.isGameOver)) { + console.warn(`[GameManager.getAvailablePvPGamesListForClient] Игра ${gameId} находится в pendingPvPGames, но не является допустимой ожидающей игрой (игроков: ${game.playerCount}, завершена: ${game.gameState?.isGameOver}). Удаление.`); + this._cleanupGame(gameId, 'invalid_pending_game_in_list'); + } + return null; + }) + .filter(info => info !== null); + } + + broadcastAvailablePvPGames() { + const list = this.getAvailablePvPGamesListForClient(); + this.io.emit('availablePvPGamesList', list); + } + + handleRequestGameState(socket, identifier) { + const gameIdFromMap = this.userIdentifierToGameId[identifier]; + console.log(`[GameManager.handleRequestGameState] Пользователь: ${identifier} (Socket: ${socket.id}) запрашивает состояние. GameID из карты: ${gameIdFromMap}`); + const game = gameIdFromMap ? this.games[gameIdFromMap] : null; + + if (game) { + const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); + + if (playerInfoInGame) { + if (game.gameState?.isGameOver) { + socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' }); + // Не удаляем из userIdentifierToGameId здесь, _cleanupGame сделает это, если игра еще в this.games + return; + } + if (typeof game.handlePlayerReconnected === 'function') { + const reconnected = game.handlePlayerReconnected(playerInfoInGame.id, socket); + if (!reconnected) { + console.warn(`[GameManager.handleRequestGameState] game.handlePlayerReconnected для ${identifier} в ${game.id} вернул false.`); + // GameInstance должен был отправить ошибку. + } + } else { + console.error(`[GameManager.handleRequestGameState] КРИТИЧЕСКИ: GameInstance ${game.id} отсутствует handlePlayerReconnected!`); + this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_gm_on_request'); + } + } else { + // Игрок сопоставлен с игрой, но НЕ НАЙДЕН в game.players. Это может произойти, если PCH еще не добавил игрока (например, F5 на экране создания игры). + // Попытаемся добавить игрока в игру, если это PvP и есть место, или если это его же игра в режиме AI. + console.warn(`[GameManager.handleRequestGameState] Пользователь ${identifier} сопоставлен с игрой ${gameIdFromMap}, но НЕ НАЙДЕН в game.players. Попытка добавить/переподключить.`); + if (game.mode === 'pvp') { + // Пытаемся присоединить, предполагая, что он мог быть удален или это F5 перед полным присоединением + const chosenCharKey = socket.handshake.query.chosenCharacterKey || 'elena'; // Получаем ключ из запроса или дефолтный + if (game.addPlayer(socket, chosenCharKey, identifier)) { + // Успешно добавили или переподключили через addPlayer -> handlePlayerReconnected + const newPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier); + socket.emit('gameCreated', { // Отправляем событие, как при обычном присоединении + gameId: game.id, + mode: game.mode, + yourPlayerId: newPlayerInfo.id, + chosenCharacterKey: newPlayerInfo.chosenCharacterKey + }); + if (game.playerCount === 2) { // Если игра стала полной + if(game.initializeGame()) game.startGame(); else this._cleanupGame(game.id, 'init_fail_pvp_readd_gm'); + const idx = this.pendingPvPGames.indexOf(game.id); + if (idx > -1) this.pendingPvPGames.splice(idx, 1); + this.broadcastAvailablePvPGames(); + } + } else { + // Не удалось добавить/переподключить через addPlayer + this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_readd_failed_in_gi_on_request'); + } + + } else if (game.mode === 'ai' && game.ownerIdentifier === identifier) { + // Для AI игры, если это владелец, пытаемся через handlePlayerReconnected + if (typeof game.handlePlayerReconnected === 'function') { + // Предполагаем, что роль PLAYER_ID, так как это AI игра и он владелец + const reconnected = game.handlePlayerReconnected(GAME_CONFIG.PLAYER_ID, socket); + if (!reconnected) { + this._handleGameRecoveryError(socket, game.id, identifier, 'ai_owner_reconnect_failed_on_request'); + } + } else { + this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_ai_owner_on_request'); + } + } else { + this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_unhandled_case_on_request'); + } + } + } else { + socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' }); + if (this.userIdentifierToGameId[identifier]) { + console.warn(`[GameManager.handleRequestGameState] Экземпляр игры для gameId ${gameIdFromMap} (пользователь ${identifier}) не найден. Очистка устаревшей записи в карте.`); + delete this.userIdentifierToGameId[identifier]; + } + } + } + + _handleGameRecoveryError(socket, gameId, identifier, reasonCode) { + console.error(`[GameManager._handleGameRecoveryError] Ошибка восстановления игры (ID: ${gameId || 'N/A'}) для пользователя ${identifier}. Причина: ${reasonCode}.`); + socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры. Попробуйте войти снова.' }); + + if (gameId && this.games[gameId]) { + this._cleanupGame(gameId, `recovery_error_gm_${reasonCode}_for_${identifier}`); + } else if (this.userIdentifierToGameId[identifier]) { + const problematicGameIdForUser = this.userIdentifierToGameId[identifier]; + delete this.userIdentifierToGameId[identifier]; + console.log(`[GameManager._handleGameRecoveryError] Очищено устаревшее userIdentifierToGameId[${identifier}], указывающее на ${problematicGameIdForUser}.`); + } + if (this.userIdentifierToGameId[identifier]) { // Финальная проверка + delete this.userIdentifierToGameId[identifier]; + console.warn(`[GameManager._handleGameRecoveryError] Принудительно очищено userIdentifierToGameId[${identifier}] в качестве финальной меры.`); + } + socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки. Пожалуйста, войдите снова.' }); + } +} + +module.exports = GameManager; \ No newline at end of file diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js new file mode 100644 index 0000000..c6cd72d --- /dev/null +++ b/server/game/instance/GameInstance.js @@ -0,0 +1,752 @@ +// /server/game/instance/GameInstance.js +const { v4: uuidv4 } = require('uuid'); +const TurnTimer = require('./TurnTimer'); +const gameLogic = require('../logic'); +const dataUtils = require('../../data/dataUtils'); +const GAME_CONFIG = require('../../core/config'); +const PlayerConnectionHandler = require('./PlayerConnectionHandler'); + +class GameInstance { + constructor(gameId, io, mode = 'ai', gameManager) { + this.id = gameId; + this.io = io; + this.mode = mode; + this.gameManager = gameManager; + + this.playerConnectionHandler = new PlayerConnectionHandler(this); + + this.gameState = null; + this.aiOpponent = (mode === 'ai'); + this.logBuffer = []; + + this.playerCharacterKey = null; + this.opponentCharacterKey = null; + this.ownerIdentifier = null; + + this.turnTimer = new TurnTimer( + GAME_CONFIG.TURN_DURATION_MS, + GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, + () => this.handleTurnTimeout(), + (remainingTime, isPlayerTurnForTimer, isPaused) => { + // Логируем отправку обновления таймера + // console.log(`[GI TURN_TIMER_CB ${this.id}] Sending update. Remaining: ${remainingTime}, isPlayerT: ${isPlayerTurnForTimer}, isPaused (raw): ${isPaused}, effectivelyPaused: ${this.isGameEffectivelyPaused()}`); + this.io.to(this.id).emit('turnTimerUpdate', { + remainingTime, + isPlayerTurn: isPlayerTurnForTimer, + isPaused: isPaused || this.isGameEffectivelyPaused() + }); + }, + this.id + ); + + if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { + console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: Ссылка на GameManager недействительна.`); + } + console.log(`[GameInstance ${this.id}] Создан. Режим: ${mode}. PlayerConnectionHandler также инициализирован.`); + } + + get playerCount() { + return this.playerConnectionHandler.playerCount; + } + + get players() { + return this.playerConnectionHandler.getAllPlayersInfo(); + } + + setPlayerCharacterKey(key) { this.playerCharacterKey = key; } + setOpponentCharacterKey(key) { this.opponentCharacterKey = key; } + setOwnerIdentifier(identifier) { this.ownerIdentifier = identifier; } + + addPlayer(socket, chosenCharacterKey, identifier) { + return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier); + } + + removePlayer(socketId, reason) { + this.playerConnectionHandler.removePlayer(socketId, reason); + } + + handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) { + this.playerConnectionHandler.handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId); + } + + handlePlayerReconnected(playerIdRole, newSocket) { + console.log(`[GameInstance ${this.id}] Делегирование handlePlayerReconnected в PCH для роли ${playerIdRole}, сокет ${newSocket.id}`); + return this.playerConnectionHandler.handlePlayerReconnected(playerIdRole, newSocket); + } + + clearAllReconnectTimers() { + this.playerConnectionHandler.clearAllReconnectTimers(); + } + + isGameEffectivelyPaused() { + return this.playerConnectionHandler.isGameEffectivelyPaused(); + } + + handlePlayerPermanentlyLeft(playerRole, characterKey, reason) { + console.log(`[GameInstance ${this.id}] Игрок окончательно покинул игру. Роль: ${playerRole}, Персонаж: ${characterKey}, Причина: ${reason}`); + if (this.gameState && !this.gameState.isGameOver) { + if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { + this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game"); + } else if (this.mode === 'pvp') { + if (this.playerCount < 2) { + const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected); + this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id); + } + } + } else if (!this.gameState && Object.keys(this.players).length === 0) { + this.gameManager._cleanupGame(this.id, "all_players_left_before_start_gi_via_pch"); + } + } + + _sayTaunt(characterState, opponentCharacterKey, triggerType, subTriggerOrContext = null, contextOverrides = {}) { + if (!characterState || !characterState.characterKey) return; + if (!opponentCharacterKey) return; + if (!gameLogic.getRandomTaunt) { console.error(`[Taunt ${this.id}] _sayTaunt: gameLogic.getRandomTaunt недоступен!`); return; } + if (!this.gameState) return; + + let context = {}; + let subTrigger = null; + + if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') { + subTrigger = subTriggerOrContext; + } else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) { + context = { ...subTriggerOrContext }; + } + context = { ...context, ...contextOverrides }; + + if ((triggerType === 'selfCastAbility' || triggerType === 'onOpponentAction') && + (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number')) { + context.abilityId = subTriggerOrContext; + subTrigger = subTriggerOrContext; + } else if (triggerType === 'onBattleState' && typeof subTriggerOrContext === 'string') { + subTrigger = subTriggerOrContext; + } else if (triggerType === 'basicAttack' && typeof subTriggerOrContext === 'string') { + subTrigger = subTriggerOrContext; + } + + const opponentFullData = dataUtils.getCharacterData(opponentCharacterKey); + if (!opponentFullData) return; + + const tauntText = gameLogic.getRandomTaunt( + characterState.characterKey, + triggerType, + subTrigger || context, + GAME_CONFIG, + opponentFullData, + this.gameState + ); + + if (tauntText && tauntText !== "(Молчание)") { + this.addToLog(`${characterState.name}: "${tauntText}"`, GAME_CONFIG.LOG_TYPE_INFO); + } + } + + initializeGame() { + console.log(`[GameInstance ${this.id}] Инициализация состояния игры. Режим: ${this.mode}. Активных игроков (PCH): ${this.playerCount}. Всего записей в PCH.players: ${Object.keys(this.players).length}. PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`); + + const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); + const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); + + // Устанавливаем ключи персонажей, если они еще не установлены, на основе активных игроков в PCH + // Это важно, если initializeGame вызывается до того, как PCH успел обновить ключи в GI через сеттеры + if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey; + if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey; + + + if (this.mode === 'ai') { + if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; } + if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;} + this.opponentCharacterKey = 'balard'; + } else { // pvp + if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) { + this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false; + } + if (this.playerCount === 2 && (!this.playerCharacterKey || !this.opponentCharacterKey)) { + this._handleCriticalError('init_pvp_char_key_missing_gi', `Инициализация PvP: playerCount=2, но ключ персонажа отсутствует. P1Key: ${this.playerCharacterKey}, P2Key: ${this.opponentCharacterKey}.`); + return false; + } + } + + const playerData = this.playerCharacterKey ? dataUtils.getCharacterData(this.playerCharacterKey) : null; + const opponentData = this.opponentCharacterKey ? dataUtils.getCharacterData(this.opponentCharacterKey) : null; + + const isPlayerSlotFilledAndActive = !!(playerData && p1ActiveEntry); + const isOpponentSlotFilledAndActive = !!(opponentData && (this.mode === 'ai' || p2ActiveEntry)); + + if (this.mode === 'ai' && (!isPlayerSlotFilledAndActive || !opponentData) ) { + this._handleCriticalError('init_ai_data_fail_gs_gi', 'Инициализация AI игры: Не удалось загрузить данные игрока или AI для gameState.'); return false; + } + + this.logBuffer = []; + + // Имена берутся из playerData/opponentData, если они есть. PCH обновит их при реконнекте, если они изменились. + const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...'); + let opponentName; + if (this.mode === 'ai') { + opponentName = opponentData?.baseStats?.name || 'Противник AI'; + } else { + opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...'); + } + + + this.gameState = { + player: isPlayerSlotFilledAndActive ? + this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : // Передаем имя + this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName), + opponent: isOpponentSlotFilledAndActive ? + this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : // Передаем имя + this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName), + isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true, + isGameOver: false, + turnNumber: 1, + gameMode: this.mode + }; + console.log(`[GameInstance ${this.id}] Состояние игры инициализировано. Игрок: ${this.gameState.player.name} (${this.gameState.player.characterKey}). Оппонент: ${this.gameState.opponent.name} (${this.gameState.opponent.characterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}. Готово к старту: AI=${isPlayerSlotFilledAndActive && !!opponentData}, PvP1=${isPlayerSlotFilledAndActive}, PvP2=${isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive}`); + return (this.mode === 'ai') ? (isPlayerSlotFilledAndActive && !!opponentData) : isPlayerSlotFilledAndActive; + } + + _createFighterState(roleId, baseStats, abilities, explicitName = null) { + const fighterState = { + id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, // Используем explicitName если передано + currentHp: baseStats.maxHp, maxHp: baseStats.maxHp, + currentResource: baseStats.maxResource, maxResource: baseStats.maxResource, + resourceName: baseStats.resourceName, attackPower: baseStats.attackPower, + isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} + }; + (abilities || []).forEach(ability => { + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + fighterState.abilityCooldowns[ability.id] = 0; + } + }); + if (baseStats.characterKey === 'balard') { + fighterState.silenceCooldownTurns = 0; + fighterState.manaDrainCooldownTurns = 0; + } + return fighterState; + } + + startGame() { + console.log(`[GameInstance ${this.id}] Попытка запуска игры. Paused: ${this.isGameEffectivelyPaused()}`); + if (this.isGameEffectivelyPaused()) { + console.log(`[GameInstance ${this.id}] Запуск игры отложен: игра на паузе.`); + return; + } + + if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) { + console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`); + if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { + this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.'); + return; + } + } + console.log(`[GameInstance ${this.id}] Запуск игры. Игрок в GS: ${this.gameState.player.name} (${this.playerCharacterKey}), Оппонент в GS: ${this.gameState.opponent.name} (${this.opponentCharacterKey}). IsPlayerTurn: ${this.gameState.isPlayerTurn}`); + + const pData = dataUtils.getCharacterData(this.playerCharacterKey); + const oData = dataUtils.getCharacterData(this.opponentCharacterKey); + + if (!pData || !oData) { + this._handleCriticalError('start_char_data_fail_sg_gi', `Не удалось загрузить данные персонажей при старте игры. PData: ${!!pData}, OData: ${!!oData}`); + return; + } + + // Обновляем имена в gameState на основе данных персонажей перед отправкой клиентам + // Это гарантирует, что имена из dataUtils (самые "правильные") попадут в первое gameStarted + if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name; + if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name; + + + this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); + + if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { + this._sayTaunt(this.gameState.player, this.gameState.opponent.characterKey, 'onBattleState', 'start'); + this._sayTaunt(this.gameState.opponent, this.gameState.player.characterKey, 'onBattleState', 'start'); + } else { + console.warn(`[GameInstance ${this.id}] Не удалось произнести стартовые насмешки во время startGame, gameState акторы/ключи не полностью готовы.`); + } + + const initialLog = this.consumeLogBuffer(); + + Object.values(this.players).forEach(playerInfo => { + if (playerInfo.socket?.connected && !playerInfo.isTemporarilyDisconnected) { + const dataForThisClient = playerInfo.id === GAME_CONFIG.PLAYER_ID ? + { playerBaseStats: pData.baseStats, opponentBaseStats: oData.baseStats, playerAbilities: pData.abilities, opponentAbilities: oData.abilities } : + { playerBaseStats: oData.baseStats, opponentBaseStats: pData.baseStats, playerAbilities: oData.abilities, opponentAbilities: pData.abilities }; + + playerInfo.socket.emit('gameStarted', { + gameId: this.id, + yourPlayerId: playerInfo.id, + initialGameState: this.gameState, + ...dataForThisClient, + log: [...initialLog], + clientConfig: { ...GAME_CONFIG } + }); + } + }); + + const firstTurnActor = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; + this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${firstTurnActor.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastLogUpdate(); + + const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; + console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn: ${this.gameState.isPlayerTurn}, isFirstTurnAi: ${isFirstTurnAi}`); + this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi); + + if (isFirstTurnAi) { + setTimeout(() => { + if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) { + this.processAiTurn(); + } + }, GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } + + processPlayerAction(identifier, actionData) { + console.log(`[GameInstance ${this.id}] processPlayerAction от ${identifier}. Действие: ${actionData.actionType}. Текущий GS.isPlayerTurn: ${this.gameState?.isPlayerTurn}. Paused: ${this.isGameEffectivelyPaused()}`); + const actingPlayerInfo = Object.values(this.players).find(p => p.identifier === identifier); + if (!actingPlayerInfo || !actingPlayerInfo.socket) { + console.error(`[GameInstance ${this.id}] Действие от неизвестного или безсокетного идентификатора ${identifier}.`); return; + } + + if (this.isGameEffectivelyPaused()) { + actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."}); + return; + } + if (!this.gameState || this.gameState.isGameOver) { return; } + + const actingPlayerRole = actingPlayerInfo.id; + const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || + (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); + + if (!isCorrectTurn) { + console.warn(`[GameInstance ${this.id}] Неверный ход! Игрок ${identifier} (роль ${actingPlayerRole}) пытался действовать. GS.isPlayerTurn: ${this.gameState.isPlayerTurn}`); + actingPlayerInfo.socket.emit('gameError', { message: "Не ваш ход." }); + return; + } + + console.log(`[GameInstance ${this.id}] Ход корректен. Очистка таймера.`); + if(this.turnTimer.isActive()) this.turnTimer.clear(); + + const attackerState = this.gameState[actingPlayerRole]; + const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const defenderState = this.gameState[defenderRole]; + + if (!attackerState || !attackerState.characterKey || !defenderState || !defenderState.characterKey) { + this._handleCriticalError('action_actor_state_invalid_gi', `Состояние/ключ Атакующего или Защитника недействительны.`); return; + } + const attackerData = dataUtils.getCharacterData(attackerState.characterKey); + const defenderData = dataUtils.getCharacterData(defenderState.characterKey); + if (!attackerData || !defenderData) { this._handleCriticalError('action_char_data_fail_process_gi', 'Ошибка данных персонажа при действии.'); return; } + + let actionIsValidAndPerformed = false; + + if (actionData.actionType === 'attack') { + this._sayTaunt(attackerState, defenderState.characterKey, 'basicAttack'); + gameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt); + actionIsValidAndPerformed = true; + } else if (actionData.actionType === 'ability' && actionData.abilityId) { + const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); + if (!ability) { + actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); + } else { + const validityCheck = gameLogic.checkAbilityValidity(ability, attackerState, defenderState, GAME_CONFIG); + if (validityCheck.isValid) { + this._sayTaunt(attackerState, defenderState.characterKey, 'selfCastAbility', ability.id); + attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); + gameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null); + gameLogic.setAbilityCooldown(ability, attackerState, GAME_CONFIG); + actionIsValidAndPerformed = true; + } else { + this.addToLog(validityCheck.reason || `${attackerState.name} не может использовать "${ability.name}".`, GAME_CONFIG.LOG_TYPE_INFO); + actionIsValidAndPerformed = false; + } + } + } else { + actionIsValidAndPerformed = false; + } + + if (this.checkGameOver()) return; + this.broadcastLogUpdate(); + if (actionIsValidAndPerformed) { + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + } else { + const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn; + console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера. isPlayerTurn: ${this.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`); + this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer); + } + } + + switchTurn() { + console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`); + if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; } + if (!this.gameState || this.gameState.isGameOver) { return; } + if(this.turnTimer.isActive()) this.turnTimer.clear(); + + const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + const endingTurnActorState = this.gameState[endingTurnActorRole]; + if (!endingTurnActorState || !endingTurnActorState.characterKey) { this._handleCriticalError('switch_turn_ending_actor_invalid_gi', `Состояние или ключ актора, завершающего ход, недействительны.`); return; } + const endingTurnActorData = dataUtils.getCharacterData(endingTurnActorState.characterKey); + if (!endingTurnActorData) { this._handleCriticalError('switch_turn_char_data_fail_gi', `Отсутствуют данные персонажа.`); return; } + + gameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnActorData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils); + gameLogic.updateBlockingStatus(endingTurnActorState); + if (endingTurnActorState.abilityCooldowns && endingTurnActorData.abilities) gameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG); + if (endingTurnActorState.characterKey === 'balard') gameLogic.processBalardSpecialCooldowns(endingTurnActorState); + if (endingTurnActorState.disabledAbilities?.length > 0 && endingTurnActorData.abilities) gameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, endingTurnActorData.abilities, endingTurnActorState.name, this.addToLog.bind(this), GAME_CONFIG); + + if (this.checkGameOver()) return; + + this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; + if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; + + const currentTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + const currentTurnActorState = this.gameState[currentTurnActorRole]; + if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; } + + this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + this.broadcastGameStateUpdate(); + + const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole); + if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) { + console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`); + } else { + const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; + console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn: ${this.gameState.isPlayerTurn}, isNextTurnAi: ${isNextTurnAi}`); + this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi); + if (isNextTurnAi) { + setTimeout(() => { + if (!this.isGameEffectivelyPaused() && this.gameState && !this.gameState.isGameOver && this.mode === 'ai' && !this.gameState.isPlayerTurn) { + this.processAiTurn(); + } + }, GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } + } + + processAiTurn() { + console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn: ${this.gameState?.isPlayerTurn}`); + if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; } + if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; } + if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) { + console.error(`[GameInstance ${this.id}] AI не Балард! Персонаж AI: ${this.gameState.opponent?.characterKey}. Принудительная смена хода.`); + this.switchTurn(); + return; + } + if(this.turnTimer.isActive()) this.turnTimer.clear(); + + const aiState = this.gameState.opponent; + const playerState = this.gameState.player; + if (!playerState || !playerState.characterKey) { this._handleCriticalError('ai_turn_player_state_invalid_gi', 'Состояние игрока недействительно для хода AI.'); return; } + + const aiDecision = gameLogic.decideAiAction(this.gameState, dataUtils, GAME_CONFIG, this.addToLog.bind(this)); + let actionIsValidAndPerformedForAI = false; + + if (aiDecision.actionType === 'attack') { + this._sayTaunt(aiState, playerState.characterKey, 'basicAttack'); + gameLogic.performAttack(aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt); + actionIsValidAndPerformedForAI = true; + } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { + this._sayTaunt(aiState, playerState.characterKey, 'selfCastAbility', aiDecision.ability.id); + aiState.currentResource = Math.round(aiState.currentResource - aiDecision.ability.cost); + gameLogic.applyAbilityEffect(aiDecision.ability, aiState, playerState, dataUtils.getCharacterBaseStats(aiState.characterKey), dataUtils.getCharacterBaseStats(playerState.characterKey), this.gameState, this.addToLog.bind(this), GAME_CONFIG, dataUtils, gameLogic.getRandomTaunt, null); + gameLogic.setAbilityCooldown(aiDecision.ability, aiState, GAME_CONFIG); + actionIsValidAndPerformedForAI = true; + } else if (aiDecision.actionType === 'pass') { + if (aiDecision.logMessage && this.addToLog) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type); + else if(this.addToLog) this.addToLog(`${aiState.name} пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); + actionIsValidAndPerformedForAI = true; + } + + if (this.checkGameOver()) return; + this.broadcastLogUpdate(); + if (actionIsValidAndPerformedForAI) { + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + } else { + console.error(`[GameInstance ${this.id}] AI не смог выполнить действие. Принудительная смена хода.`); + setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); + } + } + + checkGameOver() { + if (!this.gameState || this.gameState.isGameOver) return this.gameState?.isGameOver ?? true; + + if (!this.gameState.isGameOver && this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { + const player = this.gameState.player; const opponent = this.gameState.opponent; + const pData = dataUtils.getCharacterData(player.characterKey); const oData = dataUtils.getCharacterData(opponent.characterKey); + if (pData && oData) { + const nearDefeatThreshold = GAME_CONFIG.OPPONENT_NEAR_DEFEAT_THRESHOLD_PERCENT || 0.2; + if (opponent.currentHp > 0 && (opponent.currentHp / oData.baseStats.maxHp) <= nearDefeatThreshold) { + this._sayTaunt(player, opponent.characterKey, 'onBattleState', 'opponentNearDefeat'); + } + if (player.currentHp > 0 && (player.currentHp / pData.baseStats.maxHp) <= nearDefeatThreshold) { + this._sayTaunt(opponent, player.characterKey, 'onBattleState', 'opponentNearDefeat'); + } + } + } + + const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode); + if (gameOverResult.isOver) { + this.gameState.isGameOver = true; + if(this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + + const winnerState = this.gameState[gameOverResult.winnerRole]; + const loserState = this.gameState[gameOverResult.loserRole]; + if (winnerState?.characterKey && loserState?.characterKey) { + this._sayTaunt(winnerState, loserState.characterKey, 'onBattleState', 'opponentNearDefeat'); + } + + console.log(`[GameInstance ${this.id}] Игра окончена. Победитель: ${gameOverResult.winnerRole || 'Нет'}. Причина: ${gameOverResult.reason}.`); + this.io.to(this.id).emit('gameOver', { + winnerId: gameOverResult.winnerRole, + reason: gameOverResult.reason, + finalGameState: this.gameState, + log: this.consumeLogBuffer(), + loserCharacterKey: loserState?.characterKey || 'unknown' + }); + this.gameManager._cleanupGame(this.id, `game_ended_${gameOverResult.reason}`); + return true; + } + return false; + } + + endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { + if (this.gameState && !this.gameState.isGameOver) { + this.gameState.isGameOver = true; + if(this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + + let actualWinnerRole = winnerIfAny; + let winnerActuallyExists = false; + + if (actualWinnerRole) { + const winnerPlayerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected); + if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) { + winnerActuallyExists = !!this.gameState.opponent?.characterKey; + } else if (winnerPlayerEntry) { + winnerActuallyExists = true; + } + } + + if (!winnerActuallyExists) { + actualWinnerRole = (disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID); + const defaultWinnerEntry = Object.values(this.players).find(p => p.id === actualWinnerRole && !p.isTemporarilyDisconnected); + if (this.mode === 'ai' && actualWinnerRole === GAME_CONFIG.OPPONENT_ID) { + winnerActuallyExists = !!this.gameState.opponent?.characterKey; + } else if (defaultWinnerEntry) { + winnerActuallyExists = true; + } + } + + const finalWinnerRole = winnerActuallyExists ? actualWinnerRole : null; + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, reason, finalWinnerRole, disconnectedPlayerRole); + + this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + console.log(`[GameInstance ${this.id}] Игра завершена из-за отключения: ${reason}. Победитель: ${result.winnerRole || 'Нет'}.`); + this.io.to(this.id).emit('gameOver', { + winnerId: result.winnerRole, + reason: result.reason, + finalGameState: this.gameState, + log: this.consumeLogBuffer(), + loserCharacterKey: disconnectedCharacterKey, + disconnectedCharacterName: (reason === 'opponent_disconnected' || reason === 'player_left_ai_game' || reason === 'opponent_left_pvp_game') ? + (this.gameState[disconnectedPlayerRole]?.name || disconnectedCharacterKey) : undefined + }); + this.gameManager._cleanupGame(this.id, `disconnect_game_ended_gi_${result.reason}`); + } else if (this.gameState?.isGameOver) { + console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: игра уже была завершена.`); + this.gameManager._cleanupGame(this.id, `already_over_on_disconnect_cleanup_gi`); + } else { + console.log(`[GameInstance ${this.id}] EndGameDueToDisconnect: нет gameState.`); + this.gameManager._cleanupGame(this.id, `no_gamestate_on_disconnect_cleanup_gi`); + } + } + + playerExplicitlyLeftAiGame(identifier) { + if (this.mode !== 'ai' || (this.gameState && this.gameState.isGameOver)) { + console.log(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame вызван, но не режим AI или игра завершена.`); + if (this.gameState?.isGameOver) this.gameManager._cleanupGame(this.id, `player_left_ai_already_over_gi`); + return; + } + + const playerEntry = Object.values(this.players).find(p => p.identifier === identifier); + if (!playerEntry || playerEntry.id !== GAME_CONFIG.PLAYER_ID) { + console.warn(`[GameInstance ${this.id}] playerExplicitlyLeftAiGame: Идентификатор ${identifier} не является игроком-человеком или не найден.`); + return; + } + + console.log(`[GameInstance ${this.id}] Игрок ${identifier} явно покинул AI игру.`); + if (this.gameState) { + this.gameState.isGameOver = true; + this.addToLog(`Игрок покинул битву с ${this.gameState.opponent?.name || 'AI'}.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else { + this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } + + if (this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + + this.io.to(this.id).emit('gameOver', { + winnerId: GAME_CONFIG.OPPONENT_ID, + reason: "player_left_ai_game", + finalGameState: this.gameState, + log: this.consumeLogBuffer(), + loserCharacterKey: playerEntry.chosenCharacterKey + }); + this.gameManager._cleanupGame(this.id, 'player_left_ai_explicitly_gi'); + } + + playerDidSurrender(surrenderingPlayerIdentifier) { + console.log(`[GameInstance ${this.id}] playerDidSurrender вызван для идентификатора: ${surrenderingPlayerIdentifier}`); + + if (!this.gameState || this.gameState.isGameOver) { + if (this.gameState?.isGameOver) { this.gameManager._cleanupGame(this.id, `surrender_on_finished_gi`); } + console.warn(`[GameInstance ${this.id}] Попытка сдачи в неактивной/завершенной игре от ${surrenderingPlayerIdentifier}.`); + return; + } + + const surrenderedPlayerEntry = Object.values(this.players).find(p => p.identifier === surrenderingPlayerIdentifier); + if (!surrenderedPlayerEntry) { + console.error(`[GameInstance ${this.id}] Сдающийся игрок ${surrenderingPlayerIdentifier} не найден.`); + return; + } + const surrenderingPlayerRole = surrenderedPlayerEntry.id; + + if (this.mode === 'ai') { + if (surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID) { + console.log(`[GameInstance ${this.id}] Игрок ${surrenderingPlayerIdentifier} "сдался" (покинул) AI игру.`); + this.playerExplicitlyLeftAiGame(surrenderingPlayerIdentifier); + } else { + console.warn(`[GameInstance ${this.id}] Сдача в AI режиме от не-игрока (роль: ${surrenderingPlayerRole}). Игнорируется.`); + } + return; + } + + if (this.mode !== 'pvp') { + console.warn(`[GameInstance ${this.id}] Сдача вызвана в не-PvP, не-AI режиме: ${this.mode}. Игнорируется.`); + return; + } + + const surrenderedPlayerName = this.gameState[surrenderingPlayerRole]?.name || surrenderedPlayerEntry.chosenCharacterKey; + const surrenderedPlayerCharKey = this.gameState[surrenderingPlayerRole]?.characterKey || surrenderedPlayerEntry.chosenCharacterKey; + const winnerRole = surrenderingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const winnerName = this.gameState[winnerRole]?.name || `Оппонент`; + const winnerCharKey = this.gameState[winnerRole]?.characterKey; + + this.gameState.isGameOver = true; + if(this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + + this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM); + console.log(`[GameInstance ${this.id}] Игрок ${surrenderedPlayerName} (Роль: ${surrenderingPlayerRole}) сдался. Победитель: ${winnerName} (Роль: ${winnerRole}).`); + + if (winnerCharKey && surrenderedPlayerCharKey && this.gameState[winnerRole]) { + this._sayTaunt(this.gameState[winnerRole], surrenderedPlayerCharKey, 'onBattleState', 'opponentNearDefeat'); + } + + this.io.to(this.id).emit('gameOver', { + winnerId: winnerRole, reason: "player_surrendered", + finalGameState: this.gameState, log: this.consumeLogBuffer(), + loserCharacterKey: surrenderedPlayerCharKey + }); + this.gameManager._cleanupGame(this.id, "player_surrendered_gi"); + } + + handleTurnTimeout() { + if (!this.gameState || this.gameState.isGameOver) return; + console.log(`[GameInstance ${this.id}] Произошел таймаут хода.`); + const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + + const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + let winnerActuallyExists = false; + + if (this.mode === 'ai' && winnerPlayerRoleIfHuman === GAME_CONFIG.OPPONENT_ID) { + winnerActuallyExists = !!this.gameState.opponent?.characterKey; + } else { + const winnerEntry = Object.values(this.players).find(p => p.id === winnerPlayerRoleIfHuman && !p.isTemporarilyDisconnected); + winnerActuallyExists = !!winnerEntry; + } + + const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRoleIfHuman : null, timedOutPlayerRole); + + this.gameState.isGameOver = true; + this.clearAllReconnectTimers(); + + this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); + if (result.winnerRole && this.gameState[result.winnerRole]?.characterKey && this.gameState[result.loserRole]?.characterKey) { + this._sayTaunt(this.gameState[result.winnerRole], this.gameState[result.loserRole].characterKey, 'onBattleState', 'opponentNearDefeat'); + } + console.log(`[GameInstance ${this.id}] Ход истек для ${this.gameState[timedOutPlayerRole]?.name || timedOutPlayerRole}. Победитель: ${result.winnerRole ? (this.gameState[result.winnerRole]?.name || result.winnerRole) : 'Нет'}.`); + this.io.to(this.id).emit('gameOver', { + winnerId: result.winnerRole, + reason: result.reason, + finalGameState: this.gameState, + log: this.consumeLogBuffer(), + loserCharacterKey: this.gameState[timedOutPlayerRole]?.characterKey || 'unknown' + }); + this.gameManager._cleanupGame(this.id, `timeout_gi_${result.reason}`); + } + + _handleCriticalError(reasonCode, logMessage) { + console.error(`[GameInstance ${this.id}] КРИТИЧЕСКАЯ ОШИБКА: ${logMessage} (Код: ${reasonCode})`); + if (this.gameState && !this.gameState.isGameOver) this.gameState.isGameOver = true; + else if (!this.gameState) { + this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode }; + } + + if(this.turnTimer.isActive()) this.turnTimer.clear(); + this.clearAllReconnectTimers(); + + this.addToLog(`Критическая ошибка сервера: ${logMessage}. Игра будет завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.io.to(this.id).emit('gameOver', { + winnerId: null, + reason: `server_error_${reasonCode}`, + finalGameState: this.gameState, + log: this.consumeLogBuffer(), + loserCharacterKey: 'unknown' + }); + this.gameManager._cleanupGame(this.id, `critical_error_gi_${reasonCode}`); + } + + addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { + if (!message) return; + this.logBuffer.push({ message, type, timestamp: Date.now() }); + // Раскомментируйте для немедленной отправки логов, если нужно (но обычно лучше батчинг) + // this.broadcastLogUpdate(); + } + + consumeLogBuffer() { + const logs = [...this.logBuffer]; + this.logBuffer = []; + return logs; + } + + broadcastGameStateUpdate() { + if (this.isGameEffectivelyPaused()) { + console.log(`[GameInstance ${this.id}] broadcastGameStateUpdate отложено: игра на паузе.`); + return; + } + if (!this.gameState) { + console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`); + return; + } + console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn: ${this.gameState.isPlayerTurn}`); + this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); + } + + broadcastLogUpdate() { + if (this.isGameEffectivelyPaused() && this.logBuffer.some(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM)) { + const systemLogs = this.logBuffer.filter(log => log.type === GAME_CONFIG.LOG_TYPE_SYSTEM); + if (systemLogs.length > 0) { + this.io.to(this.id).emit('logUpdate', { log: systemLogs }); + } + this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); // Оставляем несистемные + return; + } + if (this.logBuffer.length > 0) { + this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); + } + } +} + +module.exports = GameInstance; \ No newline at end of file diff --git a/server/game/instance/Player.js b/server/game/instance/Player.js new file mode 100644 index 0000000..e69de29 diff --git a/server/game/instance/PlayerConnectionHandler.js b/server/game/instance/PlayerConnectionHandler.js new file mode 100644 index 0000000..d102294 --- /dev/null +++ b/server/game/instance/PlayerConnectionHandler.js @@ -0,0 +1,502 @@ +// /server/game/instance/PlayerConnectionHandler.js // +const GAME_CONFIG = require('../../core/config'); +const dataUtils = require('../../data/dataUtils'); + +class PlayerConnectionHandler { + constructor(gameInstance) { + this.gameInstance = gameInstance; // Ссылка на основной GameInstance + this.io = gameInstance.io; + this.gameId = gameInstance.id; + this.mode = gameInstance.mode; + + this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected, name (optional from gameState) } } + this.playerSockets = {}; // { playerIdRole: socket } // Авторитетный сокет для роли + this.playerCount = 0; + + this.reconnectTimers = {}; // { playerIdRole: { timerId, updateIntervalId, startTimeMs, durationMs } } + this.pausedTurnState = null; // { remainingTime, forPlayerRoleIsPlayer, isAiCurrentlyMoving } + console.log(`[PCH for Game ${this.gameId}] Инициализирован.`); + } + + addPlayer(socket, chosenCharacterKey = 'elena', identifier) { + console.log(`[PCH ${this.gameId}] Попытка addPlayer. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`); + const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); + + if (existingPlayerByIdentifier) { + console.warn(`[PCH ${this.gameId}] Идентификатор ${identifier} уже связан с ролью игрока ${existingPlayerByIdentifier.id} (сокет ${existingPlayerByIdentifier.socket?.id}). Обрабатывается как возможное переподключение.`); + if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { + console.warn(`[PCH ${this.gameId}] Игрок ${identifier} пытается (пере)присоединиться к уже завершенной игре. Отправка gameError.`); + socket.emit('gameError', { message: 'Эта игра уже завершена.' }); + return false; + } + // Если игрок уже есть, и это не временное отключение, и сокет другой - это F5 или новая вкладка. + // GameManager должен был направить на handleRequestGameState, который вызовет handlePlayerReconnected. + // Прямой addPlayer в этом случае - редкий сценарий, но handlePlayerReconnected его обработает. + return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); + } + + if (Object.keys(this.players).length >= 2 && this.playerCount >=2 && this.mode === 'pvp') { // В AI режиме только 1 человек + socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); + return false; + } + if (this.mode === 'ai' && this.playerCount >=1) { + socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'}); + return false; + } + + + let assignedPlayerId; + let actualCharacterKey = chosenCharacterKey || 'elena'; + const charData = dataUtils.getCharacterData(actualCharacterKey); + + if (this.mode === 'ai') { + // if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Эта проверка уже покрыта playerCount >= 1 выше + // socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); + // return false; + // } + assignedPlayerId = GAME_CONFIG.PLAYER_ID; + } else { // pvp + if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { + assignedPlayerId = GAME_CONFIG.PLAYER_ID; + } else if (!this.playerSockets[GAME_CONFIG.OPPONENT_ID]) { + assignedPlayerId = GAME_CONFIG.OPPONENT_ID; + const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) { + if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest'; + else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena'; + else actualCharacterKey = dataUtils.getAllCharacterKeys().find(k => k !== firstPlayerInfo.chosenCharacterKey) || 'elena'; + } + } else { // Оба слота заняты, но playerCount мог быть < 2 если кто-то в процессе дисконнекта + socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре (возможно, все заняты или в процессе переподключения).' }); + return false; + } + } + + // Если для этой роли УЖЕ был игрок (например, старый сокет при F5 до того, как сработал disconnect), + // то handlePlayerReconnected должен был бы это обработать. Этот блок здесь - подстраховка, + // если addPlayer вызван напрямую в таком редком случае. + const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id); + if (oldPlayerSocketIdForRole) { + const oldPlayerInfo = this.players[oldPlayerSocketIdForRole]; + console.warn(`[PCH ${this.gameId}] addPlayer: Найден старый сокет ${oldPlayerInfo.socket?.id} для роли ${assignedPlayerId}. Удаляем его запись.`); + if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); oldPlayerInfo.socket.disconnect(true); } catch(e){} } + delete this.players[oldPlayerSocketIdForRole]; + } + + this.players[socket.id] = { + id: assignedPlayerId, + socket: socket, + chosenCharacterKey: actualCharacterKey, + identifier: identifier, + isTemporarilyDisconnected: false, + name: charData?.baseStats?.name || actualCharacterKey + }; + this.playerSockets[assignedPlayerId] = socket; + this.playerCount++; + socket.join(this.gameId); + console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`); + + + if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey); + else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.gameInstance.setOpponentCharacterKey(actualCharacterKey); + + if (!this.gameInstance.ownerIdentifier && (this.mode === 'ai' || (this.mode === 'pvp' && assignedPlayerId === GAME_CONFIG.PLAYER_ID))) { + this.gameInstance.setOwnerIdentifier(identifier); + } + + console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Socket: ${socket.id}) добавлен как ${assignedPlayerId} с персонажем ${this.players[socket.id].name}. Активных игроков: ${this.playerCount}. Владелец: ${this.gameInstance.ownerIdentifier}`); + return true; + } + + removePlayer(socketId, reason = "unknown_reason_for_removal") { + const playerInfo = this.players[socketId]; + if (playerInfo) { + const playerRole = playerInfo.id; + const playerIdentifier = playerInfo.identifier; + console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`); + + if (playerInfo.socket) { + try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave: ${e.message}`); } + } + + if (!playerInfo.isTemporarilyDisconnected) { + this.playerCount--; + } + + delete this.players[socketId]; + if (this.playerSockets[playerRole]?.id === socketId) { + delete this.playerSockets[playerRole]; + } + this.clearReconnectTimer(playerRole); + + console.log(`[PCH ${this.gameId}] Игрок ${playerIdentifier} удален. Активных игроков сейчас: ${this.playerCount}.`); + this.gameInstance.handlePlayerPermanentlyLeft(playerRole, playerInfo.chosenCharacterKey, reason); + + } else { + console.warn(`[PCH ${this.gameId}] removePlayer вызван для неизвестного socketId: ${socketId}`); + } + } + + handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) { + console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft для роли ${playerIdRole}, id ${identifier}, char ${characterKey}, disconnectedSocketId ${disconnectedSocketId}`); + const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); + + if (!playerEntry || !playerEntry.socket) { + console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`); + // Если записи нет, возможно, игрок уже удален или это был очень старый сокет. + // Проверим, есть ли запись по disconnectedSocketId, и если да, удалим ее. + if (this.players[disconnectedSocketId]) { + console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId}, удаляем ее.`); + this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_entry'); + } + return; + } + + if (playerEntry.socket.id !== disconnectedSocketId) { + console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игрок, вероятно, уже переподключился или сессия обновлена. Игнорируем дальнейшую логику "потенциального выхода" для этого устаревшего сокета.`); + if (this.players[disconnectedSocketId]) { + delete this.players[disconnectedSocketId]; // Удаляем только эту запись, не вызываем полный removePlayer + } + return; + } + + if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { + console.log(`[PCH ${this.gameId}] Игра уже завершена, не обрабатываем потенциальный выход для ${identifier}.`); + return; + } + if (playerEntry.isTemporarilyDisconnected) { + console.log(`[PCH ${this.gameId}] Игрок ${identifier} уже помечен как временно отключенный.`); + return; + } + + playerEntry.isTemporarilyDisconnected = true; + this.playerCount--; + console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`); + + const disconnectedName = playerEntry.name || this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`; + this.gameInstance.addToLog(`🔌 Игрок ${disconnectedName} отключился. Ожидание переподключения...`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.gameInstance.broadcastLogUpdate(); + + const otherPlayerRole = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const otherSocket = this.playerSockets[otherPlayerRole]; + const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole); + + if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { + otherSocket.emit('opponentDisconnected', { + disconnectedPlayerId: playerIdRole, + disconnectedCharacterName: disconnectedName, + }); + } + + if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isConfiguredForAiMove))) { + this.pausedTurnState = this.gameInstance.turnTimer.pause(); + console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState)); + } else { + this.pausedTurnState = null; + } + + this.clearReconnectTimer(playerIdRole); + const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000; + const reconnectStartTime = Date.now(); + + const updateInterval = setInterval(() => { + const remaining = reconnectDuration - (Date.now() - reconnectStartTime); + if (remaining <= 0 || !this.reconnectTimers[playerIdRole] || this.reconnectTimers[playerIdRole]?.timerId === null) { // Добавлена проверка на существование таймера + if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); + if (this.reconnectTimers[playerIdRole]) this.reconnectTimers[playerIdRole].updateIntervalId = null; // Помечаем, что интервал очищен + this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); + return; + } + this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: Math.ceil(remaining) }); + }, 1000); + + const timeoutId = setTimeout(() => { + if (this.reconnectTimers[playerIdRole]?.updateIntervalId) { // Очищаем интервал, если он еще существует + clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); + this.reconnectTimers[playerIdRole].updateIntervalId = null; + } + this.reconnectTimers[playerIdRole].timerId = null; // Помечаем, что основной таймаут сработал или очищен + + const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); + if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) { + this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout"); + } + }, reconnectDuration); + this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; + } + + handlePlayerReconnected(playerIdRole, newSocket) { + const identifier = newSocket.userData?.userId; + console.log(`[PCH RECONNECT_ATTEMPT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${identifier}, NewSocket: ${newSocket.id}`); + + if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { + newSocket.emit('gameError', { message: 'Игра уже завершена.' }); + return false; + } + + let playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); + console.log(`[PCH RECONNECT_ATTEMPT] Found playerEntry:`, playerEntry ? {id: playerEntry.id, identifier: playerEntry.identifier, oldSocketId: playerEntry.socket?.id, isTempDisc: playerEntry.isTemporarilyDisconnected} : null); + + if (playerEntry) { + const oldSocket = playerEntry.socket; + + // Обновляем сокет в playerEntry и в this.players / this.playerSockets, если сокет новый + if (oldSocket && oldSocket.id !== newSocket.id) { + console.log(`[PCH ${this.gameId}] New socket ${newSocket.id} for player ${identifier}. Old socket: ${oldSocket.id}. Updating records.`); + if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; // Удаляем старую запись по старому socket.id + this.players[newSocket.id] = playerEntry; // Убеждаемся, что по новому ID есть актуальная запись + if (oldSocket.connected) { // Пытаемся корректно закрыть старый сокет + console.log(`[PCH ${this.gameId}] Disconnecting old stale socket ${oldSocket.id}.`); + oldSocket.disconnect(true); + } + } + playerEntry.socket = newSocket; // Обновляем сокет в существующей playerEntry + + if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) { + // Если вдруг playerEntry был взят по старому socket.id, и этот ID теперь должен быть удален + delete this.players[oldSocket.id]; + } + this.playerSockets[playerIdRole] = newSocket; // Обновляем авторитетный сокет для роли + + // Всегда заново присоединяем сокет к комнате + console.log(`[PCH ${this.gameId}] Forcing newSocket ${newSocket.id} (identifier: ${identifier}) to join room ${this.gameId} during reconnect.`); + newSocket.join(this.gameId); + + + if (playerEntry.isTemporarilyDisconnected) { + console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`); + this.clearReconnectTimer(playerIdRole); // Очищаем таймер реконнекта + this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сообщаем UI, что таймер остановлен + + playerEntry.isTemporarilyDisconnected = false; + this.playerCount++; // Восстанавливаем счетчик активных игроков + } else { + // Игрок не был помечен как временно отключенный. + // Это может быть F5 или запрос состояния на "том же" (или новом, но старый не отвалился) сокете. + // playerCount не меняется, т.к. игрок считался активным. + console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Old socket ID: ${oldSocket?.id}`); + } + + // Обновление имени + if (this.gameInstance.gameState && this.gameInstance.gameState[playerIdRole]?.name) { + playerEntry.name = this.gameInstance.gameState[playerIdRole].name; + } else { + const charData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); + playerEntry.name = charData?.baseStats?.name || playerEntry.chosenCharacterKey; + } + console.log(`[PCH ${this.gameId}] Имя игрока ${identifier} обновлено/установлено на: ${playerEntry.name}`); + + this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM); + this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole); + + if (playerEntry.isTemporarilyDisconnected === false && this.pausedTurnState) { // Если игрок был временно отключен, isTemporarilyDisconnected уже false + this.resumeGameLogicAfterReconnect(playerIdRole); + } else if (playerEntry.isTemporarilyDisconnected === false && !this.pausedTurnState) { + // Игрок не был temp disconnected, и не было сохраненного состояния таймера (значит, он и не останавливался из-за этого игрока) + // Просто отправляем текущее состояние таймера, если он активен + console.log(`[PCH ${this.gameId}] Player was not temp disconnected, and no pausedTurnState. Forcing timer update if active.`); + if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive() && this.gameInstance.turnTimer.onTickCallback) { + const tt = this.gameInstance.turnTimer; + const elapsedTime = Date.now() - tt.segmentStartTimeMs; + const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime); + tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState); + } else if (this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused() && !this.isGameEffectivelyPaused()) { + // Если таймер не активен, не на паузе, и игра не на общей паузе - возможно, его нужно запустить (если сейчас ход этого игрока) + const gs = this.gameInstance.gameState; + if (gs && !gs.isGameOver) { + const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID); + const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn; + if(isHisTurnNow || isAiTurnNow) { + console.log(`[PCH ${this.gameId}] Timer not active, not paused. Game not paused. Attempting to start timer for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`); + this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow); + if (isAiTurnNow && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { + // Доп. проверка, чтобы AI точно пошел, если это его ход и таймер не стартовал для него как "AI move" + setTimeout(() => { + if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { + this.gameInstance.processAiTurn(); + } + }, GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } + } + } + } + return true; + + } else { // playerEntry не найден + console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена. Это может быть новый игрок или сессия истекла.`); + // Если это новый игрок для этой роли, то addPlayer должен был быть вызван GameManager'ом. + // Если PCH вызывается напрямую, и игрока нет, это ошибка или устаревший запрос. + newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена). Попробуйте создать игру заново.' }); + return false; + } + } + + sendFullGameStateOnReconnect(socket, playerEntry, playerIdRole) { + console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`); + if (!this.gameInstance.gameState) { + console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`); + if (!this.gameInstance.initializeGame()) { // initializeGame должен установить gameState + this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.'); + return; + } + console.log(`[PCH SEND_STATE_RECONNECT] gameState инициализирован. Player: ${this.gameInstance.gameState.player.name}, Opponent: ${this.gameInstance.gameState.opponent.name}`); + } + + const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); + const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + + // Получаем ключ оппонента из gameState ИЛИ из сохраненных ключей в GameInstance + let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey || + (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey); + const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; + + // Обновляем имена в gameState на основе сохраненных в PCH или данных персонажей + if (this.gameInstance.gameState) { + if (this.gameInstance.gameState[playerIdRole]) { + this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок'; + } + const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey); + if (this.gameInstance.gameState[oppRoleKey]) { + if (opponentPCHEntry?.name) { + this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name; + } else if (oData?.baseStats?.name) { + this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name; + } else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) { + this.gameInstance.gameState[oppRoleKey].name = 'Балард'; // Фоллбэк для AI + } else { + this.gameInstance.gameState[oppRoleKey].name = 'Оппонент'; + } + } + } + console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`); + + socket.emit('gameStarted', { // Используем 'gameStarted' для полной синхронизации состояния + gameId: this.gameId, + yourPlayerId: playerIdRole, + initialGameState: this.gameInstance.gameState, + playerBaseStats: pData?.baseStats, + opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, + playerAbilities: pData?.abilities, + opponentAbilities: oData?.abilities || [], + log: this.gameInstance.consumeLogBuffer(), + clientConfig: { ...GAME_CONFIG } + }); + } + + resumeGameLogicAfterReconnect(reconnectedPlayerIdRole) { + const playerEntry = Object.values(this.players).find(p => p.id === reconnectedPlayerIdRole); + const reconnectedName = playerEntry?.name || this.gameInstance.gameState?.[reconnectedPlayerIdRole]?.name || `Игрок (Роль ${reconnectedPlayerIdRole})`; + console.log(`[PCH RESUME_LOGIC] gameId: ${this.gameId}, Role: ${reconnectedPlayerIdRole}, Name: ${reconnectedName}, PausedState: ${JSON.stringify(this.pausedTurnState)}, TimerActive: ${this.gameInstance.turnTimer?.isActive()}, GS.isPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`); + + const otherPlayerRole = reconnectedPlayerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const otherSocket = this.playerSockets[otherPlayerRole]; + const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole); + if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { + otherSocket.emit('playerReconnected', { + reconnectedPlayerId: reconnectedPlayerIdRole, + reconnectedPlayerName: reconnectedName + }); + if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи другому игроку + otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() }); + } + } + + // Обновляем состояние для всех (включая переподключившегося, т.к. его лог мог быть уже потреблен) + this.gameInstance.broadcastGameStateUpdate(); // Это отправит gameState и оставшиеся логи + + + if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) { + // this.gameInstance.broadcastGameStateUpdate(); // Перенесено выше + + if (Object.keys(this.reconnectTimers).length === 0) { // Только если нет других ожидающих реконнекта + const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn; + const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS; + let resumedFromPausedState = false; + + if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') { + const gsTurnMatchesPausedTurn = (currentTurnIsForPlayerInGS && this.pausedTurnState.forPlayerRoleIsPlayer) || + (!currentTurnIsForPlayerInGS && !this.pausedTurnState.forPlayerRoleIsPlayer); + + if (gsTurnMatchesPausedTurn) { + console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`); + this.gameInstance.turnTimer.resume( + this.pausedTurnState.remainingTime, + this.pausedTurnState.forPlayerRoleIsPlayer, // Это isConfiguredForPlayerSlotTurn для таймера + this.pausedTurnState.isAiCurrentlyMoving // Это isConfiguredForAiMove для таймера + ); + resumedFromPausedState = true; + } else { + console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`); + } + this.pausedTurnState = null; // Сбрасываем в любом случае + } + + if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) { + console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался или был неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`); + this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer); + if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { + setTimeout(() => { + if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { + this.gameInstance.processAiTurn(); + } + }, GAME_CONFIG.DELAY_OPPONENT_TURN); + } + } else if (!resumedFromPausedState && this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive()){ + console.log(`[PCH ${this.gameId}] Таймер уже был активен при попытке перезапуска после реконнекта (pausedTurnState не использовался/неактуален). Ничего не делаем с таймером.`); + } + } else { + console.log(`[PCH ${this.gameId}] Возобновление логики таймера отложено, есть другие активные таймеры реконнекта: ${Object.keys(this.reconnectTimers)}`); + } + } else { + console.log(`[PCH ${this.gameId}] Игра на паузе или завершена, логика таймера не возобновляется. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameInstance.gameState?.isGameOver}`); + } + } + + clearReconnectTimer(playerIdRole) { + if (this.reconnectTimers[playerIdRole]) { + clearTimeout(this.reconnectTimers[playerIdRole].timerId); + this.reconnectTimers[playerIdRole].timerId = null; // Явно обнуляем + if (this.reconnectTimers[playerIdRole].updateIntervalId) { + clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); + this.reconnectTimers[playerIdRole].updateIntervalId = null; // Явно обнуляем + } + delete this.reconnectTimers[playerIdRole]; // Удаляем всю запись + console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`); + } + } + + clearAllReconnectTimers() { + console.log(`[PCH ${this.gameId}] Очистка ВСЕХ таймеров переподключения.`); + for (const roleId in this.reconnectTimers) { + this.clearReconnectTimer(roleId); + } + } + + isGameEffectivelyPaused() { + if (this.mode === 'pvp') { + if (this.playerCount < 2 && Object.keys(this.players).length > 0) { + const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); + + if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) { + return true; + } + } + } else if (this.mode === 'ai') { + const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния. + } + return false; + } + + getAllPlayersInfo() { + return { ...this.players }; + } + + getPlayerSockets() { + return { ...this.playerSockets }; + } +} + +module.exports = PlayerConnectionHandler; \ No newline at end of file diff --git a/server/game/instance/TurnTimer.js b/server/game/instance/TurnTimer.js new file mode 100644 index 0000000..2274458 --- /dev/null +++ b/server/game/instance/TurnTimer.js @@ -0,0 +1,237 @@ +// /server/game/instance/TurnTimer.js + +class TurnTimer { + constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') { + this.initialTurnDurationMs = turnDurationMs; + this.updateIntervalMs = updateIntervalMs; + this.onTimeoutCallback = onTimeoutCallback; + this.onTickCallback = onTickCallback; // (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic) + this.gameId = gameIdForLogs; + + this.timeoutId = null; + this.tickIntervalId = null; + + this.segmentStartTimeMs = 0; // Время начала текущего активного сегмента (после start/resume) + this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий сегмент + + this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе, не ход AI) + this.isManuallyPausedState = false; // Была ли вызвана pause() + + // Состояние, для которого таймер был запущен (или должен быть запущен) + this.isConfiguredForPlayerSlotTurn = false; + this.isConfiguredForAiMove = false; + + console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); + } + + _clearInternalTimers() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + if (this.tickIntervalId) { + clearInterval(this.tickIntervalId); + this.tickIntervalId = null; + } + } + + /** + * Запускает или перезапускает таймер хода. + * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'. + * @param {boolean} isAiMakingMove - true, если текущий ход делает AI. + * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени. + */ + start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { + console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, ManualPause: ${this.isManuallyPausedState}`); + this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском + + this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn; + this.isConfiguredForAiMove = isAiMakingMove; + + // Если это не resume (т.е. customRemainingTimeMs не передан явно как результат pause), + // то сбрасываем флаг ручной паузы. + if (customRemainingTimeMs === null) { + this.isManuallyPausedState = false; + } + + if (this.isConfiguredForAiMove) { + this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для игрока + console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking.`); + if (this.onTickCallback) { + // Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе + this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false); + } + return; + } + + // Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека) + this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0) + ? customRemainingTimeMs + : this.initialTurnDurationMs; + + this.segmentStartTimeMs = Date.now(); + this.isCurrentlyRunning = true; // Таймер теперь активен + // this.isManuallyPausedState остается как есть, если это был resume, или false, если это новый start + + console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`); + + this.timeoutId = setTimeout(() => { + console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); + // Проверяем, что таймер все еще должен был работать и не был на паузе + if (this.isCurrentlyRunning && !this.isManuallyPausedState) { + this._clearInternalTimers(); // Очищаем все, включая интервал + this.isCurrentlyRunning = false; + if (this.onTimeoutCallback) { + this.onTimeoutCallback(); + } + } else { + console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT ignored (not running or manually paused).`); + } + }, this.segmentDurationMs); + + this.tickIntervalId = setInterval(() => { + // Таймер должен обновлять UI только если он isCurrentlyRunning и НЕ isManuallyPausedState + // isManuallyPausedState проверяется в onTickCallback, который должен передать "isPaused" клиенту + if (!this.isCurrentlyRunning) { // Если таймер был остановлен (clear/timeout) + this._clearInternalTimers(); // Убедимся, что этот интервал тоже остановлен + return; + } + + const elapsedTime = Date.now() - this.segmentStartTimeMs; + const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime); + + if (this.onTickCallback) { + // Передаем isManuallyPausedState как состояние "паузы" для клиента + this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); + } + + // Не очищаем интервал здесь при remainingTime <= 0, пусть setTimeout это сделает. + // Отправка 0 - это нормально. + }, this.updateIntervalMs); + + // Немедленная первая отправка состояния таймера + if (this.onTickCallback) { + console.log(`[TurnTimer ${this.gameId}] Initial tick after START. Remaining: ${this.segmentDurationMs}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}, ManualPause: ${this.isManuallyPausedState}`); + this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); + } + } + + pause() { + console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, ManualPause: ${this.isManuallyPausedState}`); + + if (this.isManuallyPausedState) { // Уже на ручной паузе + console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Returning previous pause state.`); + // Нужно вернуть актуальное оставшееся время, которое было на момент установки паузы. + // segmentDurationMs при паузе сохраняет это значение. + if (this.onTickCallback) { // Уведомляем клиента еще раз, что на паузе + this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true); + } + return { + remainingTime: this.segmentDurationMs, // Это время, которое осталось на момент паузы + forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, + isAiCurrentlyMoving: this.isConfiguredForAiMove // Важно сохранить, чей ход это был + }; + } + + let remainingTimeToSave; + + if (this.isConfiguredForAiMove) { + // Если ход AI, таймер для игрока не тикал, у него полное время + remainingTimeToSave = this.initialTurnDurationMs; + console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining: ${remainingTimeToSave}ms for player turn.`); + } else if (this.isCurrentlyRunning) { + // Таймер активно работал для игрока/оппонента-человека + const elapsedTime = Date.now() - this.segmentStartTimeMs; + remainingTimeToSave = Math.max(0, this.segmentDurationMs - elapsedTime); + console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSave}ms from segment duration ${this.segmentDurationMs}ms.`); + } else { + // Таймер не был активен (например, уже истек, был очищен, или это был start() для AI) + // В этом случае, если не ход AI, то время 0 + remainingTimeToSave = 0; + console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`); + } + + this._clearInternalTimers(); + this.isCurrentlyRunning = false; + this.isManuallyPausedState = true; + this.segmentDurationMs = remainingTimeToSave; // Сохраняем оставшееся время для resume + + if (this.onTickCallback) { + console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE. Remaining: ${remainingTimeToSave}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`); + this.onTickCallback(remainingTimeToSave, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true + } + + return { + remainingTime: remainingTimeToSave, + forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, // Чей ход это был + isAiCurrentlyMoving: this.isConfiguredForAiMove // Был ли это ход AI + }; + } + + resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) { + console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. SavedRemaining: ${remainingTimeMs}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, ManualPauseBefore: ${this.isManuallyPausedState}`); + + if (!this.isManuallyPausedState) { + console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. Current state - IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}. Ignoring resume, let PCH handle start if needed.`); + // Если не был на ручной паузе, возможно, игра уже продолжается или была очищена. + // Не вызываем start() отсюда, чтобы избежать неожиданного поведения. + // PCH должен решить, нужен ли новый start(). + // Однако, если текущий ход совпадает, и таймер просто неактивен, можно запустить. + // Но лучше, чтобы PCH всегда вызывал start() с нуля, если resume не применим. + // Просто отправим текущее состояние, если onTickCallback есть. + if (this.onTickCallback) { + const currentElapsedTime = this.isCurrentlyRunning ? (Date.now() - this.segmentStartTimeMs) : 0; + const currentRemaining = this.isCurrentlyRunning ? Math.max(0, this.segmentDurationMs - currentElapsedTime) : this.segmentDurationMs; + this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); + } + return; + } + + if (remainingTimeMs <= 0 && !isAiMakingMove) { // Если не ход AI и время вышло + console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`); + this.isManuallyPausedState = false; // Сбрасываем флаг + this._clearInternalTimers(); // Убедимся, что все остановлено + this.isCurrentlyRunning = false; + if (this.onTimeoutCallback) { + this.onTimeoutCallback(); + } + return; + } + + // Сбрасываем флаг ручной паузы и запускаем таймер с сохраненным состоянием + this.isManuallyPausedState = false; + this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); // `start` теперь правильно обработает customRemainingTimeMs + } + + clear() { + console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); + this._clearInternalTimers(); + this.isCurrentlyRunning = false; + // При полном clear сбрасываем и ручную паузу, т.к. таймер полностью останавливается. + // `pause` использует этот метод, но затем сам выставляет isManuallyPausedState = true. + this.isManuallyPausedState = false; + this.segmentDurationMs = 0; // Сбрасываем сохраненную длительность + this.segmentStartTimeMs = 0; + + // Опционально: уведомить клиента, что таймер остановлен (например, null или 0) + // if (this.onTickCallback) { + // this.onTickCallback(null, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true (т.к. он остановлен) + // } + } + + isActive() { + // Таймер активен, если он isCurrentlyRunning и не на ручной паузе + return this.isCurrentlyRunning && !this.isManuallyPausedState; + } + + isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы + return this.isManuallyPausedState; + } + + // Этот геттер больше не нужен в таком виде, т.к. isConfiguredForAiMove хранит это состояние + // get isAiCurrentlyMakingMove() { + // return this.isConfiguredForAiMove && !this.isCurrentlyRunning; + // } +} + +module.exports = TurnTimer; \ No newline at end of file diff --git a/server/game/logic/aiLogic.js b/server/game/logic/aiLogic.js new file mode 100644 index 0000000..8c2ef34 --- /dev/null +++ b/server/game/logic/aiLogic.js @@ -0,0 +1,133 @@ +// /server/game/logic/aiLogic.js + +// GAME_CONFIG и gameData (или dataUtils) будут передаваться в decideAiAction как параметры, +// но для удобства можно импортировать GAME_CONFIG здесь, если он нужен для внутренних констант AI, +// не зависящих от передаваемого конфига. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужно для чего-то внутреннего + +/** + * Логика принятия решения для AI (Балард). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.). + * @param {object} configToUse - Конфигурационный объект игры (переданный GAME_CONFIG). + * @param {function} addToLogCallback - Функция для добавления лога (опционально, если AI должен логировать свои "мысли"). + * @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability' | 'pass', ability?: object, logMessage?: {message, type} }). + */ +function decideAiAction(currentGameState, dataUtils, configToUse, addToLogCallback) { + const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent + const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме) + + // Убеждаемся, что это AI Балард и есть необходимые данные + if (opponentState.characterKey !== 'balard' || !dataUtils) { + console.warn("[AI Logic] decideAiAction called for non-Balard opponent or missing dataUtils. Passing turn."); + if (addToLogCallback) addToLogCallback(`${opponentState.name || 'AI'} пропускает ход из-за внутренней ошибки.`, configToUse.LOG_TYPE_SYSTEM); + return { actionType: 'pass', logMessage: { message: `${opponentState.name || 'AI'} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + + const balardCharacterData = dataUtils.getCharacterData('balard'); + if (!balardCharacterData || !balardCharacterData.abilities) { + console.warn("[AI Logic] Failed to get Balard's character data or abilities. Passing turn."); + if (addToLogCallback) addToLogCallback(`AI Балард пропускает ход из-за ошибки загрузки данных.`, configToUse.LOG_TYPE_SYSTEM); + return { actionType: 'pass', logMessage: { message: `Балард пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + const balardAbilities = balardCharacterData.abilities; + + // Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены и т.п.) + const isBalardFullySilenced = opponentState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 + ); + + if (isBalardFullySilenced) { + // AI под полным безмолвием просто атакует. + // Лог о безмолвии добавляется в GameInstance перед вызовом этой функции или при обработке атаки. + // Здесь можно добавить лог о "вынужденной" атаке, если нужно. + if (addToLogCallback) { + // Проверяем, не был ли лог о безмолвии уже добавлен в этом ходу (чтобы не спамить) + // Это упрощенная проверка, в реальном приложении можно использовать флаги или более сложную логику. + // if (!currentGameState.logContainsThisTurn || !currentGameState.logContainsThisTurn.includes('под действием Безмолвия')) { + // addToLogCallback(`😵 ${opponentState.name} под действием Безмолвия! Атакует в смятении.`, configToUse.LOG_TYPE_EFFECT); + // if(currentGameState) currentGameState.logContainsThisTurn = (currentGameState.logContainsThisTurn || "") + 'под действием Безмолвия'; + // } + } + return { actionType: 'attack' }; + } + + const availableActions = []; + + // 1. Проверяем способность "Покровительство Тьмы" (Лечение) + const healAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_HEAL); + if (healAbility && opponentState.currentResource >= healAbility.cost && + (opponentState.abilityCooldowns?.[healAbility.id] || 0) <= 0 && // Общий КД + healAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + availableActions.push({ weight: 80, type: 'ability', ability: healAbility, requiresSuccessCheck: true, successRate: healAbility.successRate }); + } + + // 2. Проверяем способность "Эхо Безмолвия" + const silenceAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_SILENCE); + if (silenceAbility && opponentState.currentResource >= silenceAbility.cost && + (opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && // Спец. КД + (opponentState.abilityCooldowns?.[silenceAbility.id] || 0) <= 0 && // Общий КД + silenceAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + // Условие в silenceAbility.condition уже проверяет, что Елена не под безмолвием + availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE }); + } + + // 3. Проверяем способность "Похищение Света" (Вытягивание маны и урон) + const drainAbility = balardAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN); + if (drainAbility && opponentState.currentResource >= drainAbility.cost && + (opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && // Спец. КД + (opponentState.abilityCooldowns?.[drainAbility.id] || 0) <= 0 && // Общий КД + drainAbility.condition(opponentState, playerState, currentGameState, configToUse)) { + availableActions.push({ weight: 50, type: 'ability', ability: drainAbility }); + } + + // 4. Базовая атака - всегда доступна как запасной вариант с низким весом + availableActions.push({ weight: 30, type: 'attack' }); + + + if (availableActions.length === 0) { + // Этого не должно происходить, так как атака всегда добавляется + if (addToLogCallback) addToLogCallback(`${opponentState.name} не может совершить действие (нет доступных).`, configToUse.LOG_TYPE_INFO); + return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; + } + + // Сортируем действия по весу в порядке убывания (самые приоритетные в начале) + availableActions.sort((a, b) => b.weight - a.weight); + + // console.log(`[AI Logic] Available actions for Balard, sorted by weight:`, JSON.stringify(availableActions.map(a => ({type: a.type, name: a.ability?.name, weight: a.weight})), null, 2)); + + + // Перебираем действия в порядке приоритета и выбираем первое подходящее + for (const action of availableActions) { + if (action.type === 'ability') { + if (action.requiresSuccessCheck) { + // Для способностей с шансом успеха, "бросаем кубик" + if (Math.random() < action.successRate) { + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}" (попытка успешна)...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; + } else { + // Провал шанса, добавляем лог и ИИ переходит к следующему действию в списке (если есть) + if (addToLogCallback) addToLogCallback(`💨 ${opponentState.name} пытался использовать "${action.ability.name}", но шанс не сработал!`, configToUse.LOG_TYPE_INFO); + continue; // Пробуем следующее приоритетное действие + } + } else { + // Способность без проверки шанса (например, Похищение Света) + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} решает использовать "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; + } + } else if (action.type === 'attack') { + // Атака - если дошли до нее, значит, более приоритетные способности не были выбраны или провалили шанс + if (addToLogCallback) addToLogCallback(`🦶 ${opponentState.name} решает атаковать...`, configToUse.LOG_TYPE_INFO); + return { actionType: 'attack' }; + } + } + + // Фоллбэк, если по какой-то причине ни одно действие не было выбрано (не должно происходить, если атака всегда есть) + console.warn("[AI Logic] AI Balard failed to select any action after iterating. Defaulting to pass."); + if (addToLogCallback) addToLogCallback(`${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, configToUse.LOG_TYPE_INFO); + return { actionType: 'pass', logMessage: { message: `${opponentState.name} пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; +} + +module.exports = { + decideAiAction +}; \ No newline at end of file diff --git a/server/game/logic/combatLogic.js b/server/game/logic/combatLogic.js new file mode 100644 index 0000000..525b862 --- /dev/null +++ b/server/game/logic/combatLogic.js @@ -0,0 +1,472 @@ +// /server/game/logic/combatLogic.js + +// GAME_CONFIG и dataUtils будут передаваться в функции как параметры. +// effectsLogic может потребоваться для импорта, если updateBlockingStatus используется здесь напрямую, +// но в вашем GameInstance.js он вызывается отдельно. +// const effectsLogic = require('./effectsLogic'); // Если нужно + +/** + * Обрабатывает базовую атаку одного бойца по другому. + * @param {object} attackerState - Состояние атакующего бойца из gameState. + * @param {object} defenderState - Состояние защищающегося бойца из gameState. + * @param {object} attackerBaseStats - Базовые статы атакующего (из dataUtils.getCharacterBaseStats). + * @param {object} defenderBaseStats - Базовые статы защищающегося (из dataUtils.getCharacterBaseStats). + * @param {object} currentGameState - Текущее полное состояние игры. + * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} dataUtils - Утилиты для доступа к данным игры. + * @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt, переданная для использования. + */ +function performAttack( + attackerState, + defenderState, + attackerBaseStats, + defenderBaseStats, + currentGameState, + addToLogCallback, + configToUse, + dataUtils, + getRandomTauntFunction +) { + // Расчет базового урона с вариацией + let damage = Math.floor( + attackerBaseStats.attackPower * + (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE) + ); + let wasBlocked = false; + let attackBonusesLog = []; // Для сбора информации о бонусах к атаке + + // --- ПРОВЕРКА И ПРИМЕНЕНИЕ БОНУСА ОТ ОТЛОЖЕННОГО БАФФА АТАКИ --- + const delayedAttackBuff = attackerState.activeEffects.find(eff => + eff.isDelayed && + (eff.id === configToUse.ABILITY_ID_NATURE_STRENGTH || eff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && + eff.turnsLeft > 0 && + !eff.justCast + ); + + if (delayedAttackBuff) { + console.log(`[CombatLogic performAttack] Found active delayed buff: ${delayedAttackBuff.name} for ${attackerState.name}`); + + let damageBonus = 0; + // Если бы были прямые бонусы к урону атаки от этих баффов, они бы рассчитывались здесь + // Например: + // if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH && configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS) { + // damageBonus = configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS; + // } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK && configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS) { + // damageBonus = configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS; + // } + + if (damageBonus > 0) { + damage += damageBonus; + attackBonusesLog.push(`урон +${damageBonus} от "${delayedAttackBuff.name}"`); + } + + let resourceRegenConfigKey = null; + if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH) { + resourceRegenConfigKey = 'NATURE_STRENGTH_MANA_REGEN'; + } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) { + resourceRegenConfigKey = 'ALMAGEST_DARK_ENERGY_REGEN'; // Предположительный ключ + } + + if (resourceRegenConfigKey && configToUse[resourceRegenConfigKey]) { + const regenAmount = configToUse[resourceRegenConfigKey]; + const actualRegen = Math.min(regenAmount, attackerBaseStats.maxResource - attackerState.currentResource); + if (actualRegen > 0) { + attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); + if (addToLogCallback) { + addToLogCallback( + `🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от "${delayedAttackBuff.name}"!`, + configToUse.LOG_TYPE_HEAL + ); + } + } + } + } + // --- КОНЕЦ ПРОВЕРКИ И ПРИМЕНЕНИЯ ОТЛОЖЕННОГО БАФФА АТАКИ --- + + // Проверка на блок + if (defenderState.isBlocking) { + const initialDamage = damage; + damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); + wasBlocked = true; + if (addToLogCallback) { + let blockLogMsg = `🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`; + if (attackBonusesLog.length > 0) { + blockLogMsg += ` (${attackBonusesLog.join(', ')})`; + } + addToLogCallback(blockLogMsg, configToUse.LOG_TYPE_BLOCK); + } + } else { + if (addToLogCallback) { + let hitLogMsg = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`; + if (attackBonusesLog.length > 0) { + hitLogMsg += ` (${attackBonusesLog.join(', ')})`; + } + addToLogCallback(hitLogMsg, configToUse.LOG_TYPE_DAMAGE); + } + } + + // Применяем урон, убеждаемся, что HP не ниже нуля + const actualDamageDealtToHp = Math.min(defenderState.currentHp, damage); // Сколько HP реально отнято (не может быть больше текущего HP) + defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); + + // --- Насмешка от защищающегося (defenderState) в ответ на атаку --- + if (getRandomTauntFunction && dataUtils) { + let subTriggerForTaunt = null; + if (wasBlocked) { + subTriggerForTaunt = 'attackBlocked'; + } else if (actualDamageDealtToHp > 0) { // Если не было блока, но был нанесен урон + subTriggerForTaunt = 'attackHits'; + } + // Можно добавить еще условие для промаха, если урон = 0 и не было блока (и actualDamageDealtToHp === 0) + // else if (damage <= 0 && !wasBlocked) { subTriggerForTaunt = 'attackMissed'; } // Если есть такой триггер + + if (subTriggerForTaunt) { + const attackerFullDataForTaunt = dataUtils.getCharacterData(attackerState.characterKey); + if (attackerFullDataForTaunt) { + const reactionTaunt = getRandomTauntFunction( + defenderState.characterKey, // Говорящий (защитник) + 'onOpponentAction', // Главный триггер + subTriggerForTaunt, // Подтриггер: 'attackBlocked' или 'attackHits' + configToUse, + attackerFullDataForTaunt, // Оппонент (атакующий) для говорящего + currentGameState + ); + if (reactionTaunt && reactionTaunt !== "(Молчание)") { + addToLogCallback(`${defenderState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); + } + } + } + } +} + + +/** + * применяет111 эффект способности. + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние бойца, применившего способность. + * @param {object} targetState - Состояние цели способности. + * @param {object} casterBaseStats - Базовые статы кастера. + * @param {object} targetBaseStats - Базовые статы цели. + * @param {object} currentGameState - Текущее полное состояние игры. + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурация игры. + * @param {object} dataUtils - Утилиты для доступа к данным игры. + * @param {function} getRandomTauntFunction - Функция gameLogic.getRandomTaunt. + * @param {function|null} checkIfActionWasSuccessfulFunction - (Опционально) Функция для проверки успеха действия для контекстных насмешек. + */ +function applyAbilityEffect( + ability, + casterState, + targetState, + casterBaseStats, + targetBaseStats, + currentGameState, + addToLogCallback, + configToUse, + dataUtils, + getRandomTauntFunction, + checkIfActionWasSuccessfulFunction // Пока не используется активно, outcome определяется внутри +) { + let abilityApplicationSucceeded = true; // Флаг общего успеха применения способности + let actionOutcomeForTaunt = null; // 'success' или 'fail' для специфичных насмешек (например, Безмолвие Баларда) + + switch (ability.type) { + case configToUse.ACTION_TYPE_HEAL: + const healAmount = Math.floor(ability.power * (configToUse.HEAL_VARIATION_MIN + Math.random() * configToUse.HEAL_VARIATION_RANGE)); + const actualHeal = Math.min(healAmount, casterBaseStats.maxHp - casterState.currentHp); + if (actualHeal > 0) { + casterState.currentHp = Math.round(casterState.currentHp + actualHeal); + if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет111 "${ability.name}" и восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL); + actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если таковая есть на хил + } else { + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет111 "${ability.name}", но не получает лечения (HP уже полное или хил = 0).`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } + break; + + case configToUse.ACTION_TYPE_DAMAGE: + let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); + let wasAbilityBlocked = false; + let actualDamageDealtByAbility = 0; + + if (targetState.isBlocking) { + const initialDamage = damage; + damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); + wasAbilityBlocked = true; + if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, configToUse.LOG_TYPE_BLOCK); + } + + actualDamageDealtByAbility = Math.min(targetState.currentHp, damage); + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage)); + + if (addToLogCallback && !wasAbilityBlocked) { + addToLogCallback(`💥 ${casterBaseStats.name} применяет111 "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`, configToUse.LOG_TYPE_DAMAGE); + } + + if (damage <= 0 && !wasAbilityBlocked) { // Если урон нулевой и не было блока (например, из-за резистов, которых пока нет) + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } else if (wasAbilityBlocked) { + actionOutcomeForTaunt = 'blocked'; // Специальный исход для реакции на блок способности + } else if (actualDamageDealtByAbility > 0) { + actionOutcomeForTaunt = 'hit'; // Специальный исход для реакции на попадание способностью + } else { + actionOutcomeForTaunt = 'fail'; // Если урон 0 и не было блока (например цель уже мертва и 0 хп) + } + break; + + case configToUse.ACTION_TYPE_BUFF: + let effectDescriptionBuff = ability.description; + if (typeof ability.descriptionFunction === 'function') { + effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); // targetBaseStats здесь может быть casterBaseStats, если бафф на себя + } + // Обычно баффы накладываются на кастера + casterState.activeEffects.push({ + id: ability.id, name: ability.name, description: effectDescriptionBuff, + type: ability.type, duration: ability.duration, + turnsLeft: ability.duration, + grantsBlock: !!ability.grantsBlock, + isDelayed: !!ability.isDelayed, + justCast: true + }); + if (ability.grantsBlock && casterState.activeEffects.find(e => e.id === ability.id && e.grantsBlock)) { + // Требуется effectsLogic.updateBlockingStatus(casterState); + // но GameInstance вызывает его в switchTurn, так что здесь можно не дублировать, если эффект не мгновенный + } + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если бафф на себя + break; + + case configToUse.ACTION_TYPE_DISABLE: + // Общее "полное безмолвие" от Елены или Альмагест + if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { + const effectIdFullSilence = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; + if (!targetState.activeEffects.some(e => e.id === effectIdFullSilence)) { + targetState.activeEffects.push({ + id: effectIdFullSilence, name: ability.name, description: ability.description, + type: ability.type, duration: ability.effectDuration, turnsLeft: ability.effectDuration, + power: ability.power, isFullSilence: true, justCast: true + }); + if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет111 "${ability.name}" на ${targetBaseStats.name}! Способности цели заблокированы на ${ability.effectDuration} хода!`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } + } + // Специальное Безмолвие Баларда + else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') { + const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE; + actionOutcomeForTaunt = success ? 'success' : 'fail'; // Этот outcome используется в tauntLogic + if (success) { + const targetAbilitiesList = dataUtils.getCharacterAbilities(targetState.characterKey); + const availableAbilitiesToSilence = targetAbilitiesList.filter(pa => + !targetState.disabledAbilities?.some(d => d.abilityId === pa.id) && + !targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) && + pa.id !== configToUse.ABILITY_ID_NONE // Исключаем "пустую" абилку, если она есть + ); + if (availableAbilitiesToSilence.length > 0) { + const abilityToSilence = availableAbilitiesToSilence[Math.floor(Math.random() * availableAbilitiesToSilence.length)]; + const turns = configToUse.SILENCE_DURATION; + targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); + targetState.activeEffects.push({ + id: `playerSilencedOn_${abilityToSilence.id}`, name: `Безмолвие: ${abilityToSilence.name}`, + description: `Способность "${abilityToSilence.name}" временно недоступна.`, + type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, + duration: turns, turnsLeft: turns + 1, justCast: true + }); + if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" у ${targetBaseStats.name} заблокировано на ${turns} хода!`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!`, configToUse.LOG_TYPE_INFO); + actionOutcomeForTaunt = 'fail'; // Переопределяем, т.к. нечего было глушить + } + } else { + if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!`, configToUse.LOG_TYPE_INFO); + } + } + break; + + case configToUse.ACTION_TYPE_DEBUFF: + const effectIdDebuff = 'effect_' + ability.id; // Уникальный ID для дебаффа на цели + if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { + let effectDescriptionDebuff = ability.description; + if (typeof ability.descriptionFunction === 'function') { + effectDescriptionDebuff = ability.descriptionFunction(configToUse, targetBaseStats); + } + targetState.activeEffects.push({ + id: effectIdDebuff, name: ability.name, description: effectDescriptionDebuff, + type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id, + duration: ability.effectDuration, turnsLeft: ability.effectDuration, + power: ability.power, justCast: true + }); + if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Эффект продлится ${ability.effectDuration} хода.`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } + break; + + case configToUse.ACTION_TYPE_DRAIN: // Пример для Манадрейна Баларда + if (casterState.characterKey === 'balard' && ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN) { + let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; + if (ability.powerDamage > 0) { + let baseDamageDrain = ability.powerDamage; + if (targetState.isBlocking) { // Маловероятно, что дрейны блокируются, но для полноты + baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION); + } + damageDealtDrain = Math.max(0, baseDamageDrain); + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damageDealtDrain)); + } + const potentialDrain = ability.powerManaDrain; + const actualDrain = Math.min(potentialDrain, targetState.currentResource); + + if (actualDrain > 0) { + targetState.currentResource = Math.max(0, Math.round(targetState.currentResource - actualDrain)); + manaDrained = actualDrain; + const potentialHeal = Math.floor(manaDrained * (ability.powerHealthGainFactor || 0)); // Убедимся, что фактор есть + const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp); + if (actualHealGain > 0) { + casterState.currentHp = Math.round(casterState.currentHp + actualHealGain); + healthGained = actualHealGain; + } + } + + let logMsgDrain = `⚡ ${casterBaseStats.name} применяет1111 "${ability.name}"! `; + if (damageDealtDrain > 0) logMsgDrain += `Наносит ${damageDealtDrain} урона ${targetBaseStats.name}. `; + if (manaDrained > 0) { + logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name}`; + if(healthGained > 0) logMsgDrain += ` и исцеляется на ${healthGained} HP!`; else logMsgDrain += `!`; + } else if (damageDealtDrain > 0) { + logMsgDrain += `${targetBaseStats.name} не имеет ${targetBaseStats.resourceName} для похищения.`; + } else { + logMsgDrain += `Не удалось ничего похитить у ${targetBaseStats.name}.`; + } + + if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); + + if (manaDrained <= 0 && damageDealtDrain <= 0 && healthGained <= 0) { + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } else { + actionOutcomeForTaunt = 'success'; + } + } + break; + + default: + if (addToLogCallback) addToLogCallback(`Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`, configToUse.LOG_TYPE_SYSTEM); + console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type} для способности ${ability?.id}`); + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } + + // --- Насмешка от цели (targetState) в ответ на применение способности оппонентом (casterState) --- + // Вызываем только если способность не была нацелена на самого себя И есть функция насмешек + if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id) { + const casterFullDataForTaunt = dataUtils.getCharacterData(casterState.characterKey); + if (casterFullDataForTaunt) { + let tauntContext = { abilityId: ability.id }; + + // Если для этой способности был определен исход (например, для безмолвия Баларда, или попадание/блок урона) + // Используем actionOutcomeForTaunt, который мы установили в switch-case выше + if (actionOutcomeForTaunt === 'success' || actionOutcomeForTaunt === 'fail' || actionOutcomeForTaunt === 'blocked' || actionOutcomeForTaunt === 'hit') { + tauntContext.outcome = actionOutcomeForTaunt; + } + // Для способностей типа DAMAGE, 'blocked' и 'hit' будут ключами в taunts.js (например, Elena onOpponentAction -> ABILITY_ID_ALMAGEST_DAMAGE -> blocked: [...]) + // Это не стандартные 'attackBlocked' и 'attackHits', а специфичные для реакции на *способность* + // Если вы хотите использовать общие 'attackBlocked'/'attackHits' и для способностей, вам нужно будет изменить логику в taunts.js + // или передавать здесь другие subTrigger'ы, если способность заблокирована/попала. + + const reactionTaunt = getRandomTauntFunction( + targetState.characterKey, // Кто говорит (цель способности) + 'onOpponentAction', // Триггер + tauntContext, // Контекст: ID способности кастера (оппонента) и, возможно, outcome + configToUse, + casterFullDataForTaunt, // Оппонент для говорящего - это кастер + currentGameState + ); + if (reactionTaunt && reactionTaunt !== "(Молчание)") { + addToLogCallback(`${targetState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); + } + } + } +} + + +/** + * Проверяет валидность использования способности. + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние бойца, который пытается применить способность. + * @param {object} targetState - Состояние цели (может быть тем же, что и casterState). + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @returns {object} - { isValid: boolean, reason: string|null } + */ +function checkAbilityValidity(ability, casterState, targetState, configToUse) { + if (!ability) return { isValid: false, reason: "Способность не найдена." }; + + if (casterState.currentResource < ability.cost) { + return { isValid: false, reason: `${casterState.name} пытается применить "${ability.name}", но не хватает ${casterState.resourceName} (${casterState.currentResource}/${ability.cost})!` }; + } + + if ((casterState.abilityCooldowns?.[ability.id] || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" еще на перезарядке (${casterState.abilityCooldowns[ability.id]} х.).` }; + } + + // Специальные кулдауны для Баларда + if (casterState.characterKey === 'balard') { + if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && (casterState.silenceCooldownTurns || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.silenceCooldownTurns} х.).` }; + } + if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && (casterState.manaDrainCooldownTurns || 0) > 0) { + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.manaDrainCooldownTurns} х.).` }; + } + } + + // Проверка на безмолвие + const isCasterFullySilenced = casterState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + const isAbilitySpecificallySilenced = casterState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); + if (isCasterFullySilenced) { + return { isValid: false, reason: `${casterState.name} не может использовать способности из-за полного безмолвия!` }; + } + if (isAbilitySpecificallySilenced) { + const specificSilenceEffect = casterState.disabledAbilities.find(dis => dis.abilityId === ability.id); + return { isValid: false, reason: `Способность "${ability.name}" у ${casterState.name} временно заблокирована (${specificSilenceEffect.turnsLeft} х.)!` }; + } + + + // Проверка наложения баффа, который уже активен (кроме обновляемых) + if (ability.type === configToUse.ACTION_TYPE_BUFF && casterState.activeEffects.some(e => e.id === ability.id)) { + // Исключение для "отложенных" баффов, которые можно обновлять (например, Сила Природы) + if (!ability.isDelayed) { // Если isDelayed не true, то нельзя обновлять. + return { isValid: false, reason: `Эффект "${ability.name}" уже активен у ${casterState.name}!` }; + } + } + + // Проверка наложения дебаффа, который уже активен на цели + const isTargetedDebuff = ability.type === configToUse.ACTION_TYPE_DEBUFF || + (ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE); // Безмолвие Баларда может пытаться наложиться повторно (и провалиться) + + if (isTargetedDebuff && targetState.id !== casterState.id) { // Убедимся, что это не бафф на себя, проверяемый как дебафф + const effectIdToCheck = (ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE) ? + (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest') : + ('effect_' + ability.id); + + if (targetState.activeEffects.some(e => e.id === effectIdToCheck)) { + return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` }; + } + } + + return { isValid: true, reason: null }; +} + + +module.exports = { + performAttack, + applyAbilityEffect, + checkAbilityValidity +}; \ No newline at end of file diff --git a/server/game/logic/cooldownLogic.js b/server/game/logic/cooldownLogic.js new file mode 100644 index 0000000..73e48a7 --- /dev/null +++ b/server/game/logic/cooldownLogic.js @@ -0,0 +1,154 @@ +// /server/game/logic/cooldownLogic.js + +// GAME_CONFIG будет передаваться в функции как параметр configToUse +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант + +/** + * Обрабатывает отсчет общих кулдаунов для способностей игрока в конце его хода. + * Длительность кулдауна уменьшается на 1. + * @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }). + * @param {Array} characterAbilities - Полный список способностей персонажа (для получения имени). + * @param {string} characterName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function processPlayerAbilityCooldowns(cooldownsObject, characterAbilities, characterName, addToLogCallback, configToUse) { + if (!cooldownsObject || !characterAbilities) { + // console.warn(`[CooldownLogic] processPlayerAbilityCooldowns: Missing cooldownsObject or characterAbilities for ${characterName}`); + return; + } + + for (const abilityId in cooldownsObject) { + // Проверяем, что свойство принадлежит самому объекту, а не прототипу, и что кулдаун активен + if (Object.prototype.hasOwnProperty.call(cooldownsObject, abilityId) && cooldownsObject[abilityId] > 0) { + cooldownsObject[abilityId]--; // Уменьшаем кулдаун + + if (cooldownsObject[abilityId] === 0) { + const ability = characterAbilities.find(ab => ab.id === abilityId); + if (ability && addToLogCallback) { + addToLogCallback( + `Способность "${ability.name}" персонажа ${characterName} снова готова!`, + configToUse.LOG_TYPE_INFO // Используем LOG_TYPE_INFO из переданного конфига + ); + } + } + } + } +} + +/** + * Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода. + * Длительность заглушения уменьшается на 1. + * @param {Array} disabledAbilitiesArray - Массив объектов заглушенных способностей. + * @param {Array} characterAbilities - Полный список способностей персонажа (для получения имени). + * @param {string} characterName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback, configToUse) { + if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) { + return; + } + + const stillDisabled = []; // Новый массив для активных заглушений + for (let i = 0; i < disabledAbilitiesArray.length; i++) { + const dis = disabledAbilitiesArray[i]; + dis.turnsLeft--; // Уменьшаем длительность заглушения + + if (dis.turnsLeft > 0) { + stillDisabled.push(dis); + } else { + // Заглушение закончилось + if (addToLogCallback) { + const ability = characterAbilities.find(ab => ab.id === dis.abilityId); + if (ability) { + addToLogCallback( + `Способность "${ability.name}" персонажа ${characterName} больше не заглушена!`, + configToUse.LOG_TYPE_INFO + ); + } else { + // Если способность не найдена по ID (маловероятно, но возможно при ошибках данных) + addToLogCallback( + `Заглушение для неизвестной способности персонажа ${characterName} (ID: ${dis.abilityId}) закончилось.`, + configToUse.LOG_TYPE_INFO + ); + } + } + // Также нужно удалить соответствующий эффект из activeEffects, если он там был (например, playerSilencedOn_X) + // Это должно происходить в effectsLogic.processEffects, когда эффект с id `playerSilencedOn_${dis.abilityId}` истекает. + // Здесь мы только управляем массивом `disabledAbilities`. + } + } + + // Обновляем исходный массив, удаляя истекшие заглушения + disabledAbilitiesArray.length = 0; // Очищаем массив (сохраняя ссылку, если она используется где-то еще) + disabledAbilitiesArray.push(...stillDisabled); // Добавляем обратно только те, что еще активны +} + +/** + * Устанавливает или обновляет кулдаун для способности. + * Также обрабатывает специальные внутренние кулдауны для Баларда. + * @param {object} ability - Объект способности, для которой устанавливается кулдаун. + * @param {object} casterState - Состояние персонажа, применившего способность. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + */ +function setAbilityCooldown(ability, casterState, configToUse) { + if (!ability || !casterState || !casterState.abilityCooldowns) { + console.warn("[CooldownLogic] setAbilityCooldown: Missing ability, casterState, or casterState.abilityCooldowns."); + return; + } + + let baseCooldown = 0; + if (typeof ability.cooldown === 'number' && ability.cooldown > 0) { // Убедимся, что исходный КД > 0 + baseCooldown = ability.cooldown; + } + + // Специальные внутренние КД для Баларда - они могут перебивать общий КД + if (casterState.characterKey === 'balard') { + if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && + typeof ability.internalCooldownFromConfig === 'string' && // Проверяем, что есть ключ для конфига + typeof configToUse[ability.internalCooldownFromConfig] === 'number') { + // Устанавливаем значение для специального счетчика КД Баларда + casterState.silenceCooldownTurns = configToUse[ability.internalCooldownFromConfig]; + // Этот специальный КД также становится текущим общим КД для этой способности + baseCooldown = configToUse[ability.internalCooldownFromConfig]; + } else if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && + typeof ability.internalCooldownValue === 'number') { // Здесь КД задан прямо в данных способности + casterState.manaDrainCooldownTurns = ability.internalCooldownValue; + baseCooldown = ability.internalCooldownValue; + } + } + + if (baseCooldown > 0) { + // Устанавливаем кулдаун. Добавляем +1, так как кулдаун уменьшится в конце текущего хода + // (когда будет вызван processPlayerAbilityCooldowns для этого персонажа). + casterState.abilityCooldowns[ability.id] = baseCooldown + 1; + } else { + // Если у способности нет базового кулдауна (baseCooldown === 0), + // убеждаемся, что в abilityCooldowns для нее стоит 0. + casterState.abilityCooldowns[ability.id] = 0; + } +} + +/** + * Обрабатывает специальные кулдауны для Баларда в конце его хода. + * @param {object} balardState - Состояние Баларда. + */ +function processBalardSpecialCooldowns(balardState) { + if (balardState.characterKey !== 'balard') return; + + if (balardState.silenceCooldownTurns !== undefined && balardState.silenceCooldownTurns > 0) { + balardState.silenceCooldownTurns--; + } + if (balardState.manaDrainCooldownTurns !== undefined && balardState.manaDrainCooldownTurns > 0) { + balardState.manaDrainCooldownTurns--; + } +} + + +module.exports = { + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns +}; \ No newline at end of file diff --git a/server/game/logic/effectsLogic.js b/server/game/logic/effectsLogic.js new file mode 100644 index 0000000..69295d5 --- /dev/null +++ b/server/game/logic/effectsLogic.js @@ -0,0 +1,153 @@ +// /server/game/logic/effectsLogic.js + +// GAME_CONFIG и dataUtils будут передаваться в функции как параметры. +// const GAME_CONFIG_STATIC = require('../../core/config'); // Если нужен для внутренних констант +// const DATA_UTILS_STATIC = require('../../data/dataUtils'); // Если нужен для внутренних констант + +/** + * Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода. + * Длительность эффекта уменьшается на 1. + * Периодические эффекты (DoT, сжигание ресурса и т.п.) срабатывают, если эффект не "justCast" в этом ходу. + * @param {Array} activeEffectsArray - Массив активных эффектов бойца (из gameState.player.activeEffects или gameState.opponent.activeEffects). + * @param {object} ownerState - Состояние бойца, на котором эффекты (currentHp, currentResource и т.д.). + * @param {object} ownerBaseStats - Базовые статы бойца (включая characterKey, name, maxHp, maxResource). + * @param {string} ownerRoleInGame - Роль бойца в игре ('player' или 'opponent'), для контекста. + * @param {object} currentGameState - Полное текущее состояние игры. + * @param {function} addToLogCallback - Функция для добавления сообщений в лог игры. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} dataUtils - Утилиты для доступа к данным игры (getCharacterData, getCharacterAbilities и т.д.). + */ +function processEffects( + activeEffectsArray, + ownerState, + ownerBaseStats, + ownerRoleInGame, // 'player' или 'opponent' + currentGameState, + addToLogCallback, + configToUse, + dataUtils +) { + if (!activeEffectsArray || activeEffectsArray.length === 0) { + return; + } + + const ownerName = ownerBaseStats.name; + const effectsToRemoveIndexes = []; + + for (let i = 0; i < activeEffectsArray.length; i++) { + const effect = activeEffectsArray[i]; + + // --- Применяем периодический эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен --- + if (!effect.justCast) { + // 1. Урон от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума) + // Эти эффекты наносят урон цели В КОНЦЕ ее хода. + if (effect.isFullSilence && typeof effect.power === 'number' && effect.power > 0) { + const damage = effect.power; // Урон, заложенный в эффекте + ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage)); + if (addToLogCallback) { + addToLogCallback( + `😵 Эффект "${effect.name}" наносит ${damage} урона персонажу ${ownerName}! (HP: ${ownerState.currentHp}/${ownerBaseStats.maxHp})`, + configToUse.LOG_TYPE_DAMAGE + ); + } + } + + // 2. Сжигание ресурса (Печать Слабости, Проклятие Увядания) + // Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода. + // ID эффекта на цели имеет префикс 'effect_' + ID способности, которая его наложила. + const isResourceBurnDebuff = effect.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || + effect.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF; + if (isResourceBurnDebuff && typeof effect.power === 'number' && effect.power > 0) { + const resourceToBurn = effect.power; // Количество ресурса, сжигаемое за ход + if (ownerState.currentResource > 0) { + const actualBurn = Math.min(ownerState.currentResource, resourceToBurn); + ownerState.currentResource = Math.max(0, Math.round(ownerState.currentResource - actualBurn)); + if (addToLogCallback) { + addToLogCallback( + `🔥 Эффект "${effect.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}! (Ресурс: ${ownerState.currentResource}/${ownerBaseStats.maxResource})`, + configToUse.LOG_TYPE_EFFECT + ); + } + } + } + // Примечание: Отложенные эффекты (isDelayed: true, например, Сила Природы) + // применяют свою основную силу в GameInstance.processPlayerAction (после атаки), а не здесь. + // Здесь они просто тикают по длительности. + } + + // --- Уменьшаем длительность --- + effect.turnsLeft--; + effect.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода + + // --- Отмечаем для удаления, если длительность закончилась --- + if (effect.turnsLeft <= 0) { + effectsToRemoveIndexes.push(i); + if (addToLogCallback) { + addToLogCallback( + `Эффект "${effect.name}" на персонаже ${ownerName} закончился.`, + configToUse.LOG_TYPE_EFFECT + ); + } + // Если это был эффект, дающий блок, нужно обновить статус блокировки + if (effect.grantsBlock) { + updateBlockingStatus(ownerState); // Вызываем сразу, т.к. эффект удаляется + } + // Если это был эффект заглушения конкретной способности (playerSilencedOn_X), + // то соответствующая запись в ownerState.disabledAbilities должна быть удалена в cooldownLogic.processDisabledAbilities. + // Здесь мы просто удаляем сам эффект из activeEffects. + } + } + + // Удаляем эффекты с конца массива, чтобы не нарушить индексы при удалении + for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) { + activeEffectsArray.splice(effectsToRemoveIndexes[i], 1); + } + + // После удаления всех истекших эффектов, еще раз обновляем статус блока, + // так как какой-то из удаленных эффектов мог быть последним дающим блок. + // (хотя updateBlockingStatus вызывается и при удалении конкретного блокирующего эффекта) + updateBlockingStatus(ownerState); +} + +/** + * Обновляет статус 'isBlocking' для бойца на основе его активных эффектов. + * Боец считается блокирующим, если у него есть хотя бы один активный эффект с флагом grantsBlock: true. + * @param {object} fighterState - Состояние бойца (объект из gameState.player или gameState.opponent). + */ +function updateBlockingStatus(fighterState) { + if (!fighterState || !fighterState.activeEffects) { + // console.warn("[EffectsLogic] updateBlockingStatus: fighterState or activeEffects missing."); + if (fighterState) fighterState.isBlocking = false; // Если нет эффектов, то точно не блокирует + return; + } + // Боец блокирует, если есть ХОТЯ БЫ ОДИН активный эффект, дающий блок + const wasBlocking = fighterState.isBlocking; + fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0); + + // Можно добавить лог, если статус блока изменился, для отладки + // if (wasBlocking !== fighterState.isBlocking && addToLogCallback) { + // addToLogCallback(`${fighterState.name} ${fighterState.isBlocking ? 'встает в защиту' : 'перестает защищаться'} из-за эффектов.`, 'info'); + // } +} + +/** + * Проверяет, находится ли персонаж под действием полного безмолвия. + * @param {object} characterState - Состояние персонажа из gameState. + * @param {object} configToUse - Конфигурационный объект игры. + * @returns {boolean} true, если персонаж под полным безмолвием, иначе false. + */ +function isCharacterFullySilenced(characterState, configToUse) { + if (!characterState || !characterState.activeEffects) { + return false; + } + return characterState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 + ); +} + + +module.exports = { + processEffects, + updateBlockingStatus, + isCharacterFullySilenced +}; \ No newline at end of file diff --git a/server/game/logic/gameStateLogic.js b/server/game/logic/gameStateLogic.js new file mode 100644 index 0000000..cafe475 --- /dev/null +++ b/server/game/logic/gameStateLogic.js @@ -0,0 +1,133 @@ +// /server/game/logic/gameStateLogic.js + +// GAME_CONFIG будет передаваться в функции как параметр configToUse. +// dataUtils также может передаваться, если нужен для какой-то логики здесь. + +/** + * Внутренняя проверка условий конца игры (основано на HP). + * @param {object} currentGameState - Текущее состояние игры. + * // configToUse и dataUtils здесь не используются, но могут понадобиться для более сложных условий + * @param {object} configToUse - Конфигурация игры. + * @param {object} dataUtils - Утилиты для доступа к данным. + * @returns {boolean} true, если игра окончена по HP, иначе false. + */ +function checkGameOverInternal(currentGameState, configToUse, dataUtils) { + if (!currentGameState || currentGameState.isGameOver) { + // Если игра уже помечена как оконченная, или нет состояния, возвращаем текущий статус + return currentGameState ? currentGameState.isGameOver : true; + } + + // Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами + if (!currentGameState.player || !currentGameState.opponent || + !currentGameState.player.characterKey || !currentGameState.opponent.characterKey || // Проверяем, что персонажи назначены + currentGameState.opponent.name === 'Ожидание игрока...' || // Дополнительная проверка на плейсхолдер + !currentGameState.opponent.maxHp || currentGameState.opponent.maxHp <= 0) { + return false; // Игра не может закончиться по HP, если один из бойцов не готов/не определен + } + + const playerDead = currentGameState.player.currentHp <= 0; + const opponentDead = currentGameState.opponent.currentHp <= 0; + + return playerDead || opponentDead; // Игра окончена, если хотя бы один мертв +} + +/** + * Определяет результат завершения игры (победитель, проигравший, причина). + * Вызывается, когда checkGameOverInternal вернул true или игра завершается по другой причине (дисконнект, таймаут). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} configToUse - Конфигурация игры (GAME_CONFIG). + * @param {string} gameMode - Режим игры ('ai' или 'pvp'). + * @param {string} [explicitReason=null] - Явная причина завершения (например, 'turn_timeout', 'opponent_disconnected'). + * Если null, причина определяется по HP. + * @param {string} [explicitWinnerRole=null] - Явный победитель (если известен, например, при дисконнекте). + * @param {string} [explicitLoserRole=null] - Явный проигравший (если известен). + * @returns {{isOver: boolean, winnerRole: string|null, loserRole: string|null, reason: string, logMessage: string}} + */ +function getGameOverResult( + currentGameState, + configToUse, + gameMode, + explicitReason = null, + explicitWinnerRole = null, + explicitLoserRole = null +) { + if (!currentGameState) { + return { isOver: true, winnerRole: null, loserRole: null, reason: 'error_no_gamestate', logMessage: 'Ошибка: нет состояния игры.' }; + } + + // Если причина уже задана (например, дисконнект или таймаут), используем ее + if (explicitReason) { + let winnerName = explicitWinnerRole ? (currentGameState[explicitWinnerRole]?.name || explicitWinnerRole) : 'Никто'; + let loserName = explicitLoserRole ? (currentGameState[explicitLoserRole]?.name || explicitLoserRole) : 'Никто'; + let logMsg = ""; + + if (explicitReason === 'turn_timeout') { + logMsg = `⏱️ Время хода для ${loserName} истекло! Победа присуждается ${winnerName}!`; + } else if (explicitReason === 'opponent_disconnected') { + logMsg = `🔌 Игрок ${loserName} отключился. Победа присуждается ${winnerName}!`; + if (gameMode === 'ai' && explicitLoserRole === configToUse.PLAYER_ID) { // Игрок отключился в AI игре + winnerName = currentGameState.opponent?.name || 'AI'; // AI "выиграл" по факту, но не формально + logMsg = `🔌 Игрок ${loserName} отключился. Игра завершена.`; + explicitWinnerRole = null; // В AI режиме нет формального победителя при дисконнекте игрока + } + } else { + logMsg = `Игра завершена. Причина: ${explicitReason}. Победитель: ${winnerName}.`; + } + + return { + isOver: true, + winnerRole: explicitWinnerRole, + loserRole: explicitLoserRole, + reason: explicitReason, + logMessage: logMsg + }; + } + + // Если явной причины нет, проверяем по HP + const playerDead = currentGameState.player?.currentHp <= 0; + const opponentDead = currentGameState.opponent?.currentHp <= 0; + + if (!playerDead && !opponentDead) { + return { isOver: false, winnerRole: null, loserRole: null, reason: 'not_over_hp', logMessage: "" }; // Игра еще не окончена по HP + } + + let winnerRole = null; + let loserRole = null; + let reason = 'hp_zero'; + let logMessage = ""; + + if (gameMode === 'ai') { + if (playerDead) { // Игрок проиграл AI + winnerRole = configToUse.OPPONENT_ID; // AI победил + loserRole = configToUse.PLAYER_ID; + logMessage = `😭 ПОРАЖЕНИЕ! ${currentGameState.opponent.name} оказался сильнее! 😭`; + } else { // Игрок победил AI (opponentDead) + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `🏁 ПОБЕДА! Вы одолели ${currentGameState.opponent.name}! 🏁`; + } + } else { // PvP режим + if (playerDead && opponentDead) { // Ничья - победа присуждается игроку в слоте 'player' (или по другим правилам) + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `⚔️ Ничья! Оба бойца пали! Победа присуждается ${currentGameState.player.name} по правилам арены!`; + reason = 'draw_player_wins'; + } else if (playerDead) { + winnerRole = configToUse.OPPONENT_ID; + loserRole = configToUse.PLAYER_ID; + logMessage = `🏁 ПОБЕДА! ${currentGameState.opponent.name} одолел(а) ${currentGameState.player.name}! 🏁`; + } else { // opponentDead + winnerRole = configToUse.PLAYER_ID; + loserRole = configToUse.OPPONENT_ID; + logMessage = `🏁 ПОБЕДА! ${currentGameState.player.name} одолел(а) ${currentGameState.opponent.name}! 🏁`; + } + } + + return { isOver: true, winnerRole, loserRole, reason, logMessage }; +} + + +module.exports = { + checkGameOverInternal, + getGameOverResult +}; \ No newline at end of file diff --git a/server/game/logic/index.js b/server/game/logic/index.js new file mode 100644 index 0000000..780102b --- /dev/null +++ b/server/game/logic/index.js @@ -0,0 +1,66 @@ +// /server/game/logic/index.js + +// Импортируем функции из всех специализированных логических модулей + +const { + performAttack, + applyAbilityEffect, + checkAbilityValidity +} = require('./combatLogic'); + +const { + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns +} = require('./cooldownLogic'); + +const { + processEffects, + updateBlockingStatus, + isCharacterFullySilenced +} = require('./effectsLogic'); + +const { + decideAiAction +} = require('./aiLogic'); + +const { + getRandomTaunt +} = require('./tauntLogic'); // Предполагаем, что getRandomTaunt вынесен в tauntLogic.js + +const { + checkGameOverInternal, // Внутренняя проверка на HP + getGameOverResult // Определяет победителя и причину для checkGameOver в GameInstance +} = require('./gameStateLogic'); // Предполагаем, что логика завершения игры вынесена + + +// Экспортируем все импортированные функции, чтобы они были доступны +// через единый объект 'gameLogic' в GameInstance.js +module.exports = { + // Combat Logic + performAttack, + applyAbilityEffect, + checkAbilityValidity, + + // Cooldown Logic + processPlayerAbilityCooldowns, + processDisabledAbilities, + setAbilityCooldown, + processBalardSpecialCooldowns, + + // Effects Logic + processEffects, + updateBlockingStatus, + isCharacterFullySilenced, + + // AI Logic + decideAiAction, + + // Taunt Logic + getRandomTaunt, + + // Game State Logic (например, для условий завершения) + checkGameOverInternal, + getGameOverResult +}; \ No newline at end of file diff --git a/server/game/logic/tauntLogic.js b/server/game/logic/tauntLogic.js new file mode 100644 index 0000000..1356631 --- /dev/null +++ b/server/game/logic/tauntLogic.js @@ -0,0 +1,151 @@ +// /server/game/logic/tauntLogic.js +const GAME_CONFIG = require('../../core/config'); +// Предполагаем, что gameData.tauntSystem импортируется или доступен. +// Если tauntSystem экспортируется напрямую из data/taunts.js: +// const { tauntSystem } = require('../../data/taunts'); +// Если он часть общего gameData, который собирается в data/index.js: +const gameData = require('../../data'); // Тогда используем gameData.tauntSystem + +/** + * Получает случайную насмешку из системы насмешек. + * @param {string} speakerCharacterKey - Ключ персонажа, который говорит. + * @param {string} trigger - Тип триггера насмешки (например, 'selfCastAbility', 'onBattleState', 'onOpponentAction'). + * @param {string|number|object} [subTriggerOrContext={}] - Может быть ID способности, специфичный ключ состояния ('start', 'dominating') или объект контекста. + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @param {object} opponentFullData - Полные данные персонажа, к которому обращена насмешка (цель). + * @param {object} currentGameState - Текущее полное состояние игры. + * @returns {string} Текст насмешки или "(Молчание)". + */ +function getRandomTaunt(speakerCharacterKey, trigger, subTriggerOrContext = {}, configToUse, opponentFullData, currentGameState) { + // console.log(`[TauntLogic DEBUG] Called with: speaker=${speakerCharacterKey}, trigger=${trigger}, subTriggerOrContext=`, subTriggerOrContext, `opponentKey=${opponentFullData?.baseStats?.characterKey}`); + + const tauntSystemToUse = gameData.tauntSystem || (gameData.default && gameData.default.tauntSystem); // Совместимость, если gameData имеет default экспорт + if (!tauntSystemToUse) { + console.error("[TauntLogic ERROR] tauntSystem is not available from gameData import!"); + return "(Молчание)"; + } + + const speakerTauntBranch = tauntSystemToUse[speakerCharacterKey]; + if (!speakerTauntBranch) { + // console.log(`[TauntLogic] No taunt branch for speaker: ${speakerCharacterKey}`); + return "(Молчание)"; + } + + const opponentKeyForTaunts = opponentFullData?.baseStats?.characterKey; + if (!opponentKeyForTaunts) { + // console.log(`[TauntLogic] Opponent key for taunts not available for speaker ${speakerCharacterKey}, trigger ${trigger}. OpponentData:`, opponentFullData); + // Особый случай для старта AI игры, где оппонент (AI Балард) может быть известен, даже если opponentFullData не полон + if (trigger === 'onBattleState' && subTriggerOrContext === 'start' && speakerCharacterKey === 'elena' && currentGameState.gameMode === 'ai') { + // Елена против Баларда (AI) в начале боя + const elenaVsBalardStartTaunts = speakerTauntBranch.balard?.onBattleState?.start; + if (Array.isArray(elenaVsBalardStartTaunts) && elenaVsBalardStartTaunts.length > 0) { + return elenaVsBalardStartTaunts[Math.floor(Math.random() * elenaVsBalardStartTaunts.length)] || "(Молчание)"; + } + } + return "(Молчание)"; + } + + const specificTauntBranch = speakerTauntBranch[opponentKeyForTaunts]; + if (!specificTauntBranch || !specificTauntBranch[trigger]) { + // console.log(`[TauntLogic] No specific taunt branch or trigger branch for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}`); + return "(Молчание)"; + } + + let tauntSet = specificTauntBranch[trigger]; + let context = {}; + let subTriggerKey = null; // Это будет ключ для прямого доступа к массиву насмешек, например, ID способности или 'start' + + if (typeof subTriggerOrContext === 'string' || typeof subTriggerOrContext === 'number') { + subTriggerKey = subTriggerOrContext; + // Если subTriggerOrContext - это ID способности, помещаем его в контекст для onOpponentAction + if (trigger === 'onOpponentAction' || trigger === 'selfCastAbility') { + context.abilityId = subTriggerOrContext; + } + } else if (typeof subTriggerOrContext === 'object' && subTriggerOrContext !== null) { + context = { ...subTriggerOrContext }; + // Если ID способности передан в контексте, используем его как subTriggerKey для прямого доступа + if (context.abilityId && (trigger === 'selfCastAbility' || trigger === 'onOpponentAction')) { + subTriggerKey = context.abilityId; + } else if (trigger === 'onBattleState' && typeof context === 'string') { // на случай если GameInstance передает строку для onBattleState + subTriggerKey = context; + } + } + // Для basicAttack subTriggerKey может быть 'merciful', 'dominating' или null (тогда general) + if (trigger === 'basicAttack' && typeof subTriggerOrContext === 'string') { + subTriggerKey = subTriggerOrContext; + } + + + // console.log(`[TauntLogic DEBUG] Parsed: trigger=${trigger}, subTriggerKey=${subTriggerKey}, context=`, context); + + let potentialTaunts = []; + + if (subTriggerKey !== null && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && tauntSet[subTriggerKey]) { + // Если есть subTriggerKey и tauntSet - это объект (а не массив), то получаем вложенный набор + tauntSet = tauntSet[subTriggerKey]; + } else if (Array.isArray(tauntSet)) { + // Если tauntSet уже массив (например, для onOpponentAttackBlocked), используем его как есть + potentialTaunts = tauntSet; // Присваиваем сразу + } else if (typeof tauntSet === 'object' && tauntSet.general) { // Фоллбэк на general, если subTriggerKey не найден в объекте + tauntSet = tauntSet.general; + } + + + // Специальная обработка для onOpponentAction с исходом (success/fail) + if (trigger === 'onOpponentAction' && typeof tauntSet === 'object' && !Array.isArray(tauntSet) && context.outcome) { + if (tauntSet[context.outcome]) { + potentialTaunts = tauntSet[context.outcome]; + } else { + // console.log(`[TauntLogic] No outcome '${context.outcome}' for onOpponentAction, abilityId ${context.abilityId}`); + potentialTaunts = []; // Явно пустой, чтобы не упасть ниже + } + } else if (Array.isArray(tauntSet)) { + potentialTaunts = tauntSet; + } + + + // Обработка basicAttack (merciful/dominating/general) + if (trigger === 'basicAttack' && specificTauntBranch.basicAttack) { // Убедимся что ветка basicAttack существует + const basicAttackBranch = specificTauntBranch.basicAttack; + if (speakerCharacterKey === 'elena' && opponentKeyForTaunts === 'balard' && currentGameState && currentGameState[GAME_CONFIG.OPPONENT_ID]) { + const opponentState = currentGameState[GAME_CONFIG.OPPONENT_ID]; // Балард всегда оппонент для Елены в этом контексте + if (opponentState && opponentState.maxHp > 0) { + const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100; + if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT && basicAttackBranch.dominating) { + potentialTaunts = basicAttackBranch.dominating; + } else if (basicAttackBranch.merciful) { + potentialTaunts = basicAttackBranch.merciful; + } else if (basicAttackBranch.general) { // Фоллбэк на general если нет merciful + potentialTaunts = basicAttackBranch.general; + } + } else if (basicAttackBranch.general) { // Если нет HP данных, используем general + potentialTaunts = basicAttackBranch.general; + } + } else if (basicAttackBranch.general) { // Общий случай для basicAttack + potentialTaunts = basicAttackBranch.general; + } + // Если subTriggerKey был ('merciful'/'dominating') и он найден в basicAttackBranch, то tauntSet уже установлен выше + // Этот блок if (trigger === 'basicAttack') должен быть более специфичным или объединен с логикой subTriggerKey выше. + // Пока оставим как есть, предполагая, что subTriggerKey для basicAttack обрабатывается отдельно. + // Если subTriggerKey был 'merciful' или 'dominating', и такой ключ есть в basicAttackBranch, то tauntSet уже должен быть им. + if (subTriggerKey && basicAttackBranch[subTriggerKey]) { + potentialTaunts = basicAttackBranch[subTriggerKey]; + } else if (potentialTaunts.length === 0 && basicAttackBranch.general) { // Если не нашли по subTriggerKey, берем general + potentialTaunts = basicAttackBranch.general; + } + } + + + if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) { + // console.log(`[TauntLogic] No potential taunts found or empty array for ${speakerCharacterKey} vs ${opponentKeyForTaunts}, trigger: ${trigger}, subTriggerKey: ${subTriggerKey}`); + return "(Молчание)"; + } + + const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)]; + // console.log(`[TauntLogic] Selected for ${speakerCharacterKey} vs ${opponentKeyForTaunts} (Trigger: ${trigger}, SubTriggerKey: ${subTriggerKey}): "${selectedTaunt}"`); + return selectedTaunt || "(Молчание)"; +} + +module.exports = { + getRandomTaunt +}; \ No newline at end of file diff --git a/server/services/SocketService.js b/server/services/SocketService.js new file mode 100644 index 0000000..e69de29 diff --git a/server/views/index.ejs b/server/views/index.ejs new file mode 100644 index 0000000..da859d7 --- /dev/null +++ b/server/views/index.ejs @@ -0,0 +1,237 @@ + + + + + + Битва: Елена vs Балард (Сетевая Версия) + + + + + + + + +
+ +
+
+
Ожидание подключения к серверу...
+
+ +
+

Вход / Регистрация

+
+

Регистрация

+ + + +
+
+
+

Вход

+ + + +
+
+ + +
+ + + + + + + + + + + + + + + + \ No newline at end of file