diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dbf0821
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules/*
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..92de514
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,850 @@
+{
+ "name": "texas-holdem-online",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "texas-holdem-online",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "express": "^4.18.2",
+ "ws": "^8.16.0"
+ }
+ },
+ "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/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "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/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "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.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "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/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "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/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/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/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": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "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": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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/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-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.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "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/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": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "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/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/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "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/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/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": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "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.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "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": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "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/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/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/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "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/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.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": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "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/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "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..3ac7e0e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "texas-holdem-online",
+ "version": "1.0.0",
+ "description": "Онлайн Texas Hold'em No-Limit Poker с мультиплеером и ИИ",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js"
+ },
+ "dependencies": {
+ "ws": "^8.16.0",
+ "express": "^4.18.2"
+ },
+ "keywords": ["poker", "texas-holdem", "websocket", "game"],
+ "author": "",
+ "license": "MIT"
+}
diff --git a/public/ai.js b/public/ai.js
new file mode 100644
index 0000000..7b32641
--- /dev/null
+++ b/public/ai.js
@@ -0,0 +1,1010 @@
+/**
+ * =============================================================================
+ * Texas Hold'em - ИИ Оппонент
+ * 3 уровня сложности:
+ * 1 - Случайный (новичок)
+ * 2 - Тайтово-агрессивный по диапазонам
+ * 3 - Equity-based + блеф-блокеры
+ * =============================================================================
+ */
+
+const pokerAI = {
+ // Персональности ботов с системными промптами для LLM
+ personalities: [
+ {
+ name: 'Виктор "Акула"',
+ avatar: '🦈',
+ style: 'aggressive',
+ systemPrompt: `Ты Виктор, опытный профессиональный игрок в покер с прозвищем "Акула".
+Ты играешь уже 20 лет, у тебя несколько браслетов WSOP.
+Твой стиль - агрессивный и уверенный. Ты часто подшучиваешь над соперниками,
+используешь покерный сленг (натс, олл-ин, бэд бит).
+Отвечай кратко (1-2 предложения), с легкой надменностью победителя.
+Иногда упоминай свои прошлые победы. Говори на русском языке.`
+ },
+ {
+ name: 'Анна "Блефер"',
+ avatar: '👩💼',
+ style: 'tricky',
+ systemPrompt: `Ты Анна, загадочная женщина-игрок в покер, известная как "Блефер".
+Ты умна, проницательна и любишь психологические игры.
+Твой стиль общения - загадочный, с намёками и двусмысленностями.
+Ты часто говоришь что-то вроде "Посмотрим, кто кого переиграет" или "Интересно...".
+Отвечай кратко (1-2 предложения), держи интригу. Говори на русском языке.`
+ },
+ {
+ name: 'Дед Михалыч',
+ avatar: '👴',
+ style: 'oldschool',
+ systemPrompt: `Ты Дед Михалыч, пожилой мудрый игрок в покер, играющий ещё с советских времён.
+Ты добродушный, любишь рассказывать истории "а вот в наше время...".
+Используешь старомодные выражения, иногда путаешь покерные термины.
+Относишься к молодым игрокам снисходительно, но по-доброму.
+Отвечай кратко (1-2 предложения), с юмором. Говори на русском языке.`
+ },
+ {
+ name: 'Макс "ГТО"',
+ avatar: '🤓',
+ style: 'mathematical',
+ systemPrompt: `Ты Макс, молодой игрок-математик, помешанный на GTO (Game Theory Optimal).
+Ты постоянно считаешь odds, equity, EV в уме и любишь об этом говорить.
+Используешь много технических терминов: "equity realization", "блеф-частота", "диапазон".
+Немного занудный, но добродушный.
+Отвечай кратко (1-2 предложения), упоминай математику. Говори на русском языке.`
+ },
+ {
+ name: 'Катя "Удача"',
+ avatar: '🍀',
+ style: 'lucky',
+ systemPrompt: `Ты Катя, весёлая девушка, которая верит в приметы и удачу.
+У тебя есть талисман, ты загадываешь желания перед важными раздачами.
+Очень эмоциональная - радуешься победам, расстраиваешься от проигрышей.
+Используешь смайлики в речи, говоришь позитивно.
+Отвечай кратко (1-2 предложения), эмоционально. Говори на русском языке.`
+ },
+ {
+ name: 'Борис "Молчун"',
+ avatar: '🎭',
+ style: 'silent',
+ systemPrompt: `Ты Борис, молчаливый игрок в покер, известный как "Молчун".
+Ты говоришь очень мало, каждое слово на вес золота.
+Твои ответы минималистичны: "Да", "Нет", "Посмотрим", "Хм".
+Иногда просто многозначительно молчишь (отвечаешь "...").
+Отвечай очень кратко (1-3 слова максимум). Говори на русском языке.`
+ },
+ {
+ name: 'Олег "Тильтер"',
+ avatar: '😤',
+ style: 'tilted',
+ systemPrompt: `Ты Олег, эмоциональный игрок, склонный к тильту.
+Когда проигрываешь - злишься и обвиняешь удачу, соперников, карты.
+Когда выигрываешь - становишься чересчур самоуверенным.
+Часто жалуешься на "рандом", "читеров", "донков".
+Отвечай кратко (1-2 предложения), эмоционально. Говори на русском языке.`
+ },
+ {
+ name: 'Ирина "Профи"',
+ avatar: '💎',
+ style: 'professional',
+ systemPrompt: `Ты Ирина, профессиональный онлайн-гриндер, играющая на высоких лимитах.
+Ты спокойная, рациональная, относишься к покеру как к работе.
+Даёшь дельные советы, анализируешь розыгрыши.
+Уважительно относишься к соперникам, но уверена в своих силах.
+Отвечай кратко (1-2 предложения), профессионально. Говори на русском языке.`
+ }
+ ],
+
+ // Имена для ботов (для совместимости)
+ botNames: [
+ 'Алексей', 'Мария', 'Дмитрий', 'Анна', 'Сергей',
+ 'Елена', 'Иван', 'Ольга', 'Николай', 'Татьяна',
+ 'Phil Hellmuth', 'Daniel Negreanu', 'Phil Ivey',
+ 'Vanessa Selbst', 'Fedor Holz'
+ ],
+
+ /**
+ * Получить случайное имя для бота (используем персональности если LLM включён)
+ */
+ getRandomName() {
+ return this.botNames[Math.floor(Math.random() * this.botNames.length)];
+ },
+
+ /**
+ * Получить случайную персональность для бота
+ */
+ getRandomPersonality() {
+ const personality = this.personalities[Math.floor(Math.random() * this.personalities.length)];
+ return { ...personality }; // Возвращаем копию
+ },
+
+ /**
+ * Принять решение на основе уровня ИИ
+ */
+ makeDecision(player, game) {
+ switch (player.aiLevel) {
+ case 1:
+ return this.level1Decision(player, game);
+ case 2:
+ return this.level2Decision(player, game);
+ case 3:
+ return this.level3Decision(player, game);
+ default:
+ return this.level1Decision(player, game);
+ }
+ },
+
+ // =========================================================================
+ // УРОВЕНЬ 1: Случайный игрок (новичок)
+ // =========================================================================
+
+ /**
+ * Случайные решения с небольшой логикой
+ */
+ level1Decision(player, game) {
+ const actions = game.getAvailableActions(player);
+ const toCall = game.currentBet - player.bet;
+
+ // Случайный выбор с весами
+ const random = Math.random();
+
+ if (toCall === 0) {
+ // Нет ставки
+ if (random < 0.6) {
+ return { action: 'check', amount: 0 };
+ } else if (random < 0.85) {
+ const betAmount = game.bigBlind * (2 + Math.floor(Math.random() * 3));
+ return { action: 'bet', amount: Math.min(betAmount, player.chips) };
+ } else {
+ return { action: 'allin', amount: player.chips };
+ }
+ } else {
+ // Есть ставка
+ if (random < 0.3) {
+ return { action: 'fold', amount: 0 };
+ } else if (random < 0.75) {
+ return { action: 'call', amount: toCall };
+ } else if (random < 0.95 && player.chips > toCall * 2) {
+ const raiseAmount = game.currentBet + game.lastRaiseAmount +
+ Math.floor(Math.random() * game.bigBlind * 3);
+ return { action: 'raise', amount: raiseAmount };
+ } else {
+ return { action: 'allin', amount: player.chips };
+ }
+ }
+ },
+
+ // =========================================================================
+ // УРОВЕНЬ 2: Тайтово-агрессивный по диапазонам
+ // =========================================================================
+
+ /**
+ * Играет по диапазонам рук
+ */
+ level2Decision(player, game) {
+ const handStrength = this.evaluatePreflopHand(player.hand);
+ const toCall = game.currentBet - player.bet;
+ const potOdds = toCall / (game.pot + toCall);
+ const position = this.getPosition(player, game);
+ const phase = game.gamePhase;
+
+ if (phase === 'preflop') {
+ return this.level2Preflop(player, game, handStrength, position);
+ } else {
+ return this.level2Postflop(player, game, position);
+ }
+ },
+
+ /**
+ * Префлоп решения для уровня 2
+ */
+ level2Preflop(player, game, handStrength, position) {
+ const toCall = game.currentBet - player.bet;
+
+ // Премиум руки (AA, KK, QQ, AKs)
+ if (handStrength >= 90) {
+ if (toCall === 0 || toCall <= game.bigBlind) {
+ // Рейз 3x
+ return { action: 'raise', amount: game.currentBet + game.bigBlind * 3 };
+ } else {
+ // 3-бет или 4-бет
+ return { action: 'raise', amount: game.currentBet * 3 };
+ }
+ }
+
+ // Сильные руки (JJ, 1010, AQs, AKo)
+ if (handStrength >= 75) {
+ if (toCall === 0) {
+ return { action: 'raise', amount: game.bigBlind * 3 };
+ } else if (toCall <= game.bigBlind * 4) {
+ return { action: 'call', amount: toCall };
+ } else if (toCall <= game.bigBlind * 8) {
+ // Иногда коллируем
+ return Math.random() < 0.5
+ ? { action: 'call', amount: toCall }
+ : { action: 'fold', amount: 0 };
+ } else {
+ return { action: 'fold', amount: 0 };
+ }
+ }
+
+ // Средние руки (99-66, AJs, KQs)
+ if (handStrength >= 55) {
+ if (position === 'late' || position === 'dealer') {
+ if (toCall === 0) {
+ return { action: 'raise', amount: game.bigBlind * 2.5 };
+ } else if (toCall <= game.bigBlind * 3) {
+ return { action: 'call', amount: toCall };
+ }
+ } else if (toCall <= game.bigBlind * 2) {
+ return { action: 'call', amount: toCall };
+ }
+ return { action: 'fold', amount: 0 };
+ }
+
+ // Спекулятивные руки (малые пары, suited connectors)
+ if (handStrength >= 35) {
+ if (position === 'late' || position === 'dealer') {
+ if (toCall === 0) {
+ return Math.random() < 0.4
+ ? { action: 'raise', amount: game.bigBlind * 2 }
+ : { action: 'check', amount: 0 };
+ } else if (toCall <= game.bigBlind * 2) {
+ return { action: 'call', amount: toCall };
+ }
+ } else if (toCall <= game.bigBlind) {
+ return { action: 'call', amount: toCall };
+ }
+ return { action: 'fold', amount: 0 };
+ }
+
+ // Слабые руки
+ if (toCall === 0) {
+ return position === 'late' && Math.random() < 0.2
+ ? { action: 'raise', amount: game.bigBlind * 2 }
+ : { action: 'check', amount: 0 };
+ }
+
+ return { action: 'fold', amount: 0 };
+ },
+
+ /**
+ * Постфлоп решения для уровня 2
+ */
+ level2Postflop(player, game, position) {
+ const handResult = evaluateHand([...player.hand, ...game.communityCards]);
+ const toCall = game.currentBet - player.bet;
+ const potOdds = toCall / (game.pot + toCall);
+
+ // Очень сильная рука (сет+)
+ if (handResult.rank >= 4) {
+ if (toCall === 0) {
+ // Вэлью бет
+ const betSize = Math.floor(game.pot * (0.5 + Math.random() * 0.25));
+ return { action: 'bet', amount: betSize };
+ } else {
+ // Рейз для вэлью
+ return { action: 'raise', amount: game.currentBet * 2.5 };
+ }
+ }
+
+ // Пара топ кикер / две пары
+ if (handResult.rank >= 2) {
+ if (toCall === 0) {
+ return Math.random() < 0.6
+ ? { action: 'bet', amount: Math.floor(game.pot * 0.5) }
+ : { action: 'check', amount: 0 };
+ } else if (potOdds < 0.35) {
+ return { action: 'call', amount: toCall };
+ } else {
+ return Math.random() < 0.3
+ ? { action: 'call', amount: toCall }
+ : { action: 'fold', amount: 0 };
+ }
+ }
+
+ // Дро или оверкарты
+ const hasFlushDraw = this.hasFlushDraw(player.hand, game.communityCards);
+ const hasStraightDraw = this.hasStraightDraw(player.hand, game.communityCards);
+
+ if (hasFlushDraw || hasStraightDraw) {
+ if (toCall === 0) {
+ // Полублеф
+ return Math.random() < 0.4
+ ? { action: 'bet', amount: Math.floor(game.pot * 0.5) }
+ : { action: 'check', amount: 0 };
+ } else if (potOdds < 0.25) {
+ return { action: 'call', amount: toCall };
+ }
+ }
+
+ // Слабая рука
+ if (toCall === 0) {
+ return { action: 'check', amount: 0 };
+ }
+
+ return { action: 'fold', amount: 0 };
+ },
+
+ // =========================================================================
+ // УРОВЕНЬ 3: Equity-based + блеф-блокеры
+ // =========================================================================
+
+ /**
+ * Продвинутый ИИ с расчётом equity
+ */
+ level3Decision(player, game) {
+ const phase = game.gamePhase;
+ const position = this.getPosition(player, game);
+ const toCall = game.currentBet - player.bet;
+
+ if (phase === 'preflop') {
+ return this.level3Preflop(player, game, position);
+ } else {
+ return this.level3Postflop(player, game, position);
+ }
+ },
+
+ /**
+ * Префлоп для уровня 3
+ */
+ level3Preflop(player, game, position) {
+ const handStrength = this.evaluatePreflopHand(player.hand);
+ const toCall = game.currentBet - player.bet;
+ const stackDepth = player.chips / game.bigBlind;
+
+ // GTO-подобные диапазоны открытия по позициям
+ const openRanges = {
+ 'early': 85, // Топ 15%
+ 'middle': 70, // Топ 30%
+ 'late': 50, // Топ 50%
+ 'dealer': 40, // Топ 60%
+ 'blind': 55 // Топ 45%
+ };
+
+ const openThreshold = openRanges[position] || 60;
+
+ // Премиум руки - всегда 3-бет
+ if (handStrength >= 92) {
+ if (toCall > game.bigBlind * 10) {
+ return { action: 'allin', amount: player.chips };
+ }
+ const raiseSize = Math.max(game.currentBet * 3, game.bigBlind * 4);
+ return { action: 'raise', amount: raiseSize };
+ }
+
+ // Открытие в позиции
+ if (toCall === 0 || toCall === game.bigBlind) {
+ if (handStrength >= openThreshold) {
+ // Размер открытия зависит от позиции
+ const openSize = position === 'early' ? 3 : 2.5;
+ return {
+ action: toCall === 0 ? 'bet' : 'raise',
+ amount: game.bigBlind * openSize
+ };
+ } else if (toCall === 0) {
+ return { action: 'check', amount: 0 };
+ }
+ }
+
+ // Против рейза
+ if (toCall > game.bigBlind) {
+ const callThreshold = 75 - (position === 'late' ? 10 : 0);
+ const threeBetThreshold = 88;
+
+ if (handStrength >= threeBetThreshold && Math.random() < 0.7) {
+ return { action: 'raise', amount: game.currentBet * 2.5 };
+ } else if (handStrength >= callThreshold) {
+ return { action: 'call', amount: toCall };
+ } else if (handStrength >= 45 && toCall <= game.bigBlind * 3) {
+ // Колл с suited connectors и малыми парами (сетмайнинг)
+ return { action: 'call', amount: toCall };
+ }
+ }
+
+ return { action: 'fold', amount: 0 };
+ },
+
+ /**
+ * Постфлоп для уровня 3
+ */
+ level3Postflop(player, game, position) {
+ const equity = this.calculateEquity(player.hand, game.communityCards);
+ const toCall = game.currentBet - player.bet;
+ const potOdds = toCall / (game.pot + toCall);
+ const impliedOdds = this.calculateImpliedOdds(player, game);
+ const hasBlockers = this.checkBlockers(player.hand, game.communityCards);
+
+ // Расчёт EV
+ const callEV = equity * (game.pot + toCall) - (1 - equity) * toCall;
+
+ // Мы в позиции?
+ const inPosition = position === 'late' || position === 'dealer';
+
+ // Сильная рука (> 70% equity)
+ if (equity > 0.7) {
+ if (toCall === 0) {
+ // Вэлью бет с размером зависящим от текстуры борда
+ const betSize = this.calculateOptimalBet(game.pot, equity, game.gamePhase);
+ return { action: 'bet', amount: betSize };
+ } else {
+ // Рейз для вэлью
+ if (player.chips > game.currentBet * 3) {
+ return { action: 'raise', amount: game.currentBet * 2.5 };
+ }
+ return { action: 'call', amount: toCall };
+ }
+ }
+
+ // Средняя рука (40-70% equity)
+ if (equity > 0.4) {
+ if (toCall === 0) {
+ // Бет для защиты или вэлью
+ if (Math.random() < 0.5) {
+ return { action: 'bet', amount: Math.floor(game.pot * 0.5) };
+ }
+ return { action: 'check', amount: 0 };
+ } else if (equity > potOdds * 1.2) {
+ return { action: 'call', amount: toCall };
+ }
+ }
+
+ // Дро или блеф с блокерами
+ if (equity > 0.25 || (hasBlockers && Math.random() < 0.3)) {
+ if (toCall === 0 && inPosition && Math.random() < 0.35) {
+ // Полублеф
+ return { action: 'bet', amount: Math.floor(game.pot * 0.6) };
+ } else if (equity + impliedOdds > potOdds) {
+ return { action: 'call', amount: toCall };
+ }
+ }
+
+ // Чистый блеф на ривере с блокерами
+ if (game.gamePhase === 'river' && hasBlockers && toCall === 0 && Math.random() < 0.15) {
+ return { action: 'bet', amount: Math.floor(game.pot * 0.75) };
+ }
+
+ // Слабая рука
+ if (toCall === 0) {
+ return { action: 'check', amount: 0 };
+ }
+
+ return { action: 'fold', amount: 0 };
+ },
+
+ // =========================================================================
+ // ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
+ // =========================================================================
+
+ /**
+ * Оценка префлоп руки (0-100)
+ */
+ evaluatePreflopHand(hand) {
+ if (!hand || hand.length !== 2) return 0;
+
+ const [c1, c2] = hand;
+ const highCard = Math.max(c1.value, c2.value);
+ const lowCard = Math.min(c1.value, c2.value);
+ const suited = c1.suit === c2.suit;
+ const gap = highCard - lowCard;
+ const isPair = c1.value === c2.value;
+
+ let score = 0;
+
+ // Пары
+ if (isPair) {
+ score = 50 + c1.value * 3;
+ if (c1.value >= 10) score += 10;
+ if (c1.value >= 13) score += 10;
+ } else {
+ // Непарные руки
+ score = highCard * 2 + lowCard;
+
+ // Бонус за одномастные
+ if (suited) score += 8;
+
+ // Бонус за коннекторы
+ if (gap === 1) score += 6;
+ else if (gap === 2) score += 3;
+ else if (gap >= 4) score -= gap;
+
+ // Бонус за картинки
+ if (highCard >= 12 && lowCard >= 10) score += 15;
+ if (highCard === 14) score += 10;
+ }
+
+ return Math.min(100, Math.max(0, score));
+ },
+
+ /**
+ * Получить позицию игрока
+ */
+ getPosition(player, game) {
+ const playerIndex = game.players.indexOf(player);
+ const dealerIndex = game.dealerIndex;
+ const numPlayers = game.players.length;
+
+ const relativePosition = (playerIndex - dealerIndex + numPlayers) % numPlayers;
+
+ if (player.isDealer) return 'dealer';
+ if (player.isSmallBlind || player.isBigBlind) return 'blind';
+ if (relativePosition <= numPlayers * 0.33) return 'early';
+ if (relativePosition <= numPlayers * 0.66) return 'middle';
+ return 'late';
+ },
+
+ /**
+ * Расчёт equity методом Монте-Карло
+ */
+ calculateEquity(hand, communityCards, iterations = 500) {
+ if (!hand || hand.length !== 2) return 0;
+
+ let wins = 0;
+ let ties = 0;
+
+ // Создаём колоду без известных карт
+ const knownCards = new Set([
+ ...hand.map(c => `${c.rank}${c.suit}`),
+ ...communityCards.map(c => `${c.rank}${c.suit}`)
+ ]);
+
+ const deck = [];
+ for (const suit of SUITS) {
+ for (const rank of RANKS) {
+ const key = `${rank}${suit}`;
+ if (!knownCards.has(key)) {
+ deck.push(new Card(suit, rank));
+ }
+ }
+ }
+
+ for (let i = 0; i < iterations; i++) {
+ // Перемешиваем колоду
+ const shuffled = [...deck].sort(() => Math.random() - 0.5);
+
+ // Добавляем карты до 5
+ const cardsNeeded = 5 - communityCards.length;
+ const simBoard = [...communityCards, ...shuffled.slice(0, cardsNeeded)];
+
+ // Рука оппонента
+ const oppHand = shuffled.slice(cardsNeeded, cardsNeeded + 2);
+
+ // Оценка рук
+ const myHand = evaluateHand([...hand, ...simBoard]);
+ const oppHandResult = evaluateHand([...oppHand, ...simBoard]);
+
+ const comparison = compareHandResults(myHand, oppHandResult);
+
+ if (comparison > 0) wins++;
+ else if (comparison === 0) ties++;
+ }
+
+ return (wins + ties * 0.5) / iterations;
+ },
+
+ /**
+ * Проверка на флеш-дро
+ */
+ hasFlushDraw(hand, community) {
+ const allCards = [...hand, ...community];
+ const suitCounts = {};
+
+ for (const card of allCards) {
+ suitCounts[card.suit] = (suitCounts[card.suit] || 0) + 1;
+ }
+
+ return Object.values(suitCounts).some(count => count === 4);
+ },
+
+ /**
+ * Проверка на стрит-дро
+ */
+ hasStraightDraw(hand, community) {
+ const allCards = [...hand, ...community];
+ const values = [...new Set(allCards.map(c => c.value))].sort((a, b) => a - b);
+
+ // Проверяем гатшот или OESD
+ for (let i = 0; i < values.length - 3; i++) {
+ const window = values.slice(i, i + 4);
+ const gaps = window[3] - window[0];
+ if (gaps <= 4) return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Проверка блокеров
+ */
+ checkBlockers(hand, community) {
+ // Блокеры для флеша
+ const allCards = [...hand, ...community];
+ const suitCounts = {};
+
+ for (const card of allCards) {
+ suitCounts[card.suit] = (suitCounts[card.suit] || 0) + 1;
+ }
+
+ // Если на борде 3+ одной масти, проверяем блокеры
+ for (const [suit, count] of Object.entries(suitCounts)) {
+ if (count >= 3) {
+ // Проверяем, есть ли у нас туз или король этой масти
+ for (const card of hand) {
+ if (card.suit === suit && card.value >= 13) {
+ return true;
+ }
+ }
+ }
+ }
+
+ // Блокеры для стрита (тузы, короли на коннекторных бордах)
+ const boardValues = community.map(c => c.value).sort((a, b) => a - b);
+ if (boardValues.length >= 3) {
+ const isConnected = boardValues[boardValues.length - 1] - boardValues[0] <= 4;
+ if (isConnected) {
+ for (const card of hand) {
+ if (card.value >= 13) return true;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Расчёт implied odds
+ */
+ calculateImpliedOdds(player, game) {
+ // Упрощённый расчёт implied odds
+ const remainingStreets = {
+ 'flop': 2,
+ 'turn': 1,
+ 'river': 0
+ }[game.gamePhase] || 0;
+
+ const avgOppStack = game.players
+ .filter(p => !p.folded && p.id !== player.id)
+ .reduce((sum, p) => sum + p.chips, 0) /
+ game.getActivePlayers().length;
+
+ // Оцениваем, сколько можем выиграть на следующих улицах
+ const impliedValue = avgOppStack * 0.15 * remainingStreets;
+
+ return impliedValue / (game.pot + impliedValue);
+ },
+
+ /**
+ * Расчёт оптимального размера ставки
+ */
+ calculateOptimalBet(pot, equity, phase) {
+ // Чем выше equity и чем позже фаза, тем больше ставка
+ const baseMultiplier = {
+ 'flop': 0.5,
+ 'turn': 0.6,
+ 'river': 0.7
+ }[phase] || 0.5;
+
+ const equityBonus = (equity - 0.5) * 0.5;
+ const finalMultiplier = Math.min(1, baseMultiplier + equityBonus);
+
+ return Math.floor(pot * finalMultiplier);
+ }
+};
+
+// =============================================================================
+// LLM КЛИЕНТ ДЛЯ ЧАТА
+// =============================================================================
+
+const llmChat = {
+ // Кэш истории сообщений для каждого бота
+ messageHistory: new Map(),
+
+ // Максимум сообщений в истории
+ maxHistory: 10,
+
+ /**
+ * Отправить сообщение и получить ответ от LLM
+ */
+ async chat(botId, botPersonality, userMessage, gameContext = {}) {
+ const settings = this.getSettings();
+
+ if (!settings.llmEnabled) {
+ return this.getFallbackResponse(botPersonality, userMessage, gameContext);
+ }
+
+ try {
+ // Получаем или создаём историю для этого бота
+ if (!this.messageHistory.has(botId)) {
+ this.messageHistory.set(botId, []);
+ }
+ const history = this.messageHistory.get(botId);
+
+ // Формируем контекст игры
+ const gameContextStr = this.buildGameContext(gameContext);
+
+ // Системный промпт с контекстом
+ const systemPrompt = `${botPersonality.systemPrompt}
+
+Текущая ситуация в игре:
+${gameContextStr}
+
+Отвечай на сообщения игрока в соответствии со своей личностью.`;
+
+ // Добавляем сообщение пользователя в историю
+ history.push({ role: 'user', content: userMessage });
+
+ // Ограничиваем историю
+ while (history.length > this.maxHistory) {
+ history.shift();
+ }
+
+ // Отправляем запрос к LLM
+ const response = await this.sendToLLM(systemPrompt, history, settings);
+
+ // Добавляем ответ в историю
+ if (response) {
+ history.push({ role: 'assistant', content: response });
+ }
+
+ return response || this.getFallbackResponse(botPersonality, userMessage, gameContext);
+
+ } catch (error) {
+ console.error('LLM ошибка:', error);
+ return this.getFallbackResponse(botPersonality, userMessage, gameContext);
+ }
+ },
+
+ /**
+ * Отправить запрос к LLM API
+ */
+ async sendToLLM(systemPrompt, messages, settings) {
+ const { provider, apiUrl, model, apiKey } = settings;
+
+ let url, body, headers;
+
+ switch (provider) {
+ case 'ollama':
+ url = `${apiUrl}/api/chat`;
+ body = {
+ model: model,
+ messages: [
+ { role: 'system', content: systemPrompt },
+ ...messages
+ ],
+ stream: false
+ };
+ headers = { 'Content-Type': 'application/json' };
+ break;
+
+ case 'lmstudio':
+ url = `${apiUrl}/v1/chat/completions`;
+ body = {
+ model: model,
+ messages: [
+ { role: 'system', content: systemPrompt },
+ ...messages
+ ],
+ max_tokens: 150,
+ temperature: 0.8
+ };
+ headers = { 'Content-Type': 'application/json' };
+ break;
+
+ case 'openai':
+ url = 'https://api.openai.com/v1/chat/completions';
+ body = {
+ model: model || 'gpt-3.5-turbo',
+ messages: [
+ { role: 'system', content: systemPrompt },
+ ...messages
+ ],
+ max_tokens: 150,
+ temperature: 0.8
+ };
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`
+ };
+ break;
+
+ default:
+ throw new Error('Неизвестный провайдер LLM');
+ }
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // Извлекаем текст ответа в зависимости от провайдера
+ if (provider === 'ollama') {
+ return data.message?.content || '';
+ } else {
+ return data.choices?.[0]?.message?.content || '';
+ }
+ },
+
+ /**
+ * Построить контекст игры для LLM
+ */
+ buildGameContext(ctx) {
+ if (!ctx || Object.keys(ctx).length === 0) {
+ return 'Игра ещё не началась.';
+ }
+
+ let context = '';
+
+ if (ctx.phase) {
+ const phases = {
+ 'preflop': 'Префлоп',
+ 'flop': 'Флоп',
+ 'turn': 'Тёрн',
+ 'river': 'Ривер',
+ 'showdown': 'Вскрытие'
+ };
+ context += `Фаза: ${phases[ctx.phase] || ctx.phase}\n`;
+ }
+
+ if (ctx.pot !== undefined) {
+ context += `Банк: ${ctx.pot} фишек\n`;
+ }
+
+ if (ctx.myChips !== undefined) {
+ context += `Мои фишки: ${ctx.myChips}\n`;
+ }
+
+ if (ctx.lastAction) {
+ context += `Последнее действие игрока: ${ctx.lastAction}\n`;
+ }
+
+ if (ctx.communityCards && ctx.communityCards.length > 0) {
+ context += `Общие карты: ${ctx.communityCards.join(', ')}\n`;
+ }
+
+ return context || 'Идёт игра.';
+ },
+
+ /**
+ * Запасной ответ если LLM недоступен
+ */
+ getFallbackResponse(personality, userMessage, gameContext) {
+ const style = personality?.style || 'default';
+
+ const responses = {
+ aggressive: [
+ 'Посмотрим, что ты можешь.',
+ 'Удача любит смелых!',
+ 'Это только начало.',
+ 'Не расслабляйся.',
+ 'Интересно...'
+ ],
+ tricky: [
+ 'Может быть... 🤔',
+ 'Посмотрим...',
+ 'Интересный ход мыслей.',
+ 'Загадка, не правда ли?',
+ 'Время покажет.'
+ ],
+ oldschool: [
+ 'А вот в наше время...',
+ 'Эх, молодёжь!',
+ 'Ничего-ничего.',
+ 'Бывает и хуже.',
+ 'Терпение, друг мой.'
+ ],
+ mathematical: [
+ 'По EV это нормально.',
+ 'Статистика на моей стороне.',
+ 'Интересный спот.',
+ 'Надо посчитать...',
+ '+EV решение.'
+ ],
+ lucky: [
+ 'Удачи! 🍀',
+ 'Верю в лучшее! ✨',
+ 'Всё будет хорошо! 😊',
+ 'Загадаю желание! 🌟',
+ 'Пусть карта ляжет!'
+ ],
+ silent: [
+ '...',
+ 'Хм.',
+ 'Да.',
+ 'Нет.',
+ 'Посмотрим.'
+ ],
+ tilted: [
+ 'Опять этот рандом!',
+ 'Невезение...',
+ 'Ну конечно!',
+ 'Просто невероятно!',
+ 'Как всегда...'
+ ],
+ professional: [
+ 'Хороший спот.',
+ 'Стандартная игра.',
+ 'Разумно.',
+ 'Интересное решение.',
+ 'Принято.'
+ ],
+ default: [
+ 'Удачи!',
+ 'Интересно...',
+ 'Посмотрим.',
+ 'Хм...',
+ 'Неплохо!'
+ ]
+ };
+
+ const options = responses[style] || responses.default;
+ return options[Math.floor(Math.random() * options.length)];
+ },
+
+ /**
+ * Получить настройки LLM
+ */
+ getSettings() {
+ const saved = localStorage.getItem('pokerSettings');
+ if (!saved) {
+ return { llmEnabled: false };
+ }
+
+ const settings = JSON.parse(saved);
+ return {
+ llmEnabled: settings.llmEnabled || false,
+ provider: settings.llmProvider || 'ollama',
+ apiUrl: settings.llmApiUrl || 'http://localhost:11434',
+ model: settings.llmModel || 'llama3.2',
+ apiKey: settings.llmApiKey || ''
+ };
+ },
+
+ /**
+ * Проверить подключение к LLM
+ */
+ async testConnection() {
+ const settings = this.getSettings();
+
+ if (!settings.llmEnabled) {
+ return { success: false, error: 'LLM чат отключён в настройках' };
+ }
+
+ try {
+ const testMessage = [{ role: 'user', content: 'Привет! Скажи "работает" если ты меня слышишь.' }];
+ const response = await this.sendToLLM('Ты тестовый бот. Отвечай кратко.', testMessage, settings);
+
+ if (response) {
+ return { success: true, response };
+ } else {
+ return { success: false, error: 'Пустой ответ от LLM' };
+ }
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ },
+
+ /**
+ * Очистить историю чата для бота
+ */
+ clearHistory(botId) {
+ this.messageHistory.delete(botId);
+ },
+
+ /**
+ * Очистить всю историю
+ */
+ clearAllHistory() {
+ this.messageHistory.clear();
+ }
+};
+
+// Для совместимости с серверной и клиентской частью
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { pokerAI, llmChat };
+}
diff --git a/public/game.js b/public/game.js
new file mode 100644
index 0000000..a5f9126
--- /dev/null
+++ b/public/game.js
@@ -0,0 +1,912 @@
+/**
+ * =============================================================================
+ * Texas Hold'em - Игровая логика (клиентская сторона)
+ * Колода, раздача, оценка рук, определение победителя
+ * =============================================================================
+ */
+
+// =============================================================================
+// КОНСТАНТЫ
+// =============================================================================
+
+const SUITS = ['hearts', 'diamonds', 'clubs', 'spades'];
+const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+
+const SUIT_SYMBOLS = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+};
+
+const HAND_RANKINGS = {
+ 1: 'Старшая карта',
+ 2: 'Пара',
+ 3: 'Две пары',
+ 4: 'Сет',
+ 5: 'Стрит',
+ 6: 'Флеш',
+ 7: 'Фулл-хаус',
+ 8: 'Каре',
+ 9: 'Стрит-флеш',
+ 10: 'Роял-флеш'
+};
+
+// =============================================================================
+// КЛАСС КАРТЫ
+// =============================================================================
+
+class Card {
+ constructor(suit, rank) {
+ this.suit = suit;
+ this.rank = rank;
+ this.value = RANKS.indexOf(rank) + 2; // 2-14 (A=14)
+ }
+
+ /**
+ * Получить HTML элемент карты
+ */
+ toHTML(isSmall = false, isBack = false) {
+ const card = document.createElement('div');
+ card.className = `card ${this.suit}${isSmall ? ' card-small' : ''}${isBack ? ' card-back' : ''}`;
+
+ if (!isBack) {
+ card.innerHTML = `
+ ${this.rank}
+ ${SUIT_SYMBOLS[this.suit]}
+ `;
+ }
+
+ return card;
+ }
+
+ toString() {
+ return `${this.rank}${SUIT_SYMBOLS[this.suit]}`;
+ }
+}
+
+// =============================================================================
+// КЛАСС КОЛОДЫ
+// =============================================================================
+
+class Deck {
+ constructor() {
+ this.reset();
+ }
+
+ /**
+ * Сбросить и перемешать колоду
+ */
+ reset() {
+ this.cards = [];
+ for (const suit of SUITS) {
+ for (const rank of RANKS) {
+ this.cards.push(new Card(suit, rank));
+ }
+ }
+ this.shuffle();
+ }
+
+ /**
+ * Перемешать колоду (Fisher-Yates)
+ */
+ shuffle() {
+ for (let i = this.cards.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]];
+ }
+ }
+
+ /**
+ * Раздать карты
+ */
+ deal(count = 1) {
+ return this.cards.splice(0, count);
+ }
+}
+
+// =============================================================================
+// КЛАСС ИГРОКА
+// =============================================================================
+
+class Player {
+ constructor(id, name, chips = 1000, isAI = false, aiLevel = 1) {
+ this.id = id;
+ this.name = name;
+ this.chips = chips;
+ this.hand = [];
+ this.bet = 0;
+ this.totalBet = 0;
+ this.folded = false;
+ this.allIn = false;
+ this.isDealer = false;
+ this.isSmallBlind = false;
+ this.isBigBlind = false;
+ this.isAI = isAI;
+ this.aiLevel = aiLevel;
+ this.lastAction = null;
+ this.isConnected = true;
+ }
+
+ /**
+ * Сбросить состояние для новой раздачи
+ */
+ reset() {
+ this.hand = [];
+ this.bet = 0;
+ this.totalBet = 0;
+ this.folded = false;
+ this.allIn = false;
+ this.isDealer = false;
+ this.isSmallBlind = false;
+ this.isBigBlind = false;
+ this.lastAction = null;
+ }
+}
+
+// =============================================================================
+// КЛАСС ИГРЫ
+// =============================================================================
+
+class PokerGame {
+ constructor(options = {}) {
+ this.players = [];
+ this.deck = new Deck();
+ this.communityCards = [];
+ this.pot = 0;
+ this.sidePots = [];
+ this.currentBet = 0;
+ this.dealerIndex = -1;
+ this.currentPlayerIndex = 0;
+ this.gamePhase = 'waiting'; // waiting, preflop, flop, turn, river, showdown
+ this.smallBlind = options.smallBlind || 5;
+ this.bigBlind = options.bigBlind || 10;
+ this.minRaise = this.bigBlind;
+ this.lastRaiseAmount = this.bigBlind;
+ this.isGameStarted = false;
+ this.onUpdate = options.onUpdate || (() => {});
+ this.onAction = options.onAction || (() => {});
+ this.onHandEnd = options.onHandEnd || (() => {});
+ }
+
+ /**
+ * Добавить игрока
+ */
+ addPlayer(player) {
+ this.players.push(player);
+ }
+
+ /**
+ * Получить активных игроков
+ */
+ getActivePlayers() {
+ return this.players.filter(p => !p.folded && p.chips >= 0);
+ }
+
+ /**
+ * Получить игроков с фишками
+ */
+ getPlayersWithChips() {
+ return this.players.filter(p => p.chips > 0);
+ }
+
+ /**
+ * Начать новую раздачу
+ */
+ startNewHand() {
+ if (this.getPlayersWithChips().length < 2) {
+ return false;
+ }
+
+ // Сброс состояния
+ this.deck.reset();
+ this.communityCards = [];
+ this.pot = 0;
+ this.sidePots = [];
+ this.currentBet = 0;
+ this.minRaise = this.bigBlind;
+ this.lastRaiseAmount = this.bigBlind;
+
+ // Сброс игроков
+ for (const player of this.players) {
+ player.reset();
+ }
+
+ // Определение позиций
+ this.moveDealer();
+ this.assignBlinds();
+
+ // Раздача карт
+ this.dealHoleCards();
+
+ // Начало игры
+ this.gamePhase = 'preflop';
+ this.isGameStarted = true;
+
+ // Ставки блайндов
+ this.postBlinds();
+
+ // Первый ход
+ this.setFirstPlayer();
+
+ this.onUpdate();
+
+ // Если первый ход - ИИ
+ this.checkAITurn();
+
+ return true;
+ }
+
+ /**
+ * Передвинуть баттон дилера
+ */
+ moveDealer() {
+ const playersWithChips = this.getPlayersWithChips();
+ if (playersWithChips.length < 2) return;
+
+ // Находим следующего дилера
+ do {
+ this.dealerIndex = (this.dealerIndex + 1) % this.players.length;
+ } while (this.players[this.dealerIndex].chips <= 0);
+
+ this.players[this.dealerIndex].isDealer = true;
+ }
+
+ /**
+ * Назначить блайнды
+ */
+ assignBlinds() {
+ const playersWithChips = this.getPlayersWithChips();
+
+ if (playersWithChips.length === 2) {
+ // Heads-up: дилер = SB
+ this.players[this.dealerIndex].isSmallBlind = true;
+ let bbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ this.players[bbIndex].isBigBlind = true;
+ } else {
+ // 3+ игроков
+ let sbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ this.players[sbIndex].isSmallBlind = true;
+
+ let bbIndex = this.getNextActivePlayerIndex(sbIndex);
+ this.players[bbIndex].isBigBlind = true;
+ }
+ }
+
+ /**
+ * Поставить блайнды
+ */
+ postBlinds() {
+ const sbPlayer = this.players.find(p => p.isSmallBlind);
+ const bbPlayer = this.players.find(p => p.isBigBlind);
+
+ if (sbPlayer) {
+ const sbAmount = Math.min(sbPlayer.chips, this.smallBlind);
+ sbPlayer.chips -= sbAmount;
+ sbPlayer.bet = sbAmount;
+ sbPlayer.totalBet = sbAmount;
+ this.pot += sbAmount;
+ sbPlayer.lastAction = `SB ${sbAmount}`;
+ }
+
+ if (bbPlayer) {
+ const bbAmount = Math.min(bbPlayer.chips, this.bigBlind);
+ bbPlayer.chips -= bbAmount;
+ bbPlayer.bet = bbAmount;
+ bbPlayer.totalBet = bbAmount;
+ this.pot += bbAmount;
+ this.currentBet = bbAmount;
+ bbPlayer.lastAction = `BB ${bbAmount}`;
+ }
+ }
+
+ /**
+ * Раздать карманные карты
+ */
+ dealHoleCards() {
+ for (const player of this.getPlayersWithChips()) {
+ player.hand = this.deck.deal(2);
+ }
+ }
+
+ /**
+ * Установить первого игрока для хода
+ */
+ setFirstPlayer() {
+ if (this.gamePhase === 'preflop') {
+ // UTG - после BB
+ const bbPlayer = this.players.find(p => p.isBigBlind);
+ const bbIndex = this.players.indexOf(bbPlayer);
+ this.currentPlayerIndex = this.getNextActivePlayerIndex(bbIndex);
+ } else {
+ // После флопа - первый активный после дилера
+ this.currentPlayerIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ }
+ }
+
+ /**
+ * Получить индекс следующего активного игрока
+ */
+ getNextActivePlayerIndex(fromIndex) {
+ let index = (fromIndex + 1) % this.players.length;
+ let attempts = 0;
+
+ while (attempts < this.players.length) {
+ const player = this.players[index];
+ if (!player.folded && player.chips >= 0 && !player.allIn) {
+ return index;
+ }
+ index = (index + 1) % this.players.length;
+ attempts++;
+ }
+
+ return -1;
+ }
+
+ /**
+ * Текущий игрок
+ */
+ getCurrentPlayer() {
+ return this.players[this.currentPlayerIndex];
+ }
+
+ /**
+ * Получить доступные действия для игрока
+ */
+ getAvailableActions(player) {
+ const actions = [];
+ const toCall = this.currentBet - player.bet;
+
+ // Фолд всегда доступен
+ actions.push('fold');
+
+ if (toCall === 0) {
+ // Можно чекнуть
+ actions.push('check');
+
+ // Можно поставить
+ if (player.chips > 0) {
+ actions.push('bet');
+ }
+ } else {
+ // Нужно колл или рейз
+ if (player.chips > toCall) {
+ actions.push('call');
+ actions.push('raise');
+ } else {
+ // Можно только колл (олл-ин)
+ actions.push('call');
+ }
+ }
+
+ // All-in всегда доступен если есть фишки
+ if (player.chips > 0) {
+ actions.push('allin');
+ }
+
+ return actions;
+ }
+
+ /**
+ * Обработать действие игрока
+ */
+ processAction(playerId, action, amount = 0) {
+ const player = this.players.find(p => p.id === playerId);
+ if (!player) return { success: false, error: 'Игрок не найден' };
+
+ const currentPlayer = this.players[this.currentPlayerIndex];
+ if (currentPlayer.id !== playerId) {
+ return { success: false, error: 'Сейчас не ваш ход' };
+ }
+
+ const result = { success: true, action, amount: 0 };
+
+ switch (action) {
+ case 'fold':
+ player.folded = true;
+ player.lastAction = 'Фолд';
+ break;
+
+ case 'check':
+ if (player.bet < this.currentBet) {
+ return { success: false, error: 'Нельзя чекнуть' };
+ }
+ player.lastAction = 'Чек';
+ break;
+
+ case 'call':
+ const callAmount = Math.min(this.currentBet - player.bet, player.chips);
+ player.chips -= callAmount;
+ player.bet += callAmount;
+ player.totalBet += callAmount;
+ this.pot += callAmount;
+ result.amount = callAmount;
+ player.lastAction = `Колл ${callAmount}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'Олл-ин';
+ }
+ break;
+
+ case 'bet':
+ if (this.currentBet > 0) {
+ return { success: false, error: 'Используйте рейз' };
+ }
+ if (amount < this.bigBlind) {
+ amount = this.bigBlind;
+ }
+ if (amount > player.chips) {
+ amount = player.chips;
+ }
+
+ player.chips -= amount;
+ player.bet = amount;
+ player.totalBet += amount;
+ this.pot += amount;
+ this.currentBet = amount;
+ this.lastRaiseAmount = amount;
+ this.minRaise = amount;
+ result.amount = amount;
+ player.lastAction = `Бет ${amount}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'Олл-ин';
+ }
+ break;
+
+ case 'raise':
+ const toCall = this.currentBet - player.bet;
+ const minRaiseTotal = this.currentBet + this.lastRaiseAmount;
+
+ if (amount < minRaiseTotal && amount < player.chips + player.bet) {
+ amount = minRaiseTotal;
+ }
+
+ const raiseTotal = Math.min(amount, player.chips + player.bet);
+ const raiseAmount = raiseTotal - player.bet;
+
+ player.chips -= raiseAmount;
+ this.pot += raiseAmount;
+ this.lastRaiseAmount = raiseTotal - this.currentBet;
+ this.currentBet = raiseTotal;
+ player.bet = raiseTotal;
+ player.totalBet += raiseAmount;
+ result.amount = raiseTotal;
+ player.lastAction = `Рейз до ${raiseTotal}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'Олл-ин';
+ }
+ break;
+
+ case 'allin':
+ const allInAmount = player.chips;
+ this.pot += allInAmount;
+ player.bet += allInAmount;
+ player.totalBet += allInAmount;
+ result.amount = player.bet;
+
+ if (player.bet > this.currentBet) {
+ this.lastRaiseAmount = player.bet - this.currentBet;
+ this.currentBet = player.bet;
+ }
+
+ player.chips = 0;
+ player.allIn = true;
+ player.lastAction = `Олл-ин ${player.bet}`;
+ break;
+
+ default:
+ return { success: false, error: 'Неизвестное действие' };
+ }
+
+ // Уведомляем о действии
+ this.onAction(player, action, result.amount);
+
+ // Проверка завершения раунда ставок
+ if (this.isBettingRoundComplete()) {
+ this.nextPhase();
+ } else {
+ this.moveToNextPlayer();
+ this.onUpdate();
+ this.checkAITurn();
+ }
+
+ return result;
+ }
+
+ /**
+ * Перейти к следующему игроку
+ */
+ moveToNextPlayer() {
+ const nextIndex = this.getNextActivePlayerIndex(this.currentPlayerIndex);
+ if (nextIndex !== -1) {
+ this.currentPlayerIndex = nextIndex;
+ }
+ }
+
+ /**
+ * Проверить, ход ли ИИ
+ */
+ checkAITurn() {
+ if (!this.isGameStarted) return;
+
+ const currentPlayer = this.getCurrentPlayer();
+ if (currentPlayer && currentPlayer.isAI) {
+ // Задержка для реалистичности
+ setTimeout(() => {
+ this.makeAIMove(currentPlayer);
+ }, 1000 + Math.random() * 1500);
+ }
+ }
+
+ /**
+ * Ход ИИ
+ */
+ makeAIMove(player) {
+ if (!this.isGameStarted || player.folded || player.allIn) return;
+
+ const decision = pokerAI.makeDecision(player, this);
+ this.processAction(player.id, decision.action, decision.amount);
+ }
+
+ /**
+ * Проверить завершение раунда ставок
+ */
+ isBettingRoundComplete() {
+ const activePlayers = this.getActivePlayers().filter(p => !p.allIn);
+
+ // Все фолднули кроме одного
+ if (this.getActivePlayers().length === 1) {
+ return true;
+ }
+
+ // Все активные (не all-in) игроки уравняли ставку
+ if (activePlayers.length === 0) {
+ return true;
+ }
+
+ // Все поставили одинаково и сделали действие
+ const allEqual = activePlayers.every(p => p.bet === this.currentBet);
+ const allActed = activePlayers.every(p => p.lastAction !== null &&
+ !p.lastAction.includes('SB') && !p.lastAction.includes('BB'));
+
+ return allEqual && allActed;
+ }
+
+ /**
+ * Переход к следующей фазе
+ */
+ nextPhase() {
+ // Сброс ставок для новой улицы
+ for (const player of this.players) {
+ player.bet = 0;
+ if (!player.folded && !player.allIn) {
+ player.lastAction = null;
+ }
+ }
+ this.currentBet = 0;
+
+ const activePlayers = this.getActivePlayers();
+
+ // Один игрок остался - он победитель
+ if (activePlayers.length === 1) {
+ this.endHand([activePlayers[0]]);
+ return;
+ }
+
+ switch (this.gamePhase) {
+ case 'preflop':
+ this.gamePhase = 'flop';
+ this.communityCards = this.deck.deal(3);
+ break;
+ case 'flop':
+ this.gamePhase = 'turn';
+ this.communityCards.push(...this.deck.deal(1));
+ break;
+ case 'turn':
+ this.gamePhase = 'river';
+ this.communityCards.push(...this.deck.deal(1));
+ break;
+ case 'river':
+ this.gamePhase = 'showdown';
+ this.determineWinner();
+ return;
+ }
+
+ // Установка первого игрока для новой улицы
+ this.setFirstPlayer();
+
+ this.onUpdate();
+
+ // Если все в all-in или только один активный, автоматически переходим
+ const nonAllInPlayers = this.getActivePlayers().filter(p => !p.allIn);
+ if (nonAllInPlayers.length <= 1) {
+ setTimeout(() => this.nextPhase(), 1500);
+ } else {
+ this.checkAITurn();
+ }
+ }
+
+ /**
+ * Определить победителя
+ */
+ determineWinner() {
+ const activePlayers = this.getActivePlayers();
+
+ if (activePlayers.length === 1) {
+ this.endHand([activePlayers[0]]);
+ return;
+ }
+
+ // Оценка рук
+ const playerHands = activePlayers.map(player => ({
+ player,
+ handResult: evaluateHand([...player.hand, ...this.communityCards])
+ }));
+
+ // Сортировка по силе руки
+ playerHands.sort((a, b) => {
+ if (b.handResult.rank !== a.handResult.rank) {
+ return b.handResult.rank - a.handResult.rank;
+ }
+ for (let i = 0; i < a.handResult.kickers.length; i++) {
+ if (b.handResult.kickers[i] !== a.handResult.kickers[i]) {
+ return b.handResult.kickers[i] - a.handResult.kickers[i];
+ }
+ }
+ return 0;
+ });
+
+ // Находим победителей (может быть сплит)
+ const winners = [playerHands[0]];
+ for (let i = 1; i < playerHands.length; i++) {
+ const cmp = this.compareHands(playerHands[0].handResult, playerHands[i].handResult);
+ if (cmp === 0) {
+ winners.push(playerHands[i]);
+ } else {
+ break;
+ }
+ }
+
+ this.endHand(
+ winners.map(w => w.player),
+ playerHands.map(ph => ({ player: ph.player, hand: ph.handResult }))
+ );
+ }
+
+ /**
+ * Сравнение двух рук
+ */
+ compareHands(h1, h2) {
+ if (h1.rank !== h2.rank) return h2.rank - h1.rank;
+ for (let i = 0; i < h1.kickers.length; i++) {
+ if (h1.kickers[i] !== h2.kickers[i]) {
+ return h2.kickers[i] - h1.kickers[i];
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Завершить раздачу
+ */
+ endHand(winners, allHands = null) {
+ const winAmount = Math.floor(this.pot / winners.length);
+
+ for (const winner of winners) {
+ winner.chips += winAmount;
+ }
+
+ // Остаток при нечётном сплите
+ const remainder = this.pot % winners.length;
+ if (remainder > 0) {
+ winners[0].chips += remainder;
+ }
+
+ this.gamePhase = 'showdown';
+ this.isGameStarted = false;
+
+ const result = {
+ winners: winners.map(w => ({
+ id: w.id,
+ name: w.name,
+ amount: winAmount,
+ hand: allHands ? allHands.find(h => h.player.id === w.id)?.hand : null
+ })),
+ pot: this.pot,
+ hands: allHands
+ };
+
+ this.onHandEnd(result);
+ this.onUpdate();
+
+ return result;
+ }
+}
+
+// =============================================================================
+// ОЦЕНКА РУК
+// =============================================================================
+
+/**
+ * Оценить покерную руку из 7 карт
+ */
+function evaluateHand(cards) {
+ const allCombinations = getCombinations(cards, 5);
+ let bestHand = null;
+
+ for (const combo of allCombinations) {
+ const hand = evaluateFiveCards(combo);
+ if (!bestHand || compareHandResults(hand, bestHand) > 0) {
+ bestHand = hand;
+ }
+ }
+
+ return bestHand;
+}
+
+/**
+ * Получить все комбинации из n элементов
+ */
+function getCombinations(arr, n) {
+ if (n === 0) return [[]];
+ if (arr.length === 0) return [];
+
+ const [first, ...rest] = arr;
+ const withFirst = getCombinations(rest, n - 1).map(c => [first, ...c]);
+ const withoutFirst = getCombinations(rest, n);
+
+ return [...withFirst, ...withoutFirst];
+}
+
+/**
+ * Оценить 5 карт
+ */
+function evaluateFiveCards(cards) {
+ const sortedCards = [...cards].sort((a, b) => b.value - a.value);
+ const values = sortedCards.map(c => c.value);
+ const suits = sortedCards.map(c => c.suit);
+
+ const isFlush = suits.every(s => s === suits[0]);
+ const isStraight = checkStraight(values);
+ const isWheel = values.join(',') === '14,5,4,3,2'; // A-2-3-4-5
+
+ const valueCounts = {};
+ for (const v of values) {
+ valueCounts[v] = (valueCounts[v] || 0) + 1;
+ }
+
+ const counts = Object.values(valueCounts).sort((a, b) => b - a);
+ const uniqueValues = Object.keys(valueCounts)
+ .map(Number)
+ .sort((a, b) => {
+ if (valueCounts[b] !== valueCounts[a]) {
+ return valueCounts[b] - valueCounts[a];
+ }
+ return b - a;
+ });
+
+ // Рояль-флеш
+ if (isFlush && isStraight && values[0] === 14) {
+ return { rank: 10, name: 'Роял-флеш', kickers: values };
+ }
+
+ // Стрит-флеш
+ if (isFlush && (isStraight || isWheel)) {
+ return { rank: 9, name: 'Стрит-флеш', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
+ }
+
+ // Каре
+ if (counts[0] === 4) {
+ return { rank: 8, name: 'Каре', kickers: uniqueValues };
+ }
+
+ // Фулл-хаус
+ if (counts[0] === 3 && counts[1] === 2) {
+ return { rank: 7, name: 'Фулл-хаус', kickers: uniqueValues };
+ }
+
+ // Флеш
+ if (isFlush) {
+ return { rank: 6, name: 'Флеш', kickers: values };
+ }
+
+ // Стрит
+ if (isStraight || isWheel) {
+ return { rank: 5, name: 'Стрит', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
+ }
+
+ // Сет
+ if (counts[0] === 3) {
+ return { rank: 4, name: 'Сет', kickers: uniqueValues };
+ }
+
+ // Две пары
+ if (counts[0] === 2 && counts[1] === 2) {
+ return { rank: 3, name: 'Две пары', kickers: uniqueValues };
+ }
+
+ // Пара
+ if (counts[0] === 2) {
+ return { rank: 2, name: 'Пара', kickers: uniqueValues };
+ }
+
+ // Старшая карта
+ return { rank: 1, name: 'Старшая карта', kickers: values };
+}
+
+/**
+ * Проверка на стрит
+ */
+function checkStraight(values) {
+ for (let i = 0; i < values.length - 1; i++) {
+ if (values[i] - values[i + 1] !== 1) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Сравнение результатов рук
+ */
+function compareHandResults(h1, h2) {
+ if (h1.rank !== h2.rank) return h1.rank - h2.rank;
+ for (let i = 0; i < h1.kickers.length; i++) {
+ if (h1.kickers[i] !== h2.kickers[i]) {
+ return h1.kickers[i] - h2.kickers[i];
+ }
+ }
+ return 0;
+}
+
+/**
+ * Получить силу руки для отображения
+ */
+function getHandStrength(cards, communityCards) {
+ if (cards.length < 2) return null;
+
+ const allCards = [...cards];
+ if (communityCards && communityCards.length > 0) {
+ allCards.push(...communityCards);
+ }
+
+ if (allCards.length < 5) {
+ // Только карманные карты - оцениваем префлоп силу
+ return getPreflopHandName(cards);
+ }
+
+ const hand = evaluateHand(allCards);
+ return hand.name;
+}
+
+/**
+ * Получить название префлоп руки
+ */
+function getPreflopHandName(cards) {
+ if (cards.length !== 2) return '';
+
+ const [c1, c2] = cards;
+ const highCard = c1.value > c2.value ? c1 : c2;
+ const lowCard = c1.value > c2.value ? c2 : c1;
+ const suited = c1.suit === c2.suit;
+
+ let name = '';
+
+ if (c1.value === c2.value) {
+ name = `Пара ${c1.rank}`;
+ } else {
+ name = `${highCard.rank}${lowCard.rank}${suited ? 's' : 'o'}`;
+ }
+
+ return name;
+}
+
+// Экспорт для использования в других модулях
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { Card, Deck, Player, PokerGame, evaluateHand, getHandStrength };
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2135cd7
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,415 @@
+
+
+
+
+
+ 🃏 Texas Hold'em Poker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Комната
+
+
+
+
+
+
+ Блайнды: 5/10
+ Игроки: 1/6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Банк
+ 0
+
+
+ Ожидание
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Результат
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/main.js b/public/main.js
new file mode 100644
index 0000000..4fac81d
--- /dev/null
+++ b/public/main.js
@@ -0,0 +1,1494 @@
+/**
+ * =============================================================================
+ * Texas Hold'em - Главный клиентский модуль
+ * UI, WebSocket, звуки, управление игрой
+ * =============================================================================
+ */
+
+// =============================================================================
+// ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ
+// =============================================================================
+
+let game = null; // Локальная игра (одиночный режим)
+let ws = null; // WebSocket соединение
+let currentPlayerId = null; // ID текущего игрока
+let currentRoomId = null; // ID текущей комнаты
+let isMultiplayer = false; // Режим игры
+let settings = {}; // Настройки
+let leaderboard = []; // Таблица лидеров
+let soundEnabled = true; // Звук включён
+let currentGamePhase = 'waiting'; // Текущая фаза игры для мультиплеера
+let wsConnecting = false; // Флаг подключения WebSocket
+
+// Выбранные опции
+const selectedOptions = {
+ 'bot-count': '1',
+ 'ai-difficulty': '1',
+ 'starting-stack': '1000',
+ 'blinds': '5/10',
+ 'max-players': '6'
+};
+
+// Звуки
+const sounds = {
+ deal: null,
+ check: null,
+ call: null,
+ bet: null,
+ fold: null,
+ win: null,
+ chip: null,
+ message: null
+};
+
+// =============================================================================
+// ИНИЦИАЛИЗАЦИЯ
+// =============================================================================
+
+document.addEventListener('DOMContentLoaded', () => {
+ loadSettings();
+ loadLeaderboard();
+ initSounds();
+
+ // Восстанавливаем имя игрока
+ const savedName = localStorage.getItem('playerName');
+ if (savedName) {
+ document.getElementById('sp-player-name').value = savedName;
+ document.getElementById('mp-player-name').value = savedName;
+ }
+});
+
+/**
+ * Инициализация звуков
+ */
+function initSounds() {
+ // Простые звуки через Web Audio API
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+
+ const createBeep = (frequency, duration) => {
+ return () => {
+ if (!soundEnabled) return;
+
+ const oscillator = audioContext.createOscillator();
+ const gainNode = audioContext.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ oscillator.frequency.value = frequency;
+ oscillator.type = 'sine';
+
+ gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + duration);
+
+ oscillator.start(audioContext.currentTime);
+ oscillator.stop(audioContext.currentTime + duration);
+ };
+ };
+
+ sounds.deal = createBeep(400, 0.1);
+ sounds.check = createBeep(600, 0.1);
+ sounds.call = createBeep(500, 0.15);
+ sounds.bet = createBeep(700, 0.2);
+ sounds.fold = createBeep(300, 0.2);
+ sounds.win = createBeep(800, 0.4);
+ sounds.chip = createBeep(1000, 0.05);
+ sounds.message = createBeep(900, 0.1);
+}
+
+/**
+ * Воспроизвести звук
+ */
+function playSound(name) {
+ if (sounds[name] && soundEnabled) {
+ sounds[name]();
+ }
+}
+
+// =============================================================================
+// НАВИГАЦИЯ
+// =============================================================================
+
+/**
+ * Показать экран
+ */
+function showScreen(screenId) {
+ document.querySelectorAll('.screen').forEach(screen => {
+ screen.classList.remove('active');
+ });
+ document.getElementById(screenId).classList.add('active');
+
+ // Особая логика для мультиплеера
+ if (screenId === 'multiplayer-menu') {
+ connectWebSocket();
+ }
+
+ // Загрузка лидерборда
+ if (screenId === 'leaderboard-screen') {
+ renderLeaderboard();
+ }
+}
+
+/**
+ * Переключение вкладок
+ */
+function switchTab(tabId) {
+ const parent = event.target.closest('.glass-container');
+
+ parent.querySelectorAll('.tab').forEach(tab => {
+ tab.classList.remove('active');
+ });
+ parent.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.remove('active');
+ });
+
+ event.target.classList.add('active');
+ document.getElementById(tabId).classList.add('active');
+}
+
+/**
+ * Выбор опции
+ */
+function selectOption(button, optionGroup) {
+ const group = button.parentElement;
+ group.querySelectorAll('.btn-option').forEach(btn => {
+ btn.classList.remove('active');
+ });
+ button.classList.add('active');
+ selectedOptions[optionGroup] = button.dataset.value;
+}
+
+// =============================================================================
+// ОДИНОЧНАЯ ИГРА
+// =============================================================================
+
+/**
+ * Начать одиночную игру
+ */
+function startSinglePlayer() {
+ isMultiplayer = false;
+
+ const playerName = document.getElementById('sp-player-name').value || 'Игрок';
+ const botCount = parseInt(selectedOptions['bot-count']);
+ const aiDifficulty = parseInt(selectedOptions['ai-difficulty']);
+ const startingStack = parseInt(selectedOptions['starting-stack']);
+
+ // Сохраняем имя
+ localStorage.setItem('playerName', playerName);
+
+ // Создаём игру
+ game = new PokerGame({
+ smallBlind: 5,
+ bigBlind: 10,
+ onUpdate: updateGameUI,
+ onAction: onPlayerAction,
+ onHandEnd: onHandEnd
+ });
+
+ // Добавляем игрока
+ const player = new Player('player_0', playerName, startingStack, false);
+ game.addPlayer(player);
+ currentPlayerId = player.id;
+
+ // Добавляем ботов
+ const personalityKeys = typeof botPersonalities !== 'undefined'
+ ? Object.keys(botPersonalities)
+ : ['professional', 'aggressive', 'mathematical'];
+
+ for (let i = 0; i < botCount; i++) {
+ const botName = pokerAI.getRandomName();
+ const bot = new Player(`bot_${i}`, botName, startingStack, true, aiDifficulty);
+
+ // Присваиваем случайную личность боту
+ bot.personalityId = personalityKeys[i % personalityKeys.length];
+
+ game.addPlayer(bot);
+ }
+
+ // Показываем игровой экран
+ showScreen('game-screen');
+
+ // Начинаем раздачу
+ setTimeout(() => {
+ game.startNewHand();
+ }, 500);
+}
+
+/**
+ * Новая раздача
+ */
+function startNewHand() {
+ document.getElementById('new-hand-btn').style.display = 'none';
+
+ if (isMultiplayer) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'new_hand' }));
+ }
+ } else {
+ if (game && game.getPlayersWithChips().length >= 2) {
+ game.startNewHand();
+ } else {
+ showNotification('Недостаточно игроков с фишками', 'error');
+ }
+ }
+}
+
+// =============================================================================
+// МУЛЬТИПЛЕЕР
+// =============================================================================
+
+/**
+ * Подключение к WebSocket серверу
+ */
+function connectWebSocket() {
+ const serverUrl = settings.serverUrl || 'ws://localhost:3000';
+
+ // Если уже подключены или подключаемся
+ if ((ws && ws.readyState === WebSocket.OPEN) || wsConnecting) {
+ return Promise.resolve();
+ }
+
+ // Если соединение в процессе закрытия, ждём
+ if (ws && ws.readyState === WebSocket.CONNECTING) {
+ return new Promise((resolve) => {
+ ws.addEventListener('open', resolve, { once: true });
+ });
+ }
+
+ wsConnecting = true;
+
+ return new Promise((resolve, reject) => {
+ try {
+ ws = new WebSocket(serverUrl);
+
+ ws.onopen = () => {
+ console.log('Подключено к серверу');
+ wsConnecting = false;
+ document.getElementById('room-list').innerHTML = 'Загрузка комнат...
';
+ resolve();
+ };
+
+ ws.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ handleServerMessage(message);
+ };
+
+ ws.onclose = () => {
+ console.log('Отключено от сервера');
+ wsConnecting = false;
+ ws = null;
+ document.getElementById('room-list').innerHTML = 'Соединение потеряно.
';
+ };
+
+ ws.onerror = (error) => {
+ console.error('WebSocket ошибка:', error);
+ wsConnecting = false;
+ document.getElementById('room-list').innerHTML = 'Ошибка подключения к серверу.
';
+ reject(error);
+ };
+ } catch (error) {
+ console.error('Ошибка создания WebSocket:', error);
+ wsConnecting = false;
+ reject(error);
+ }
+ });
+}
+
+/**
+ * Обработка сообщений от сервера
+ */
+function handleServerMessage(message) {
+ switch (message.type) {
+ case 'room_list':
+ renderRoomList(message.rooms);
+ break;
+
+ case 'room_joined':
+ currentPlayerId = message.playerId;
+ currentRoomId = message.roomId;
+ showRoomLobby(message.room);
+ break;
+
+ case 'player_joined':
+ updateLobbyPlayers(message.room);
+ addChatMessage('lobby', null, `${message.player.name} присоединился`, true);
+ break;
+
+ case 'player_left':
+ case 'player_disconnected':
+ updateLobbyPlayers(message.room);
+ break;
+
+ case 'game_started':
+ isMultiplayer = true;
+ currentGamePhase = message.room.gamePhase;
+ showScreen('game-screen');
+ updateGameUIFromServer(message.room);
+ break;
+
+ case 'game_update':
+ currentGamePhase = message.room.gamePhase;
+ updateGameUIFromServer(message.room);
+ if (message.lastAction) {
+ playSound(message.lastAction.action);
+ }
+ break;
+
+ case 'chat':
+ if (document.getElementById('game-screen').classList.contains('active')) {
+ addChatMessage('game', message.playerName, message.message);
+ } else {
+ addChatMessage('lobby', message.playerName, message.message);
+ }
+ playSound('message');
+ break;
+
+ case 'error':
+ showNotification(message.message, 'error');
+ break;
+ }
+}
+
+/**
+ * Отрисовка списка комнат
+ */
+function renderRoomList(rooms) {
+ const container = document.getElementById('room-list');
+
+ if (rooms.length === 0) {
+ container.innerHTML = 'Нет доступных комнат
';
+ return;
+ }
+
+ container.innerHTML = rooms.map(room => `
+
+
+
${room.name}
+ Блайнды: ${room.smallBlind}/${room.bigBlind}
+
+
+
+ ${room.players}/${room.maxPlayers}
+
+
+ `).join('');
+}
+
+/**
+ * Обновить список комнат
+ */
+function refreshRooms() {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'get_rooms' }));
+ } else {
+ connectWebSocket();
+ }
+}
+
+/**
+ * Создать комнату
+ */
+async function createRoom() {
+ // Проверяем и устанавливаем соединение
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ try {
+ await connectWebSocket();
+ } catch (e) {
+ showNotification('Не удалось подключиться к серверу', 'error');
+ return;
+ }
+ }
+
+ // Повторная проверка после await
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ showNotification('Нет соединения с сервером', 'error');
+ return;
+ }
+
+ const playerName = document.getElementById('mp-player-name').value || 'Игрок';
+ const roomName = document.getElementById('room-name').value || `Комната ${playerName}`;
+ const [smallBlind, bigBlind] = selectedOptions['blinds'].split('/').map(Number);
+ const maxPlayers = parseInt(selectedOptions['max-players']);
+
+ localStorage.setItem('playerName', playerName);
+
+ ws.send(JSON.stringify({
+ type: 'create_room',
+ roomName,
+ playerName,
+ smallBlind,
+ bigBlind,
+ maxPlayers
+ }));
+}
+
+/**
+ * Присоединиться к комнате
+ */
+async function joinRoom(roomId) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ try {
+ await connectWebSocket();
+ } catch (e) {
+ showNotification('Не удалось подключиться к серверу', 'error');
+ return;
+ }
+ }
+
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ showNotification('Нет соединения с сервером', 'error');
+ return;
+ }
+
+ const playerName = document.getElementById('mp-player-name').value || 'Игрок';
+ localStorage.setItem('playerName', playerName);
+
+ ws.send(JSON.stringify({
+ type: 'join_room',
+ roomId,
+ playerName
+ }));
+}
+
+/**
+ * Показать лобби комнаты
+ */
+function showRoomLobby(room) {
+ showScreen('room-lobby');
+
+ document.getElementById('lobby-room-name').textContent = room.name;
+ document.getElementById('lobby-blinds').textContent = `${room.smallBlind}/${room.bigBlind}`;
+
+ updateLobbyPlayers(room);
+}
+
+/**
+ * Обновить игроков в лобби
+ */
+function updateLobbyPlayers(room) {
+ const container = document.getElementById('lobby-players');
+
+ container.innerHTML = room.players.map((player, index) => `
+
+
+ ${player.name.charAt(0).toUpperCase()}
+
+
${player.name}
+
+ `).join('');
+
+ document.getElementById('lobby-player-count').textContent =
+ `${room.players.length}/${room.maxPlayers || 6}`;
+
+ const startBtn = document.getElementById('start-game-btn');
+ startBtn.style.display = room.players[0]?.id === currentPlayerId ? 'block' : 'none';
+ startBtn.disabled = room.players.length < 2;
+}
+
+/**
+ * Выйти из комнаты
+ */
+function leaveRoom() {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'leave_room' }));
+ }
+ currentRoomId = null;
+ showScreen('multiplayer-menu');
+}
+
+/**
+ * Начать мультиплеерную игру
+ */
+function startMultiplayerGame() {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'start_game' }));
+ }
+}
+
+// =============================================================================
+// ИГРОВОЙ UI
+// =============================================================================
+
+/**
+ * Обновить UI игры (локальный режим)
+ */
+function updateGameUI() {
+ if (!game) return;
+
+ document.getElementById('pot-amount').textContent = game.pot;
+
+ const phaseNames = {
+ 'waiting': 'Ожидание',
+ 'preflop': 'Префлоп',
+ 'flop': 'Флоп',
+ 'turn': 'Тёрн',
+ 'river': 'Ривер',
+ 'showdown': 'Вскрытие'
+ };
+ document.getElementById('game-phase').textContent = phaseNames[game.gamePhase] || game.gamePhase;
+
+ renderCommunityCards(game.communityCards);
+ renderPlayers(game.players, game.currentPlayerIndex, currentPlayerId);
+
+ const player = game.players.find(p => p.id === currentPlayerId);
+ if (player) {
+ renderPlayerHand(player.hand, game.communityCards);
+ }
+
+ updateActionPanel(player, game);
+}
+
+/**
+ * Обновить UI из данных сервера
+ */
+function updateGameUIFromServer(room) {
+ document.getElementById('pot-amount').textContent = room.pot;
+
+ const phaseNames = {
+ 'waiting': 'Ожидание',
+ 'preflop': 'Префлоп',
+ 'flop': 'Флоп',
+ 'turn': 'Тёрн',
+ 'river': 'Ривер',
+ 'showdown': 'Вскрытие'
+ };
+ document.getElementById('game-phase').textContent = phaseNames[room.gamePhase] || room.gamePhase;
+
+ // Конвертируем данные сервера в объекты Card
+ const communityCards = room.communityCards.map(c => new Card(c.suit, c.rank));
+ renderCommunityCards(communityCards);
+
+ // Конвертируем игроков
+ const players = room.players.map((p, i) => {
+ const player = {
+ ...p,
+ hand: p.hand ? p.hand.map(c => new Card(c.suit, c.rank)) : []
+ };
+ return player;
+ });
+
+ renderPlayers(players, room.currentPlayerIndex, currentPlayerId);
+
+ const myPlayer = players.find(p => p.id === currentPlayerId);
+ if (myPlayer) {
+ renderPlayerHand(myPlayer.hand, communityCards);
+ updateActionPanelFromServer(myPlayer, room);
+ }
+
+ // Показываем кнопку новой раздачи если игра завершена
+ if (room.gamePhase === 'showdown' || !room.isGameStarted) {
+ setTimeout(() => {
+ document.getElementById('new-hand-btn').style.display = 'block';
+ }, 2000);
+ }
+}
+
+/**
+ * Отрисовка общих карт
+ */
+function renderCommunityCards(cards) {
+ const container = document.getElementById('community-cards');
+ container.innerHTML = '';
+
+ for (let i = 0; i < 5; i++) {
+ if (cards[i]) {
+ const cardEl = cards[i].toHTML ? cards[i].toHTML() : createCardElement(cards[i]);
+ cardEl.classList.add('dealing');
+ cardEl.style.animationDelay = `${i * 0.1}s`;
+ container.appendChild(cardEl);
+ } else {
+ const placeholder = document.createElement('div');
+ placeholder.className = 'card card-placeholder';
+ placeholder.style.opacity = '0.2';
+ container.appendChild(placeholder);
+ }
+ }
+
+ playSound('deal');
+}
+
+/**
+ * Создать элемент карты из данных
+ */
+function createCardElement(cardData, isSmall = false) {
+ const card = document.createElement('div');
+ card.className = `card ${cardData.suit}${isSmall ? ' card-small' : ''}`;
+
+ const symbols = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+ };
+
+ card.innerHTML = `
+ ${cardData.rank}
+ ${symbols[cardData.suit]}
+ `;
+
+ return card;
+}
+
+/**
+ * Отрисовка игроков за столом
+ */
+function renderPlayers(players, currentIndex, myPlayerId) {
+ const container = document.getElementById('player-positions');
+ container.innerHTML = '';
+
+ // Позиции для ставок (относительно позиции игрока)
+ const betPositions = [
+ { top: '-40px', left: '50%' },
+ { top: '-30px', left: '80%' },
+ { top: '50%', left: '100%' },
+ { top: '100%', left: '50%' },
+ { top: '50%', left: '-30%' },
+ { top: '-30px', left: '20%' }
+ ];
+
+ players.forEach((player, index) => {
+ // Пропускаем текущего игрока (он отображается снизу)
+ if (player.id === myPlayerId) return;
+
+ const seat = document.createElement('div');
+ seat.className = 'player-seat';
+ seat.dataset.position = index;
+
+ const isCurrentTurn = index === currentIndex;
+ const playerBox = document.createElement('div');
+ playerBox.className = `player-box ${player.folded ? 'folded' : ''} ${isCurrentTurn ? 'current-turn' : ''}`;
+
+ playerBox.innerHTML = `
+ ${player.name}
+ ${player.chips}
+ ${player.lastAction ? `${player.lastAction}
` : ''}
+ `;
+
+ // Определяем, нужно ли показывать карты
+ // Показываем карты только на showdown и только не фолднувшим игрокам
+ const gamePhase = isMultiplayer ? currentGamePhase : (game ? game.gamePhase : 'waiting');
+ const showCards = gamePhase === 'showdown' && !player.folded;
+
+ // Карты игрока (мини)
+ if (showCards && player.hand && player.hand.length > 0) {
+ const cardsDiv = document.createElement('div');
+ cardsDiv.className = 'player-cards-mini';
+
+ player.hand.forEach(card => {
+ const cardEl = card.toHTML ? card.toHTML(true) : createCardElement(card, true);
+ cardsDiv.appendChild(cardEl);
+ });
+
+ playerBox.appendChild(cardsDiv);
+ } else if ((player.hasCards || (player.hand && player.hand.length > 0)) && !player.folded) {
+ // Карты рубашкой (не показываем карты соперников до showdown)
+ const cardsDiv = document.createElement('div');
+ cardsDiv.className = 'player-cards-mini';
+ cardsDiv.innerHTML = `
+
+
+ `;
+ playerBox.appendChild(cardsDiv);
+ }
+
+ seat.appendChild(playerBox);
+
+ // Позиционные маркеры
+ if (player.isDealer) {
+ const dealerBtn = document.createElement('div');
+ dealerBtn.className = 'dealer-button';
+ dealerBtn.textContent = 'D';
+ dealerBtn.style.cssText = 'bottom: -35px; left: 50%; transform: translateX(-50%);';
+ seat.appendChild(dealerBtn);
+ }
+
+ if (player.isSmallBlind || player.isBigBlind) {
+ const blindIndicator = document.createElement('div');
+ blindIndicator.className = `blind-indicator ${player.isSmallBlind ? 'sb' : 'bb'}`;
+ blindIndicator.textContent = player.isSmallBlind ? 'SB' : 'BB';
+ blindIndicator.style.cssText = 'bottom: -35px; right: 0;';
+ seat.appendChild(blindIndicator);
+ }
+
+ // Ставка игрока
+ if (player.bet > 0) {
+ const betDisplay = document.createElement('div');
+ betDisplay.className = 'player-bet-display';
+ betDisplay.textContent = player.bet;
+ betDisplay.style.cssText = `${betPositions[index % 6].top}; left: ${betPositions[index % 6].left};`;
+ seat.appendChild(betDisplay);
+ }
+
+ container.appendChild(seat);
+ });
+}
+
+/**
+ * Отрисовка руки игрока
+ */
+function renderPlayerHand(hand, communityCards) {
+ const container = document.getElementById('player-cards');
+ container.innerHTML = '';
+
+ if (!hand || hand.length === 0) {
+ container.innerHTML = `
+
+
+ `;
+ return;
+ }
+
+ hand.forEach((card, i) => {
+ const cardEl = card.toHTML ? card.toHTML() : createCardElement(card);
+ cardEl.classList.add('dealing');
+ cardEl.style.animationDelay = `${i * 0.15}s`;
+ container.appendChild(cardEl);
+ });
+
+ // Показываем силу руки
+ if (settings.showHandStrength !== false) {
+ const strength = getHandStrength(hand, communityCards || []);
+ document.getElementById('hand-strength').textContent = strength || '';
+ }
+}
+
+/**
+ * Обновить панель действий
+ */
+function updateActionPanel(player, gameState) {
+ if (!player || !gameState || !gameState.isGameStarted) {
+ document.getElementById('action-panel').style.display = 'none';
+ return;
+ }
+
+ const isMyTurn = gameState.getCurrentPlayer()?.id === player.id;
+ document.getElementById('action-panel').style.display = isMyTurn ? 'block' : 'none';
+
+ if (!isMyTurn) return;
+
+ const toCall = gameState.currentBet - player.bet;
+ const canCheck = toCall === 0;
+ const canBet = gameState.currentBet === 0;
+
+ document.getElementById('btn-check').style.display = canCheck ? 'inline-flex' : 'none';
+ document.getElementById('btn-call').style.display = !canCheck ? 'inline-flex' : 'none';
+ document.getElementById('btn-bet').style.display = canBet ? 'inline-flex' : 'none';
+ document.getElementById('btn-raise').style.display = !canBet ? 'inline-flex' : 'none';
+
+ document.getElementById('call-amount').textContent = toCall;
+
+ // Настройка слайдера
+ const slider = document.getElementById('bet-slider');
+ const minBet = canBet ? gameState.bigBlind : gameState.currentBet + gameState.lastRaiseAmount;
+ slider.min = minBet;
+ slider.max = player.chips + player.bet;
+ slider.value = minBet;
+ document.getElementById('bet-value').value = minBet;
+}
+
+/**
+ * Обновить панель действий из данных сервера
+ */
+function updateActionPanelFromServer(player, room) {
+ if (!player || room.gamePhase === 'showdown' || !room.isGameStarted) {
+ document.getElementById('action-panel').style.display = 'none';
+ return;
+ }
+
+ const isMyTurn = room.currentPlayerId === player.id;
+ document.getElementById('action-panel').style.display = isMyTurn ? 'block' : 'none';
+
+ if (!isMyTurn) return;
+
+ const toCall = room.currentBet - player.bet;
+ const canCheck = toCall === 0;
+ const canBet = room.currentBet === 0;
+
+ document.getElementById('btn-check').style.display = canCheck ? 'inline-flex' : 'none';
+ document.getElementById('btn-call').style.display = !canCheck ? 'inline-flex' : 'none';
+ document.getElementById('btn-bet').style.display = canBet ? 'inline-flex' : 'none';
+ document.getElementById('btn-raise').style.display = !canBet ? 'inline-flex' : 'none';
+
+ document.getElementById('call-amount').textContent = toCall;
+
+ const slider = document.getElementById('bet-slider');
+ const bigBlind = room.bigBlind || 10;
+ const minBet = canBet ? bigBlind : room.minRaise || room.currentBet * 2;
+ slider.min = minBet;
+ slider.max = player.chips + player.bet;
+ slider.value = minBet;
+ document.getElementById('bet-value').value = minBet;
+}
+
+// =============================================================================
+// ДЕЙСТВИЯ ИГРОКА
+// =============================================================================
+
+/**
+ * Выполнить действие
+ */
+function playerAction(action) {
+ playSound(action);
+
+ if (isMultiplayer) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'action',
+ action: action,
+ amount: 0
+ }));
+ }
+ } else {
+ if (game) {
+ game.processAction(currentPlayerId, action, 0);
+ }
+ }
+
+ hideBetSlider();
+}
+
+/**
+ * Показать слайдер ставки
+ */
+function showBetSlider() {
+ document.getElementById('bet-slider-container').style.display = 'block';
+}
+
+/**
+ * Скрыть слайдер ставки
+ */
+function hideBetSlider() {
+ document.getElementById('bet-slider-container').style.display = 'none';
+}
+
+/**
+ * Обновить значение ставки из слайдера
+ */
+function updateBetValue() {
+ const slider = document.getElementById('bet-slider');
+ document.getElementById('bet-value').value = slider.value;
+}
+
+/**
+ * Обновить слайдер из инпута
+ */
+function updateSliderFromInput() {
+ const input = document.getElementById('bet-value');
+ const slider = document.getElementById('bet-slider');
+
+ let value = parseInt(input.value);
+ value = Math.max(parseInt(slider.min), Math.min(parseInt(slider.max), value));
+
+ slider.value = value;
+ input.value = value;
+}
+
+/**
+ * Установить пресет ставки
+ */
+function setBetPreset(multiplier) {
+ let pot;
+
+ if (isMultiplayer) {
+ pot = parseInt(document.getElementById('pot-amount').textContent) || 0;
+ } else {
+ pot = game ? game.pot : 0;
+ }
+
+ const betAmount = Math.floor(pot * multiplier);
+ const slider = document.getElementById('bet-slider');
+ const value = Math.max(parseInt(slider.min), Math.min(parseInt(slider.max), betAmount));
+
+ slider.value = value;
+ document.getElementById('bet-value').value = value;
+}
+
+/**
+ * Подтвердить ставку
+ */
+function confirmBet() {
+ const amount = parseInt(document.getElementById('bet-value').value);
+ const action = document.getElementById('btn-bet').style.display !== 'none' ? 'bet' : 'raise';
+
+ playSound('bet');
+
+ if (isMultiplayer) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'action',
+ action: action,
+ amount: amount
+ }));
+ }
+ } else {
+ if (game) {
+ game.processAction(currentPlayerId, action, amount);
+ }
+ }
+
+ hideBetSlider();
+}
+
+// =============================================================================
+// СОБЫТИЯ ИГРЫ
+// =============================================================================
+
+/**
+ * Событие действия игрока
+ */
+function onPlayerAction(player, action, amount) {
+ // Можно добавить анимации или уведомления
+ console.log(`${player.name}: ${action} ${amount || ''}`);
+}
+
+/**
+ * Событие завершения раздачи
+ */
+function onHandEnd(result) {
+ playSound('win');
+
+ // Показываем результат
+ const modal = document.getElementById('hand-result-modal');
+ const title = document.getElementById('result-title');
+ const details = document.getElementById('result-details');
+
+ const winner = result.winners[0];
+
+ title.textContent = result.winners.length > 1 ? 'Сплит!' : `${winner.name} победил!`;
+
+ let detailsHTML = `
+ +${winner.amount}
+ `;
+
+ if (winner.hand) {
+ detailsHTML += `${winner.hand.name}
`;
+ }
+
+ if (result.hands && result.hands.length > 1) {
+ detailsHTML += '';
+ result.hands.forEach(h => {
+ detailsHTML += `
${h.player.name}: ${h.hand.name}
`;
+ });
+ detailsHTML += '
';
+ }
+
+ details.innerHTML = detailsHTML;
+ modal.classList.add('active');
+
+ // Обновляем лидерборд
+ updateLeaderboard(result);
+
+ // Показываем кнопку новой раздачи
+ setTimeout(() => {
+ document.getElementById('new-hand-btn').style.display = 'block';
+ }, 1000);
+}
+
+/**
+ * Закрыть модальное окно результата
+ */
+function closeResultModal() {
+ document.getElementById('hand-result-modal').classList.remove('active');
+}
+
+// =============================================================================
+// ЧАТ
+// =============================================================================
+
+/**
+ * Добавить сообщение в чат
+ */
+function addChatMessage(chatType, sender, message, isSystem = false, isTyping = false) {
+ const containerId = chatType === 'game' ? 'game-chat-messages' : 'lobby-chat-messages';
+ const container = document.getElementById(containerId);
+
+ const msgDiv = document.createElement('div');
+ msgDiv.className = `chat-message ${isSystem ? 'system' : ''} ${isTyping ? 'typing-indicator' : ''}`;
+
+ if (isTyping) {
+ msgDiv.dataset.sender = sender;
+ }
+
+ if (isSystem) {
+ msgDiv.textContent = message;
+ } else {
+ const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ if (isTyping) {
+ msgDiv.innerHTML = `
+ ${sender}: печатает...
+ ${time}
+ `;
+ } else {
+ msgDiv.innerHTML = `
+ ${sender}: ${message}
+ ${time}
+ `;
+ }
+ }
+
+ container.appendChild(msgDiv);
+ container.scrollTop = container.scrollHeight;
+}
+
+/**
+ * Удалить индикатор "печатает..." для конкретного отправителя
+ */
+function removeTypingIndicator(sender) {
+ const container = document.getElementById('game-chat-messages');
+ const typingMsg = container.querySelector(`.typing-indicator[data-sender="${sender}"]`);
+ if (typingMsg) {
+ typingMsg.remove();
+ }
+}
+
+/**
+ * Отправить сообщение в чат лобби
+ */
+function sendLobbyChat() {
+ const input = document.getElementById('lobby-chat-input');
+ const message = input.value.trim();
+
+ if (!message) return;
+
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'chat',
+ message: message
+ }));
+ }
+
+ input.value = '';
+}
+
+/**
+ * Отправить сообщение в игровой чат
+ */
+function sendGameChat() {
+ const input = document.getElementById('game-chat-input');
+ const message = input.value.trim();
+
+ if (!message) return;
+
+ if (isMultiplayer && ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'chat',
+ message: message
+ }));
+ } else {
+ // Одиночная игра - ИИ отвечает
+ const playerName = localStorage.getItem('playerName') || 'Игрок';
+ addChatMessage('game', playerName, message);
+
+ // Получаем всех ботов для ответа
+ const bots = game.players.filter(p => p.isAI && !p.folded);
+
+ if (bots.length > 0) {
+ // Выбираем случайного бота для ответа
+ const bot = bots[Math.floor(Math.random() * bots.length)];
+
+ // Формируем контекст игры для LLM
+ const gameContext = {
+ phase: currentGamePhase,
+ pot: game.pot,
+ myChips: bot.chips,
+ lastAction: message,
+ communityCards: game.communityCards?.map(c => `${c.rank}${c.suit}`) || []
+ };
+
+ // Получаем личность бота
+ const botPersonality = typeof botPersonalities !== 'undefined'
+ ? botPersonalities[bot.personalityId] || botPersonalities.professional
+ : { style: 'default' };
+
+ // Показываем индикатор "печатает..."
+ addChatMessage('game', bot.name, '...', false, true);
+
+ // Вызываем LLM чат
+ (async () => {
+ try {
+ let response;
+
+ if (typeof llmChat !== 'undefined' && llmChat.getSettings().llmEnabled) {
+ response = await llmChat.chat(bot.id, botPersonality, message, gameContext);
+ } else {
+ // Запасные ответы если LLM отключён
+ await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1500));
+ response = llmChat?.getFallbackResponse(botPersonality, message, gameContext) || 'Удачи!';
+ }
+
+ // Удаляем индикатор "печатает..."
+ removeTypingIndicator(bot.name);
+
+ // Добавляем ответ
+ addChatMessage('game', bot.name, response);
+
+ } catch (error) {
+ console.error('Ошибка LLM чата:', error);
+ removeTypingIndicator(bot.name);
+ addChatMessage('game', bot.name, 'Удачи!');
+ }
+ })();
+ }
+ }
+
+ input.value = '';
+}
+
+/**
+ * Обработка Enter в чате лобби
+ */
+function handleLobbyChatKey(event) {
+ if (event.key === 'Enter') {
+ sendLobbyChat();
+ }
+}
+
+/**
+ * Обработка Enter в игровом чате
+ */
+function handleGameChatKey(event) {
+ if (event.key === 'Enter') {
+ sendGameChat();
+ }
+}
+
+/**
+ * Переключить чат
+ */
+function toggleChat() {
+ document.getElementById('game-chat').classList.toggle('expanded');
+}
+
+// =============================================================================
+// НАСТРОЙКИ
+// =============================================================================
+
+/**
+ * Загрузить настройки
+ */
+function loadSettings() {
+ const saved = localStorage.getItem('pokerSettings');
+ if (saved) {
+ settings = JSON.parse(saved);
+ } else {
+ settings = {
+ sound: true,
+ animations: true,
+ showHandStrength: true,
+ autofold: true,
+ serverUrl: 'ws://localhost:3000',
+ llmEnabled: false,
+ llmProvider: 'ollama',
+ llmApiUrl: 'http://localhost:11434',
+ llmModel: 'llama3.2',
+ llmApiKey: ''
+ };
+ }
+
+ // Применяем настройки к UI
+ document.getElementById('setting-sound').checked = settings.sound !== false;
+ document.getElementById('setting-animations').checked = settings.animations !== false;
+ document.getElementById('setting-hand-strength').checked = settings.showHandStrength !== false;
+ document.getElementById('setting-autofold').checked = settings.autofold !== false;
+ document.getElementById('server-url').value = settings.serverUrl || 'ws://localhost:3000';
+
+ // LLM настройки
+ const llmEnabled = document.getElementById('setting-llm-enabled');
+ const llmProvider = document.getElementById('llm-provider');
+ const llmApiUrl = document.getElementById('llm-api-url');
+ const llmModel = document.getElementById('llm-model');
+ const llmApiKey = document.getElementById('llm-api-key');
+
+ if (llmEnabled) llmEnabled.checked = settings.llmEnabled || false;
+ if (llmProvider) llmProvider.value = settings.llmProvider || 'ollama';
+ if (llmApiUrl) llmApiUrl.value = settings.llmApiUrl || 'http://localhost:11434';
+ if (llmModel) llmModel.value = settings.llmModel || 'llama3.2';
+ if (llmApiKey) llmApiKey.value = settings.llmApiKey || '';
+
+ // Обновляем видимость API ключа
+ updateLLMProviderUI();
+
+ soundEnabled = settings.sound !== false;
+}
+
+/**
+ * Обновить UI для LLM провайдера
+ */
+function updateLLMProviderUI() {
+ const provider = document.getElementById('llm-provider')?.value || 'ollama';
+ const apiKeyGroup = document.getElementById('llm-api-key-group');
+ const apiUrlLabel = document.getElementById('llm-api-url-label');
+ const apiUrl = document.getElementById('llm-api-url');
+
+ if (apiKeyGroup) {
+ apiKeyGroup.style.display = (provider === 'openai') ? 'block' : 'none';
+ }
+
+ if (apiUrlLabel && apiUrl) {
+ switch (provider) {
+ case 'ollama':
+ apiUrl.placeholder = 'http://localhost:11434';
+ if (!apiUrl.value || apiUrl.value.includes('localhost:1234')) {
+ apiUrl.value = 'http://localhost:11434';
+ }
+ break;
+ case 'lmstudio':
+ apiUrl.placeholder = 'http://localhost:1234';
+ if (!apiUrl.value || apiUrl.value.includes('localhost:11434')) {
+ apiUrl.value = 'http://localhost:1234';
+ }
+ break;
+ case 'openai':
+ apiUrl.placeholder = 'https://api.openai.com';
+ apiUrl.value = 'https://api.openai.com';
+ break;
+ }
+ }
+}
+
+/**
+ * Обновить настройки
+ */
+function updateSettings() {
+ settings = {
+ sound: document.getElementById('setting-sound').checked,
+ animations: document.getElementById('setting-animations').checked,
+ showHandStrength: document.getElementById('setting-hand-strength').checked,
+ autofold: document.getElementById('setting-autofold').checked,
+ serverUrl: document.getElementById('server-url').value,
+ llmEnabled: document.getElementById('setting-llm-enabled')?.checked || false,
+ llmProvider: document.getElementById('llm-provider')?.value || 'ollama',
+ llmApiUrl: document.getElementById('llm-api-url')?.value || 'http://localhost:11434',
+ llmModel: document.getElementById('llm-model')?.value || 'llama3.2',
+ llmApiKey: document.getElementById('llm-api-key')?.value || ''
+ };
+
+ localStorage.setItem('pokerSettings', JSON.stringify(settings));
+ soundEnabled = settings.sound;
+}
+
+/**
+ * Тестировать подключение к LLM
+ */
+async function testLLMConnection() {
+ const testBtn = document.getElementById('test-llm-btn');
+ const originalText = testBtn?.textContent;
+
+ if (testBtn) {
+ testBtn.textContent = 'Тестирую...';
+ testBtn.disabled = true;
+ }
+
+ // Сохраняем настройки перед тестом
+ updateSettings();
+
+ try {
+ if (typeof llmChat !== 'undefined') {
+ const result = await llmChat.testConnection();
+
+ if (result.success) {
+ showNotification('LLM подключён успешно! ' + (result.response?.substring(0, 50) || ''), 'success');
+ } else {
+ showNotification('Ошибка: ' + result.error, 'error');
+ }
+ } else {
+ showNotification('LLM модуль не загружен', 'error');
+ }
+ } catch (error) {
+ showNotification('Ошибка: ' + error.message, 'error');
+ }
+
+ if (testBtn) {
+ testBtn.textContent = originalText;
+ testBtn.disabled = false;
+ }
+}
+
+/**
+ * Выбрать LLM провайдера
+ */
+function selectLLMProvider(btn) {
+ // Убираем active со всех кнопок
+ const buttons = btn.parentElement.querySelectorAll('.btn-option');
+ buttons.forEach(b => b.classList.remove('active'));
+
+ // Добавляем active на выбранную
+ btn.classList.add('active');
+
+ // Устанавливаем значение в скрытый input
+ const value = btn.dataset.value;
+ document.getElementById('llm-provider').value = value;
+
+ // Обновляем UI
+ updateLLMProviderUI();
+
+ // Сохраняем настройки
+ updateSettings();
+}
+
+/**
+ * Сбросить настройки
+ */
+function resetSettings() {
+ localStorage.removeItem('pokerSettings');
+ loadSettings();
+ showNotification('Настройки сброшены', 'success');
+}
+
+/**
+ * Переключить звук
+ */
+function toggleSound() {
+ soundEnabled = !soundEnabled;
+ settings.sound = soundEnabled;
+ localStorage.setItem('pokerSettings', JSON.stringify(settings));
+
+ const btn = document.getElementById('sound-toggle');
+ btn.textContent = soundEnabled ? '🔊' : '🔇';
+
+ showNotification(soundEnabled ? 'Звук включён' : 'Звук выключён', 'info');
+}
+
+// =============================================================================
+// ЛИДЕРБОРД
+// =============================================================================
+
+/**
+ * Загрузить лидерборд
+ */
+function loadLeaderboard() {
+ const saved = localStorage.getItem('pokerLeaderboard');
+ if (saved) {
+ leaderboard = JSON.parse(saved);
+ } else {
+ leaderboard = [];
+ }
+}
+
+/**
+ * Обновить лидерборд
+ */
+function updateLeaderboard(result) {
+ const playerName = localStorage.getItem('playerName') || 'Игрок';
+
+ // Находим или создаём запись
+ let entry = leaderboard.find(e => e.name === playerName);
+ if (!entry) {
+ entry = {
+ name: playerName,
+ gamesPlayed: 0,
+ handsWon: 0,
+ totalWinnings: 0,
+ biggestPot: 0
+ };
+ leaderboard.push(entry);
+ }
+
+ entry.gamesPlayed++;
+
+ // Проверяем, выиграл ли текущий игрок
+ const won = result.winners.some(w => w.id === currentPlayerId);
+ if (won) {
+ entry.handsWon++;
+ const winAmount = result.winners.find(w => w.id === currentPlayerId)?.amount || 0;
+ entry.totalWinnings += winAmount;
+ if (result.pot > entry.biggestPot) {
+ entry.biggestPot = result.pot;
+ }
+ }
+
+ // Сортируем и сохраняем
+ leaderboard.sort((a, b) => b.totalWinnings - a.totalWinnings);
+ localStorage.setItem('pokerLeaderboard', JSON.stringify(leaderboard));
+}
+
+/**
+ * Отрисовка лидерборда
+ */
+function renderLeaderboard() {
+ const container = document.getElementById('leaderboard-list');
+
+ if (leaderboard.length === 0) {
+ container.innerHTML = 'Нет записей
';
+ return;
+ }
+
+ container.innerHTML = leaderboard.slice(0, 10).map((entry, index) => {
+ const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : '';
+
+ return `
+
+
${index + 1}
+
+
${entry.name}
+
+ Игр: ${entry.gamesPlayed} | Побед: ${entry.handsWon} | Макс. банк: ${entry.biggestPot}
+
+
+
${entry.totalWinnings}
+
+ `;
+ }).join('');
+}
+
+/**
+ * Переключить вкладку лидерборда
+ */
+function switchLeaderboardTab(tab) {
+ const parent = document.querySelector('.leaderboard-tabs');
+ parent.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ event.target.classList.add('active');
+
+ if (tab === 'global') {
+ document.getElementById('leaderboard-list').innerHTML =
+ 'Глобальный лидерборд недоступен (требуется backend)
';
+ } else {
+ renderLeaderboard();
+ }
+}
+
+/**
+ * Очистить лидерборд
+ */
+function clearLeaderboard() {
+ if (confirm('Вы уверены, что хотите очистить таблицу лидеров?')) {
+ leaderboard = [];
+ localStorage.removeItem('pokerLeaderboard');
+ renderLeaderboard();
+ showNotification('Таблица лидеров очищена', 'success');
+ }
+}
+
+// =============================================================================
+// УТИЛИТЫ
+// =============================================================================
+
+/**
+ * Выйти из игры
+ */
+function leaveGame() {
+ if (confirm('Вы уверены, что хотите выйти?')) {
+ if (isMultiplayer) {
+ leaveRoom();
+ }
+ game = null;
+ showScreen('main-menu');
+ }
+}
+
+/**
+ * Показать уведомление
+ */
+function showNotification(message, type = 'info') {
+ const container = document.getElementById('notifications');
+
+ const notification = document.createElement('div');
+ notification.className = `notification ${type}`;
+ notification.textContent = message;
+
+ container.appendChild(notification);
+
+ setTimeout(() => {
+ notification.remove();
+ }, 3000);
+}
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..52490c3
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,1563 @@
+/**
+ * =============================================================================
+ * Texas Hold'em - Стили 2025/2026
+ * Современный минималистичный UI с glassmorphism и анимациями
+ * =============================================================================
+ */
+
+/* CSS Variables */
+:root {
+ /* Цвета */
+ --bg-primary: #0a0a0f;
+ --bg-secondary: #12121a;
+ --bg-tertiary: #1a1a25;
+
+ --glass-bg: rgba(255, 255, 255, 0.05);
+ --glass-border: rgba(255, 255, 255, 0.1);
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+
+ --text-primary: #ffffff;
+ --text-secondary: rgba(255, 255, 255, 0.7);
+ --text-muted: rgba(255, 255, 255, 0.4);
+
+ --accent-primary: #6366f1;
+ --accent-secondary: #8b5cf6;
+ --accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+
+ --success: #10b981;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --info: #3b82f6;
+
+ /* Карточные масти */
+ --suit-hearts: #ef4444;
+ --suit-diamonds: #ef4444;
+ --suit-clubs: #1f2937;
+ --suit-spades: #1f2937;
+
+ /* Стол */
+ --table-felt: #1a5f3c;
+ --table-border: #0f3d26;
+ --table-shadow: 0 0 60px rgba(16, 185, 129, 0.2);
+
+ /* Размеры */
+ --border-radius: 16px;
+ --border-radius-sm: 8px;
+ --border-radius-lg: 24px;
+
+ /* Анимации */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.3s ease;
+ --transition-slow: 0.5s ease;
+}
+
+/* Reset & Base */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Animated Background */
+body::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
+ radial-gradient(ellipse at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%),
+ radial-gradient(ellipse at 50% 50%, rgba(16, 185, 129, 0.05) 0%, transparent 70%);
+ pointer-events: none;
+ z-index: -1;
+}
+
+/* =============================================================================
+ SCREENS
+ ============================================================================= */
+
+.screen {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ animation: fadeIn 0.3s ease;
+}
+
+.screen.active {
+ display: flex;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* =============================================================================
+ GLASS MORPHISM COMPONENTS
+ ============================================================================= */
+
+.glass-container {
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--glass-shadow);
+ padding: 32px;
+}
+
+.glass-card {
+ background: var(--glass-bg);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--border-radius);
+ padding: 16px;
+}
+
+/* =============================================================================
+ MENU STYLES
+ ============================================================================= */
+
+.menu-container {
+ width: 100%;
+ max-width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.logo {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+.logo-icon {
+ font-size: 64px;
+ display: block;
+ margin-bottom: 16px;
+ animation: float 3s ease-in-out infinite;
+}
+
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-10px); }
+}
+
+.logo h1 {
+ font-size: 32px;
+ font-weight: 700;
+ background: var(--accent-gradient);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ color: var(--text-secondary);
+ font-size: 14px;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+.menu-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 32px;
+}
+
+.settings-row {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+}
+
+/* =============================================================================
+ BUTTONS
+ ============================================================================= */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 24px;
+ font-size: 14px;
+ font-weight: 500;
+ border: none;
+ border-radius: var(--border-radius-sm);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ font-family: inherit;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background: var(--accent-gradient);
+ color: white;
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
+}
+
+.btn-primary:hover {
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
+ transform: translateY(-2px);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--glass-border);
+}
+
+.btn-secondary:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--text-primary);
+ border: 1px solid var(--glass-border);
+}
+
+.btn-outline:hover {
+ background: var(--glass-bg);
+}
+
+.btn-large {
+ padding: 16px 32px;
+ font-size: 16px;
+ border-radius: var(--border-radius);
+}
+
+.btn-small {
+ padding: 8px 16px;
+ font-size: 12px;
+}
+
+.btn-icon-only {
+ width: 48px;
+ height: 48px;
+ padding: 0;
+ font-size: 20px;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: 50%;
+}
+
+.btn-icon {
+ font-size: 20px;
+}
+
+.btn-back {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 8px;
+ transition: color var(--transition-fast);
+}
+
+.btn-back:hover {
+ color: var(--text-primary);
+}
+
+/* Action Buttons */
+.btn-fold {
+ background: var(--danger);
+ color: white;
+}
+
+.btn-check {
+ background: var(--info);
+ color: white;
+}
+
+.btn-call {
+ background: var(--success);
+ color: white;
+}
+
+.btn-bet, .btn-raise {
+ background: var(--warning);
+ color: white;
+}
+
+.btn-allin {
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ color: white;
+ font-weight: 700;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none !important;
+}
+
+/* Option Buttons */
+.btn-option {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border: 1px solid transparent;
+ flex: 1;
+}
+
+.btn-option:hover {
+ background: rgba(99, 102, 241, 0.1);
+ color: var(--text-primary);
+}
+
+.btn-option.active {
+ background: var(--accent-gradient);
+ color: white;
+ border-color: transparent;
+}
+
+.btn-group {
+ display: flex;
+ gap: 8px;
+}
+
+.difficulty-group {
+ flex-direction: column;
+}
+
+.difficulty-group .btn-option {
+ flex-direction: column;
+ padding: 16px;
+ gap: 8px;
+}
+
+.diff-icon {
+ font-size: 24px;
+}
+
+.diff-name {
+ font-size: 12px;
+}
+
+/* =============================================================================
+ FORMS
+ ============================================================================= */
+
+.form-group {
+ margin-bottom: 24px;
+}
+
+.form-group label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 8px;
+}
+
+.input {
+ width: 100%;
+ padding: 14px 16px;
+ font-size: 14px;
+ font-family: inherit;
+ color: var(--text-primary);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--border-radius-sm);
+ outline: none;
+ transition: all var(--transition-fast);
+}
+
+.input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
+}
+
+.input::placeholder {
+ color: var(--text-muted);
+}
+
+/* =============================================================================
+ TABS
+ ============================================================================= */
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ background: var(--bg-tertiary);
+ padding: 4px;
+ border-radius: var(--border-radius-sm);
+ margin-bottom: 24px;
+}
+
+.tab {
+ flex: 1;
+ padding: 12px;
+ font-size: 14px;
+ font-weight: 500;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: var(--border-radius-sm);
+ transition: all var(--transition-fast);
+}
+
+.tab:hover {
+ color: var(--text-primary);
+}
+
+.tab.active {
+ background: var(--accent-gradient);
+ color: white;
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* =============================================================================
+ ROOM LIST
+ ============================================================================= */
+
+.room-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin-bottom: 16px;
+}
+
+.room-list-loading {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 40px;
+}
+
+.room-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+ background: var(--bg-tertiary);
+ border-radius: var(--border-radius-sm);
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.room-item:hover {
+ background: rgba(99, 102, 241, 0.1);
+}
+
+.room-info h4 {
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 4px;
+}
+
+.room-info span {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.room-players {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.room-status {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.room-status.playing {
+ background: var(--warning);
+}
+
+/* =============================================================================
+ LOBBY
+ ============================================================================= */
+
+.lobby-container {
+ width: 100%;
+ max-width: 600px;
+ position: relative;
+}
+
+.lobby-players {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.lobby-player {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 16px;
+ background: var(--bg-tertiary);
+ border-radius: var(--border-radius);
+}
+
+.lobby-player-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: var(--accent-gradient);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ margin-bottom: 8px;
+}
+
+.lobby-player-name {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.lobby-player.host .lobby-player-avatar {
+ box-shadow: 0 0 0 3px var(--warning);
+}
+
+.lobby-info {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ margin-bottom: 24px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.lobby-chat {
+ margin-top: 24px;
+ border-top: 1px solid var(--glass-border);
+ padding-top: 16px;
+}
+
+/* =============================================================================
+ CHAT
+ ============================================================================= */
+
+.chat-messages {
+ height: 150px;
+ overflow-y: auto;
+ margin-bottom: 12px;
+ padding: 8px;
+ background: var(--bg-secondary);
+ border-radius: var(--border-radius-sm);
+}
+
+.chat-message {
+ margin-bottom: 8px;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.chat-message .sender {
+ font-weight: 600;
+ color: var(--accent-primary);
+}
+
+.chat-message .time {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-left: 8px;
+}
+
+.chat-message.system {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* Индикатор "печатает..." */
+.chat-message.typing-indicator {
+ opacity: 0.7;
+}
+
+.typing-dots span {
+ animation: typingBlink 1.4s infinite both;
+}
+
+.typing-dots span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+
+.typing-dots span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+@keyframes typingBlink {
+ 0%, 80%, 100% { opacity: 0; }
+ 40% { opacity: 1; }
+}
+
+.chat-input-row {
+ display: flex;
+ gap: 8px;
+}
+
+.chat-input-row .input {
+ flex: 1;
+}
+
+.chat-input-row .btn {
+ width: 48px;
+}
+
+/* Game Chat */
+.game-chat {
+ position: fixed;
+ bottom: 20px;
+ left: 20px;
+ width: 300px;
+ z-index: 100;
+}
+
+.chat-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--glass-border);
+ margin-bottom: 12px;
+}
+
+.chat-body {
+ display: none;
+}
+
+.game-chat.expanded .chat-body {
+ display: block;
+}
+
+.game-chat.expanded .chat-toggle {
+ transform: rotate(180deg);
+}
+
+/* =============================================================================
+ POKER TABLE
+ ============================================================================= */
+
+.game-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+
+.table-info {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 16px;
+ z-index: 10;
+}
+
+.pot-display {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px 24px;
+}
+
+.pot-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.pot-amount {
+ font-size: 24px;
+ font-weight: 700;
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.phase-display {
+ padding: 12px 24px;
+ font-size: 14px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.poker-table {
+ width: 100%;
+ max-width: 900px;
+ aspect-ratio: 2 / 1;
+ position: relative;
+}
+
+.table-felt {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(145deg, #1e6f4a 0%, var(--table-felt) 50%, #145535 100%);
+ border-radius: 50%;
+ border: 12px solid var(--table-border);
+ box-shadow:
+ var(--table-shadow),
+ inset 0 0 50px rgba(0, 0, 0, 0.3),
+ 0 10px 40px rgba(0, 0, 0, 0.5);
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.table-felt::before {
+ content: '';
+ position: absolute;
+ inset: 30px;
+ border: 2px solid rgba(255, 255, 255, 0.1);
+ border-radius: 50%;
+}
+
+/* Community Cards */
+.community-cards {
+ display: flex;
+ gap: 8px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+/* Player Positions */
+.player-positions {
+ position: absolute;
+ inset: 0;
+}
+
+.player-seat {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform: translate(-50%, -50%);
+}
+
+/* Position 6-max layout */
+.player-seat[data-position="0"] { top: 100%; left: 50%; }
+.player-seat[data-position="1"] { top: 85%; left: 15%; }
+.player-seat[data-position="2"] { top: 30%; left: 5%; }
+.player-seat[data-position="3"] { top: -5%; left: 50%; }
+.player-seat[data-position="4"] { top: 30%; left: 95%; }
+.player-seat[data-position="5"] { top: 85%; left: 85%; }
+
+.player-box {
+ background: var(--glass-bg);
+ backdrop-filter: blur(10px);
+ border: 2px solid var(--glass-border);
+ border-radius: var(--border-radius);
+ padding: 12px 16px;
+ min-width: 120px;
+ text-align: center;
+ transition: all var(--transition-fast);
+}
+
+.player-box.current-turn {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 20px rgba(99, 102, 241, 0.4);
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.4); }
+ 50% { box-shadow: 0 0 30px rgba(99, 102, 241, 0.6); }
+}
+
+.player-box.folded {
+ opacity: 0.4;
+}
+
+.player-box.winner {
+ border-color: var(--success);
+ box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
+}
+
+.player-name {
+ font-size: 12px;
+ font-weight: 600;
+ margin-bottom: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100px;
+}
+
+.player-chips {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--warning);
+}
+
+.player-action {
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+ text-transform: uppercase;
+}
+
+.player-cards-mini {
+ display: flex;
+ gap: 4px;
+ margin-top: 8px;
+ justify-content: center;
+}
+
+.player-bet-display {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.7);
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--warning);
+ white-space: nowrap;
+}
+
+/* Dealer Button */
+.dealer-button {
+ position: absolute;
+ width: 28px;
+ height: 28px;
+ background: white;
+ color: #1f2937;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 700;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+.blind-indicator {
+ position: absolute;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.blind-indicator.sb {
+ background: var(--info);
+ color: white;
+}
+
+.blind-indicator.bb {
+ background: var(--warning);
+ color: white;
+}
+
+/* =============================================================================
+ CARDS
+ ============================================================================= */
+
+.card {
+ width: 60px;
+ height: 84px;
+ border-radius: 8px;
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ position: relative;
+ transition: transform var(--transition-fast);
+ transform-style: preserve-3d;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+}
+
+.card-back {
+ background: linear-gradient(135deg, #1e40af 0%, #3b82f6 50%, #1e40af 100%);
+ background-size: 20px 20px;
+ position: relative;
+}
+
+.card-back::after {
+ content: '🃏';
+ position: absolute;
+ font-size: 24px;
+}
+
+.card-small {
+ width: 40px;
+ height: 56px;
+ font-size: 10px;
+}
+
+.card-small .card-rank {
+ font-size: 14px;
+}
+
+.card-rank {
+ font-size: 24px;
+ line-height: 1;
+}
+
+.card-suit {
+ font-size: 20px;
+ line-height: 1;
+}
+
+.card.hearts, .card.diamonds {
+ color: var(--suit-hearts);
+}
+
+.card.clubs, .card.spades {
+ color: var(--suit-clubs);
+}
+
+/* Card Animation */
+.card.dealing {
+ animation: dealCard 0.5s ease-out;
+}
+
+@keyframes dealCard {
+ from {
+ transform: translateY(-100px) rotateY(180deg) scale(0.5);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0) rotateY(0) scale(1);
+ opacity: 1;
+ }
+}
+
+.card.flipping {
+ animation: flipCard 0.6s ease-in-out;
+}
+
+@keyframes flipCard {
+ 0% { transform: rotateY(0); }
+ 50% { transform: rotateY(90deg); }
+ 100% { transform: rotateY(0); }
+}
+
+/* Player Hand Container */
+.player-hand-container {
+ position: absolute;
+ bottom: 140px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.player-cards {
+ display: flex;
+ gap: 8px;
+}
+
+.player-cards .card {
+ width: 70px;
+ height: 98px;
+}
+
+.player-cards .card:hover {
+ transform: translateY(-10px);
+}
+
+.hand-strength {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+/* =============================================================================
+ ACTION PANEL
+ ============================================================================= */
+
+.action-panel {
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 16px 24px;
+ width: 100%;
+ max-width: 600px;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.action-buttons .btn {
+ flex: 1;
+ min-width: 80px;
+ max-width: 100px;
+}
+
+.bet-slider-container {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--glass-border);
+}
+
+.bet-slider {
+ width: 100%;
+ height: 8px;
+ border-radius: 4px;
+ background: var(--bg-tertiary);
+ appearance: none;
+ outline: none;
+ margin-bottom: 12px;
+}
+
+.bet-slider::-webkit-slider-thumb {
+ appearance: none;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--accent-gradient);
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
+}
+
+.bet-slider::-moz-range-thumb {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--accent-gradient);
+ cursor: pointer;
+ border: none;
+}
+
+.bet-value-display {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 12px;
+}
+
+.bet-input {
+ width: 120px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ background: var(--bg-tertiary);
+ border: 2px solid var(--accent-primary);
+ border-radius: var(--border-radius-sm);
+ color: var(--text-primary);
+ padding: 8px;
+}
+
+.bet-presets {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ margin-bottom: 12px;
+}
+
+.bet-confirm {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+}
+
+/* =============================================================================
+ GAME CONTROLS
+ ============================================================================= */
+
+.game-controls {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ display: flex;
+ gap: 8px;
+ z-index: 100;
+}
+
+.new-hand-btn {
+ position: absolute;
+ bottom: 200px;
+ left: 50%;
+ transform: translateX(-50%);
+ animation: bounce 1s infinite;
+}
+
+@keyframes bounce {
+ 0%, 100% { transform: translateX(-50%) translateY(0); }
+ 50% { transform: translateX(-50%) translateY(-10px); }
+}
+
+/* =============================================================================
+ LEADERBOARD
+ ============================================================================= */
+
+.leaderboard-tabs {
+ display: flex;
+ gap: 4px;
+ background: var(--bg-tertiary);
+ padding: 4px;
+ border-radius: var(--border-radius-sm);
+ margin-bottom: 24px;
+}
+
+.leaderboard-list {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.leaderboard-item {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 12px;
+ background: var(--bg-tertiary);
+ border-radius: var(--border-radius-sm);
+ margin-bottom: 8px;
+}
+
+.leaderboard-rank {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ border-radius: 50%;
+ background: var(--bg-secondary);
+}
+
+.leaderboard-rank.gold {
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
+ color: #1f2937;
+}
+
+.leaderboard-rank.silver {
+ background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
+ color: white;
+}
+
+.leaderboard-rank.bronze {
+ background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
+ color: white;
+}
+
+.leaderboard-info {
+ flex: 1;
+}
+
+.leaderboard-name {
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+
+.leaderboard-stats {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.leaderboard-score {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--warning);
+}
+
+/* =============================================================================
+ SETTINGS
+ ============================================================================= */
+
+.settings-list {
+ margin-bottom: 24px;
+}
+
+.setting-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 0;
+ border-bottom: 1px solid var(--glass-border);
+}
+
+.switch {
+ position: relative;
+ width: 48px;
+ height: 24px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.switch .slider {
+ position: absolute;
+ inset: 0;
+ background: var(--bg-tertiary);
+ border-radius: 24px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.switch .slider::before {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ left: 2px;
+ top: 2px;
+ background: white;
+ border-radius: 50%;
+ transition: all var(--transition-fast);
+}
+
+.switch input:checked + .slider {
+ background: var(--accent-gradient);
+}
+
+.switch input:checked + .slider::before {
+ transform: translateX(24px);
+}
+
+/* =============================================================================
+ MODAL
+ ============================================================================= */
+
+.modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.8);
+ z-index: 1000;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+}
+
+.modal.active {
+ display: flex;
+}
+
+.modal-content {
+ max-width: 400px;
+ width: 100%;
+ text-align: center;
+}
+
+.modal-content h3 {
+ font-size: 24px;
+ margin-bottom: 16px;
+ background: var(--accent-gradient);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.result-details {
+ margin-bottom: 24px;
+}
+
+.result-winner {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.result-hand {
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+}
+
+.result-amount {
+ font-size: 32px;
+ font-weight: 700;
+ color: var(--warning);
+}
+
+/* =============================================================================
+ NOTIFICATIONS
+ ============================================================================= */
+
+.notifications {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: 300px;
+}
+
+.notification {
+ padding: 12px 16px;
+ background: var(--glass-bg);
+ backdrop-filter: blur(10px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--border-radius-sm);
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.notification.success {
+ border-color: var(--success);
+}
+
+.notification.error {
+ border-color: var(--danger);
+}
+
+.notification.info {
+ border-color: var(--info);
+}
+
+/* =============================================================================
+ RESPONSIVE
+ ============================================================================= */
+
+@media (max-width: 768px) {
+ .poker-table {
+ max-width: 100%;
+ }
+
+ .card {
+ width: 45px;
+ height: 63px;
+ }
+
+ .card-rank {
+ font-size: 16px;
+ }
+
+ .card-suit {
+ font-size: 14px;
+ }
+
+ .player-cards .card {
+ width: 55px;
+ height: 77px;
+ }
+
+ .player-box {
+ min-width: 80px;
+ padding: 8px 12px;
+ }
+
+ .player-name {
+ font-size: 10px;
+ max-width: 70px;
+ }
+
+ .player-chips {
+ font-size: 12px;
+ }
+
+ .action-panel {
+ padding: 12px 16px;
+ }
+
+ .action-buttons .btn {
+ padding: 10px 12px;
+ font-size: 12px;
+ min-width: 60px;
+ }
+
+ .game-chat {
+ width: 250px;
+ left: 10px;
+ bottom: 10px;
+ }
+
+ .table-info {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .pot-amount {
+ font-size: 18px;
+ }
+
+ .player-hand-container {
+ bottom: 100px;
+ }
+
+ .glass-container {
+ padding: 20px;
+ }
+
+ .menu-container {
+ max-width: 100%;
+ }
+
+ .logo-icon {
+ font-size: 48px;
+ }
+
+ .logo h1 {
+ font-size: 24px;
+ }
+}
+
+@media (max-width: 480px) {
+ .poker-table {
+ aspect-ratio: 1.5 / 1;
+ }
+
+ .card {
+ width: 35px;
+ height: 49px;
+ }
+
+ .card-rank {
+ font-size: 12px;
+ }
+
+ .card-suit {
+ font-size: 10px;
+ }
+
+ .player-cards .card {
+ width: 45px;
+ height: 63px;
+ }
+
+ .action-buttons {
+ gap: 4px;
+ }
+
+ .action-buttons .btn {
+ padding: 8px 10px;
+ font-size: 10px;
+ min-width: 50px;
+ }
+
+ .btn-allin span {
+ display: none;
+ }
+
+ .bet-slider-container {
+ padding: 12px;
+ }
+
+ .bet-input {
+ font-size: 18px;
+ }
+}
+
+/* =============================================================================
+ SCROLLBAR
+ ============================================================================= */
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-tertiary);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--glass-border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-primary);
+}
+
+/* =============================================================================
+ CHIP ANIMATION
+ ============================================================================= */
+
+.chip {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ border: 2px dashed white;
+ display: inline-block;
+ margin: 0 2px;
+ animation: chipBounce 0.3s ease;
+}
+
+@keyframes chipBounce {
+ 0% { transform: translateY(-20px); opacity: 0; }
+ 100% { transform: translateY(0); opacity: 1; }
+}
+
+/* Win Animation */
+.win-animation {
+ animation: winPulse 0.5s ease 3;
+}
+
+@keyframes winPulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..01aef5e
--- /dev/null
+++ b/server.js
@@ -0,0 +1,1224 @@
+/**
+ * =============================================================================
+ * Texas Hold'em WebSocket Server
+ * Node.js + ws для мультиплеера 2-6 игроков
+ * =============================================================================
+ */
+
+const express = require('express');
+const { WebSocketServer } = require('ws');
+const http = require('http');
+const path = require('path');
+
+const app = express();
+const server = http.createServer(app);
+const wss = new WebSocketServer({ server });
+
+// Статические файлы
+app.use(express.static(path.join(__dirname, 'public')));
+
+// =============================================================================
+// ИГРОВЫЕ СТРУКТУРЫ ДАННЫХ
+// =============================================================================
+
+/**
+ * Комнаты для игры
+ * @type {Map}
+ */
+const rooms = new Map();
+
+/**
+ * Связь WebSocket с игроком
+ * @type {Map}
+ */
+const connections = new Map();
+
+// Масти и номиналы карт
+const SUITS = ['hearts', 'diamonds', 'clubs', 'spades'];
+const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+
+// =============================================================================
+// КЛАССЫ
+// =============================================================================
+
+/**
+ * Класс карты
+ */
+class Card {
+ constructor(suit, rank) {
+ this.suit = suit;
+ this.rank = rank;
+ this.value = RANKS.indexOf(rank) + 2; // 2-14 (A=14)
+ }
+
+ toString() {
+ return `${this.rank}${this.suit[0].toUpperCase()}`;
+ }
+}
+
+/**
+ * Класс колоды
+ */
+class Deck {
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this.cards = [];
+ for (const suit of SUITS) {
+ for (const rank of RANKS) {
+ this.cards.push(new Card(suit, rank));
+ }
+ }
+ this.shuffle();
+ }
+
+ shuffle() {
+ // Fisher-Yates shuffle
+ for (let i = this.cards.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [this.cards[i], this.cards[j]] = [this.cards[j], this.cards[i]];
+ }
+ }
+
+ deal(count = 1) {
+ return this.cards.splice(0, count);
+ }
+}
+
+/**
+ * Класс игрока
+ */
+class Player {
+ constructor(id, name, chips = 1000) {
+ this.id = id;
+ this.name = name;
+ this.chips = chips;
+ this.hand = [];
+ this.bet = 0;
+ this.totalBet = 0; // Общая ставка за раунд
+ this.folded = false;
+ this.allIn = false;
+ this.isDealer = false;
+ this.isSmallBlind = false;
+ this.isBigBlind = false;
+ this.isConnected = true;
+ this.lastAction = null;
+ }
+
+ reset() {
+ this.hand = [];
+ this.bet = 0;
+ this.totalBet = 0;
+ this.folded = false;
+ this.allIn = false;
+ this.isDealer = false;
+ this.isSmallBlind = false;
+ this.isBigBlind = false;
+ this.lastAction = null;
+ }
+
+ toPublicJSON() {
+ return {
+ id: this.id,
+ name: this.name,
+ chips: this.chips,
+ bet: this.bet,
+ totalBet: this.totalBet,
+ folded: this.folded,
+ allIn: this.allIn,
+ isDealer: this.isDealer,
+ isSmallBlind: this.isSmallBlind,
+ isBigBlind: this.isBigBlind,
+ isConnected: this.isConnected,
+ lastAction: this.lastAction,
+ hasCards: this.hand.length > 0
+ };
+ }
+}
+
+/**
+ * Класс комнаты/стола
+ */
+class Room {
+ constructor(id, name, smallBlind = 5, bigBlind = 10, maxPlayers = 6) {
+ this.id = id;
+ this.name = name;
+ this.smallBlind = smallBlind;
+ this.bigBlind = bigBlind;
+ this.maxPlayers = maxPlayers;
+ this.players = [];
+ this.deck = new Deck();
+ this.communityCards = [];
+ this.pot = 0;
+ this.sidePots = [];
+ this.currentBet = 0;
+ this.dealerIndex = 0;
+ this.currentPlayerIndex = 0;
+ this.gamePhase = 'waiting'; // waiting, preflop, flop, turn, river, showdown
+ this.isGameStarted = false;
+ this.minRaise = bigBlind;
+ this.lastRaiseAmount = bigBlind;
+ this.messages = [];
+ this.roundStarted = false;
+ }
+
+ /**
+ * Добавить игрока в комнату
+ */
+ addPlayer(player) {
+ if (this.players.length >= this.maxPlayers) {
+ return false;
+ }
+ this.players.push(player);
+ return true;
+ }
+
+ /**
+ * Удалить игрока из комнаты
+ */
+ removePlayer(playerId) {
+ const index = this.players.findIndex(p => p.id === playerId);
+ if (index !== -1) {
+ this.players.splice(index, 1);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получить активных игроков (не фолднувших)
+ */
+ getActivePlayers() {
+ return this.players.filter(p => !p.folded && p.isConnected);
+ }
+
+ /**
+ * Получить игроков с фишками
+ */
+ getPlayersWithChips() {
+ return this.players.filter(p => p.chips > 0 && p.isConnected);
+ }
+
+ /**
+ * Начать новую раздачу
+ */
+ startNewHand() {
+ if (this.getPlayersWithChips().length < 2) {
+ return false;
+ }
+
+ // Сброс состояния
+ this.deck.reset();
+ this.communityCards = [];
+ this.pot = 0;
+ this.sidePots = [];
+ this.currentBet = 0;
+ this.minRaise = this.bigBlind;
+ this.lastRaiseAmount = this.bigBlind;
+ this.roundStarted = true;
+
+ // Сброс игроков
+ for (const player of this.players) {
+ player.reset();
+ }
+
+ // Определение позиций
+ this.moveDealer();
+ this.assignBlinds();
+
+ // Раздача карт
+ this.dealHoleCards();
+
+ // Начало игры
+ this.gamePhase = 'preflop';
+ this.isGameStarted = true;
+
+ // Ставки блайндов
+ this.postBlinds();
+
+ // Первый ход - после большого блайнда (UTG)
+ this.setFirstPlayer();
+
+ return true;
+ }
+
+ /**
+ * Передвинуть дилера
+ */
+ moveDealer() {
+ const playersWithChips = this.getPlayersWithChips();
+ if (playersWithChips.length < 2) return;
+
+ // Находим следующего дилера
+ do {
+ this.dealerIndex = (this.dealerIndex + 1) % this.players.length;
+ } while (this.players[this.dealerIndex].chips <= 0 || !this.players[this.dealerIndex].isConnected);
+
+ this.players[this.dealerIndex].isDealer = true;
+ }
+
+ /**
+ * Назначить блайнды
+ */
+ assignBlinds() {
+ const playersWithChips = this.getPlayersWithChips();
+
+ if (playersWithChips.length === 2) {
+ // Heads-up: дилер = SB
+ this.players[this.dealerIndex].isSmallBlind = true;
+ let bbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ this.players[bbIndex].isBigBlind = true;
+ } else {
+ // 3+ игроков
+ let sbIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ this.players[sbIndex].isSmallBlind = true;
+
+ let bbIndex = this.getNextActivePlayerIndex(sbIndex);
+ this.players[bbIndex].isBigBlind = true;
+ }
+ }
+
+ /**
+ * Поставить блайнды
+ */
+ postBlinds() {
+ const sbPlayer = this.players.find(p => p.isSmallBlind);
+ const bbPlayer = this.players.find(p => p.isBigBlind);
+
+ if (sbPlayer) {
+ const sbAmount = Math.min(sbPlayer.chips, this.smallBlind);
+ sbPlayer.chips -= sbAmount;
+ sbPlayer.bet = sbAmount;
+ sbPlayer.totalBet = sbAmount;
+ this.pot += sbAmount;
+ }
+
+ if (bbPlayer) {
+ const bbAmount = Math.min(bbPlayer.chips, this.bigBlind);
+ bbPlayer.chips -= bbAmount;
+ bbPlayer.bet = bbAmount;
+ bbPlayer.totalBet = bbAmount;
+ this.pot += bbAmount;
+ this.currentBet = bbAmount;
+ }
+ }
+
+ /**
+ * Раздать карманные карты
+ */
+ dealHoleCards() {
+ for (const player of this.getPlayersWithChips()) {
+ player.hand = this.deck.deal(2);
+ }
+ }
+
+ /**
+ * Установить первого игрока для хода
+ */
+ setFirstPlayer() {
+ if (this.gamePhase === 'preflop') {
+ // UTG - после BB
+ const bbPlayer = this.players.find(p => p.isBigBlind);
+ const bbIndex = this.players.indexOf(bbPlayer);
+ this.currentPlayerIndex = this.getNextActivePlayerIndex(bbIndex);
+ } else {
+ // После флопа - первый активный после дилера
+ this.currentPlayerIndex = this.getNextActivePlayerIndex(this.dealerIndex);
+ }
+ }
+
+ /**
+ * Получить индекс следующего активного игрока
+ */
+ getNextActivePlayerIndex(fromIndex) {
+ let index = (fromIndex + 1) % this.players.length;
+ let attempts = 0;
+
+ while (attempts < this.players.length) {
+ const player = this.players[index];
+ if (!player.folded && player.chips >= 0 && player.isConnected && !player.allIn) {
+ return index;
+ }
+ index = (index + 1) % this.players.length;
+ attempts++;
+ }
+
+ return -1;
+ }
+
+ /**
+ * Обработать действие игрока
+ */
+ processAction(playerId, action, amount = 0) {
+ const player = this.players.find(p => p.id === playerId);
+ if (!player) return { success: false, error: 'Игрок не найден' };
+
+ const currentPlayer = this.players[this.currentPlayerIndex];
+ if (currentPlayer.id !== playerId) {
+ return { success: false, error: 'Сейчас не ваш ход' };
+ }
+
+ const result = { success: true };
+
+ switch (action) {
+ case 'fold':
+ player.folded = true;
+ player.lastAction = 'fold';
+ break;
+
+ case 'check':
+ if (player.bet < this.currentBet) {
+ return { success: false, error: 'Нельзя чек, нужно уравнять ставку' };
+ }
+ player.lastAction = 'check';
+ break;
+
+ case 'call':
+ const callAmount = Math.min(this.currentBet - player.bet, player.chips);
+ player.chips -= callAmount;
+ player.bet += callAmount;
+ player.totalBet += callAmount;
+ this.pot += callAmount;
+ player.lastAction = `call ${callAmount}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'all-in';
+ }
+ break;
+
+ case 'bet':
+ if (this.currentBet > 0) {
+ return { success: false, error: 'Уже есть ставка, используйте raise' };
+ }
+ if (amount < this.bigBlind) {
+ return { success: false, error: `Минимальная ставка: ${this.bigBlind}` };
+ }
+ if (amount > player.chips) {
+ amount = player.chips;
+ }
+
+ player.chips -= amount;
+ player.bet = amount;
+ player.totalBet += amount;
+ this.pot += amount;
+ this.currentBet = amount;
+ this.lastRaiseAmount = amount;
+ this.minRaise = amount;
+ player.lastAction = `bet ${amount}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'all-in';
+ }
+ break;
+
+ case 'raise':
+ const toCall = this.currentBet - player.bet;
+ const minRaiseTotal = this.currentBet + this.lastRaiseAmount;
+
+ if (amount < minRaiseTotal && amount < player.chips + player.bet) {
+ return { success: false, error: `Минимальный рейз до: ${minRaiseTotal}` };
+ }
+
+ const raiseTotal = Math.min(amount, player.chips + player.bet);
+ const raiseAmount = raiseTotal - player.bet;
+
+ player.chips -= raiseAmount;
+ this.pot += raiseAmount;
+ this.lastRaiseAmount = raiseTotal - this.currentBet;
+ this.currentBet = raiseTotal;
+ player.bet = raiseTotal;
+ player.totalBet += raiseAmount;
+ player.lastAction = `raise to ${raiseTotal}`;
+
+ if (player.chips === 0) {
+ player.allIn = true;
+ player.lastAction = 'all-in';
+ }
+ break;
+
+ case 'allin':
+ const allInAmount = player.chips;
+ this.pot += allInAmount;
+ player.bet += allInAmount;
+ player.totalBet += allInAmount;
+
+ if (player.bet > this.currentBet) {
+ this.lastRaiseAmount = player.bet - this.currentBet;
+ this.currentBet = player.bet;
+ }
+
+ player.chips = 0;
+ player.allIn = true;
+ player.lastAction = `all-in ${player.bet}`;
+ break;
+
+ default:
+ return { success: false, error: 'Неизвестное действие' };
+ }
+
+ // Проверка завершения раунда ставок
+ if (this.isBettingRoundComplete()) {
+ this.nextPhase();
+ } else {
+ this.moveToNextPlayer();
+ }
+
+ return result;
+ }
+
+ /**
+ * Перейти к следующему игроку
+ */
+ moveToNextPlayer() {
+ const nextIndex = this.getNextActivePlayerIndex(this.currentPlayerIndex);
+ if (nextIndex !== -1) {
+ this.currentPlayerIndex = nextIndex;
+ }
+ }
+
+ /**
+ * Проверить завершение раунда ставок
+ */
+ isBettingRoundComplete() {
+ const activePlayers = this.getActivePlayers().filter(p => !p.allIn);
+
+ // Все фолднули кроме одного
+ if (this.getActivePlayers().length === 1) {
+ return true;
+ }
+
+ // Все активные (не all-in) игроки уравняли ставку
+ if (activePlayers.length === 0) {
+ return true;
+ }
+
+ // Все поставили одинаково и сделали действие
+ const allEqual = activePlayers.every(p => p.bet === this.currentBet);
+ const allActed = activePlayers.every(p => p.lastAction !== null);
+
+ return allEqual && allActed;
+ }
+
+ /**
+ * Переход к следующей фазе
+ */
+ nextPhase() {
+ // Сброс ставок для новой улицы
+ for (const player of this.players) {
+ player.bet = 0;
+ player.lastAction = null;
+ }
+ this.currentBet = 0;
+
+ const activePlayers = this.getActivePlayers();
+
+ // Один игрок остался - он победитель
+ if (activePlayers.length === 1) {
+ this.endHand([activePlayers[0]]);
+ return;
+ }
+
+ switch (this.gamePhase) {
+ case 'preflop':
+ this.gamePhase = 'flop';
+ this.communityCards = this.deck.deal(3);
+ break;
+ case 'flop':
+ this.gamePhase = 'turn';
+ this.communityCards.push(...this.deck.deal(1));
+ break;
+ case 'turn':
+ this.gamePhase = 'river';
+ this.communityCards.push(...this.deck.deal(1));
+ break;
+ case 'river':
+ this.gamePhase = 'showdown';
+ this.determineWinner();
+ return;
+ }
+
+ // Установка первого игрока для новой улицы
+ this.setFirstPlayer();
+
+ // Если все в all-in, автоматически переходим к следующей фазе
+ if (this.getActivePlayers().filter(p => !p.allIn).length <= 1) {
+ setTimeout(() => this.nextPhase(), 1000);
+ }
+ }
+
+ /**
+ * Определить победителя
+ */
+ determineWinner() {
+ const activePlayers = this.getActivePlayers();
+
+ if (activePlayers.length === 1) {
+ this.endHand([activePlayers[0]]);
+ return;
+ }
+
+ // Оценка рук
+ const playerHands = activePlayers.map(player => ({
+ player,
+ handResult: evaluateHand([...player.hand, ...this.communityCards])
+ }));
+
+ // Сортировка по силе руки
+ playerHands.sort((a, b) => {
+ if (b.handResult.rank !== a.handResult.rank) {
+ return b.handResult.rank - a.handResult.rank;
+ }
+ // Сравнение кикеров
+ for (let i = 0; i < a.handResult.kickers.length; i++) {
+ if (b.handResult.kickers[i] !== a.handResult.kickers[i]) {
+ return b.handResult.kickers[i] - a.handResult.kickers[i];
+ }
+ }
+ return 0;
+ });
+
+ // Находим победителей (может быть сплит)
+ const winners = [playerHands[0]];
+ for (let i = 1; i < playerHands.length; i++) {
+ const cmp = this.compareHands(playerHands[0].handResult, playerHands[i].handResult);
+ if (cmp === 0) {
+ winners.push(playerHands[i]);
+ } else {
+ break;
+ }
+ }
+
+ // Добавляем информацию о руках
+ for (const w of winners) {
+ w.player.handName = w.handResult.name;
+ }
+
+ this.endHand(winners.map(w => w.player), playerHands);
+ }
+
+ /**
+ * Сравнение двух рук
+ */
+ compareHands(h1, h2) {
+ if (h1.rank !== h2.rank) return h2.rank - h1.rank;
+ for (let i = 0; i < h1.kickers.length; i++) {
+ if (h1.kickers[i] !== h2.kickers[i]) {
+ return h2.kickers[i] - h1.kickers[i];
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Завершить раздачу
+ */
+ endHand(winners, allHands = null) {
+ const winAmount = Math.floor(this.pot / winners.length);
+
+ for (const winner of winners) {
+ winner.chips += winAmount;
+ }
+
+ // Остаток при нечётном сплите
+ const remainder = this.pot % winners.length;
+ if (remainder > 0) {
+ winners[0].chips += remainder;
+ }
+
+ this.gamePhase = 'showdown';
+ this.isGameStarted = false;
+ this.roundStarted = false;
+
+ return {
+ winners: winners.map(w => ({ id: w.id, name: w.name, amount: winAmount })),
+ pot: this.pot,
+ hands: allHands
+ };
+ }
+
+ /**
+ * Получить состояние комнаты для отправки клиентам
+ */
+ getState(forPlayerId = null) {
+ return {
+ id: this.id,
+ name: this.name,
+ smallBlind: this.smallBlind,
+ bigBlind: this.bigBlind,
+ pot: this.pot,
+ communityCards: this.communityCards.map(c => ({ suit: c.suit, rank: c.rank })),
+ currentBet: this.currentBet,
+ gamePhase: this.gamePhase,
+ isGameStarted: this.isGameStarted,
+ currentPlayerIndex: this.currentPlayerIndex,
+ currentPlayerId: this.players[this.currentPlayerIndex]?.id,
+ minRaise: this.minRaise + this.currentBet,
+ players: this.players.map(p => {
+ const publicData = p.toPublicJSON();
+ // Показываем карты только владельцу или при шоудауне
+ if (p.id === forPlayerId || this.gamePhase === 'showdown') {
+ publicData.hand = p.hand.map(c => ({ suit: c.suit, rank: c.rank }));
+ }
+ return publicData;
+ })
+ };
+ }
+}
+
+// =============================================================================
+// ОЦЕНКА РУК ПОКЕРА
+// =============================================================================
+
+/**
+ * Оценить покерную руку из 7 карт
+ */
+function evaluateHand(cards) {
+ const allCombinations = getCombinations(cards, 5);
+ let bestHand = null;
+
+ for (const combo of allCombinations) {
+ const hand = evaluateFiveCards(combo);
+ if (!bestHand || compareHandResults(hand, bestHand) > 0) {
+ bestHand = hand;
+ }
+ }
+
+ return bestHand;
+}
+
+/**
+ * Получить все комбинации из n элементов
+ */
+function getCombinations(arr, n) {
+ if (n === 0) return [[]];
+ if (arr.length === 0) return [];
+
+ const [first, ...rest] = arr;
+ const withFirst = getCombinations(rest, n - 1).map(c => [first, ...c]);
+ const withoutFirst = getCombinations(rest, n);
+
+ return [...withFirst, ...withoutFirst];
+}
+
+/**
+ * Оценить 5 карт
+ */
+function evaluateFiveCards(cards) {
+ const sortedCards = [...cards].sort((a, b) => b.value - a.value);
+ const values = sortedCards.map(c => c.value);
+ const suits = sortedCards.map(c => c.suit);
+
+ const isFlush = suits.every(s => s === suits[0]);
+ const isStraight = checkStraight(values);
+ const isWheel = values.join(',') === '14,5,4,3,2'; // A-2-3-4-5
+
+ const valueCounts = {};
+ for (const v of values) {
+ valueCounts[v] = (valueCounts[v] || 0) + 1;
+ }
+
+ const counts = Object.values(valueCounts).sort((a, b) => b - a);
+ const uniqueValues = Object.keys(valueCounts)
+ .map(Number)
+ .sort((a, b) => {
+ if (valueCounts[b] !== valueCounts[a]) {
+ return valueCounts[b] - valueCounts[a];
+ }
+ return b - a;
+ });
+
+ // Рояль-флеш
+ if (isFlush && isStraight && values[0] === 14) {
+ return { rank: 10, name: 'Роял-флеш', kickers: values };
+ }
+
+ // Стрит-флеш
+ if (isFlush && (isStraight || isWheel)) {
+ return { rank: 9, name: 'Стрит-флеш', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
+ }
+
+ // Каре
+ if (counts[0] === 4) {
+ return { rank: 8, name: 'Каре', kickers: uniqueValues };
+ }
+
+ // Фулл-хаус
+ if (counts[0] === 3 && counts[1] === 2) {
+ return { rank: 7, name: 'Фулл-хаус', kickers: uniqueValues };
+ }
+
+ // Флеш
+ if (isFlush) {
+ return { rank: 6, name: 'Флеш', kickers: values };
+ }
+
+ // Стрит
+ if (isStraight || isWheel) {
+ return { rank: 5, name: 'Стрит', kickers: isWheel ? [5, 4, 3, 2, 1] : values };
+ }
+
+ // Сет
+ if (counts[0] === 3) {
+ return { rank: 4, name: 'Сет', kickers: uniqueValues };
+ }
+
+ // Две пары
+ if (counts[0] === 2 && counts[1] === 2) {
+ return { rank: 3, name: 'Две пары', kickers: uniqueValues };
+ }
+
+ // Пара
+ if (counts[0] === 2) {
+ return { rank: 2, name: 'Пара', kickers: uniqueValues };
+ }
+
+ // Старшая карта
+ return { rank: 1, name: 'Старшая карта', kickers: values };
+}
+
+/**
+ * Проверка на стрит
+ */
+function checkStraight(values) {
+ for (let i = 0; i < values.length - 1; i++) {
+ if (values[i] - values[i + 1] !== 1) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Сравнение результатов рук
+ */
+function compareHandResults(h1, h2) {
+ if (h1.rank !== h2.rank) return h1.rank - h2.rank;
+ for (let i = 0; i < h1.kickers.length; i++) {
+ if (h1.kickers[i] !== h2.kickers[i]) {
+ return h1.kickers[i] - h2.kickers[i];
+ }
+ }
+ return 0;
+}
+
+// =============================================================================
+// WEBSOCKET ОБРАБОТЧИКИ
+// =============================================================================
+
+wss.on('connection', (ws) => {
+ console.log('Новое подключение');
+
+ const connection = {
+ ws,
+ playerId: null,
+ roomId: null,
+ playerName: null
+ };
+ connections.set(ws, connection);
+
+ // Отправляем список комнат
+ sendRoomList(ws);
+
+ ws.on('message', (data) => {
+ try {
+ const message = JSON.parse(data);
+ handleMessage(ws, message);
+ } catch (err) {
+ console.error('Ошибка обработки сообщения:', err);
+ }
+ });
+
+ ws.on('close', () => {
+ handleDisconnect(ws);
+ });
+});
+
+/**
+ * Обработка входящих сообщений
+ */
+function handleMessage(ws, message) {
+ const connection = connections.get(ws);
+
+ switch (message.type) {
+ case 'create_room':
+ handleCreateRoom(ws, message);
+ break;
+
+ case 'join_room':
+ handleJoinRoom(ws, message);
+ break;
+
+ case 'leave_room':
+ handleLeaveRoom(ws);
+ break;
+
+ case 'start_game':
+ handleStartGame(ws);
+ break;
+
+ case 'action':
+ handlePlayerAction(ws, message);
+ break;
+
+ case 'chat':
+ handleChat(ws, message);
+ break;
+
+ case 'get_rooms':
+ sendRoomList(ws);
+ break;
+
+ case 'new_hand':
+ handleNewHand(ws);
+ break;
+ }
+}
+
+/**
+ * Создание комнаты
+ */
+function handleCreateRoom(ws, message) {
+ const { roomName, playerName, smallBlind, bigBlind, maxPlayers } = message;
+ const connection = connections.get(ws);
+
+ const roomId = 'room_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+ const playerId = 'player_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+
+ const room = new Room(roomId, roomName || 'Новая комната', smallBlind || 5, bigBlind || 10, maxPlayers || 6);
+ const player = new Player(playerId, playerName || 'Игрок', 1000);
+
+ room.addPlayer(player);
+ rooms.set(roomId, room);
+
+ connection.playerId = playerId;
+ connection.roomId = roomId;
+ connection.playerName = player.name;
+
+ // Отправляем подтверждение
+ ws.send(JSON.stringify({
+ type: 'room_joined',
+ roomId,
+ playerId,
+ room: room.getState(playerId)
+ }));
+
+ // Обновляем список комнат для всех
+ broadcastRoomList();
+}
+
+/**
+ * Присоединение к комнате
+ */
+function handleJoinRoom(ws, message) {
+ const { roomId, playerName } = message;
+ const connection = connections.get(ws);
+ const room = rooms.get(roomId);
+
+ if (!room) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Комната не найдена' }));
+ return;
+ }
+
+ if (room.players.length >= room.maxPlayers) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Комната полна' }));
+ return;
+ }
+
+ if (room.isGameStarted) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Игра уже началась' }));
+ return;
+ }
+
+ const playerId = 'player_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+ const player = new Player(playerId, playerName || 'Игрок', 1000);
+
+ room.addPlayer(player);
+
+ connection.playerId = playerId;
+ connection.roomId = roomId;
+ connection.playerName = player.name;
+
+ // Отправляем подтверждение
+ ws.send(JSON.stringify({
+ type: 'room_joined',
+ roomId,
+ playerId,
+ room: room.getState(playerId)
+ }));
+
+ // Уведомляем остальных
+ broadcastToRoom(roomId, {
+ type: 'player_joined',
+ player: player.toPublicJSON(),
+ room: room.getState()
+ }, playerId);
+
+ broadcastRoomList();
+}
+
+/**
+ * Выход из комнаты
+ */
+function handleLeaveRoom(ws) {
+ const connection = connections.get(ws);
+ if (!connection.roomId) return;
+
+ const room = rooms.get(connection.roomId);
+ if (room) {
+ room.removePlayer(connection.playerId);
+
+ if (room.players.length === 0) {
+ rooms.delete(connection.roomId);
+ } else {
+ broadcastToRoom(connection.roomId, {
+ type: 'player_left',
+ playerId: connection.playerId,
+ room: room.getState()
+ });
+ }
+ }
+
+ connection.roomId = null;
+ connection.playerId = null;
+
+ broadcastRoomList();
+}
+
+/**
+ * Начать игру
+ */
+function handleStartGame(ws) {
+ const connection = connections.get(ws);
+ const room = rooms.get(connection.roomId);
+
+ if (!room) return;
+
+ if (room.players.length < 2) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Нужно минимум 2 игрока' }));
+ return;
+ }
+
+ const success = room.startNewHand();
+
+ if (success) {
+ // Отправляем состояние каждому игроку (с его картами)
+ for (const player of room.players) {
+ const conn = findConnectionByPlayerId(player.id);
+ if (conn) {
+ conn.ws.send(JSON.stringify({
+ type: 'game_started',
+ room: room.getState(player.id)
+ }));
+ }
+ }
+ }
+}
+
+/**
+ * Новая раздача
+ */
+function handleNewHand(ws) {
+ const connection = connections.get(ws);
+ const room = rooms.get(connection.roomId);
+
+ if (!room || room.isGameStarted) return;
+
+ const success = room.startNewHand();
+
+ if (success) {
+ for (const player of room.players) {
+ const conn = findConnectionByPlayerId(player.id);
+ if (conn) {
+ conn.ws.send(JSON.stringify({
+ type: 'game_started',
+ room: room.getState(player.id)
+ }));
+ }
+ }
+ }
+}
+
+/**
+ * Обработка действия игрока
+ */
+function handlePlayerAction(ws, message) {
+ const connection = connections.get(ws);
+ const room = rooms.get(connection.roomId);
+
+ if (!room) return;
+
+ const result = room.processAction(connection.playerId, message.action, message.amount);
+
+ if (!result.success) {
+ ws.send(JSON.stringify({ type: 'error', message: result.error }));
+ return;
+ }
+
+ // Отправляем обновлённое состояние всем
+ for (const player of room.players) {
+ const conn = findConnectionByPlayerId(player.id);
+ if (conn) {
+ conn.ws.send(JSON.stringify({
+ type: 'game_update',
+ room: room.getState(player.id),
+ lastAction: {
+ playerId: connection.playerId,
+ action: message.action,
+ amount: message.amount
+ }
+ }));
+ }
+ }
+}
+
+/**
+ * Обработка чата
+ */
+function handleChat(ws, message) {
+ const connection = connections.get(ws);
+ const room = rooms.get(connection.roomId);
+
+ if (!room) return;
+
+ const chatMessage = {
+ type: 'chat',
+ playerId: connection.playerId,
+ playerName: connection.playerName,
+ message: message.message,
+ timestamp: Date.now()
+ };
+
+ room.messages.push(chatMessage);
+
+ broadcastToRoom(connection.roomId, chatMessage);
+}
+
+/**
+ * Обработка отключения
+ */
+function handleDisconnect(ws) {
+ const connection = connections.get(ws);
+
+ if (connection && connection.roomId) {
+ const room = rooms.get(connection.roomId);
+ if (room) {
+ const player = room.players.find(p => p.id === connection.playerId);
+ if (player) {
+ player.isConnected = false;
+
+ // Если игра идёт и это текущий игрок - автофолд
+ if (room.isGameStarted && room.players[room.currentPlayerIndex]?.id === connection.playerId) {
+ room.processAction(connection.playerId, 'fold');
+ }
+
+ broadcastToRoom(connection.roomId, {
+ type: 'player_disconnected',
+ playerId: connection.playerId,
+ room: room.getState()
+ });
+ }
+
+ // Удаляем игрока если игра не идёт
+ if (!room.isGameStarted) {
+ room.removePlayer(connection.playerId);
+ if (room.players.length === 0) {
+ rooms.delete(connection.roomId);
+ }
+ }
+ }
+ }
+
+ connections.delete(ws);
+ broadcastRoomList();
+}
+
+// =============================================================================
+// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
+// =============================================================================
+
+/**
+ * Найти соединение по ID игрока
+ */
+function findConnectionByPlayerId(playerId) {
+ for (const [ws, conn] of connections) {
+ if (conn.playerId === playerId) {
+ return conn;
+ }
+ }
+ return null;
+}
+
+/**
+ * Отправить сообщение всем в комнате
+ */
+function broadcastToRoom(roomId, message, excludePlayerId = null) {
+ for (const [ws, conn] of connections) {
+ if (conn.roomId === roomId && conn.playerId !== excludePlayerId) {
+ ws.send(JSON.stringify(message));
+ }
+ }
+}
+
+/**
+ * Отправить список комнат
+ */
+function sendRoomList(ws) {
+ const roomList = Array.from(rooms.values()).map(room => ({
+ id: room.id,
+ name: room.name,
+ players: room.players.length,
+ maxPlayers: room.maxPlayers,
+ smallBlind: room.smallBlind,
+ bigBlind: room.bigBlind,
+ isGameStarted: room.isGameStarted
+ }));
+
+ ws.send(JSON.stringify({
+ type: 'room_list',
+ rooms: roomList
+ }));
+}
+
+/**
+ * Обновить список комнат для всех
+ */
+function broadcastRoomList() {
+ const roomList = Array.from(rooms.values()).map(room => ({
+ id: room.id,
+ name: room.name,
+ players: room.players.length,
+ maxPlayers: room.maxPlayers,
+ smallBlind: room.smallBlind,
+ bigBlind: room.bigBlind,
+ isGameStarted: room.isGameStarted
+ }));
+
+ for (const [ws, conn] of connections) {
+ if (!conn.roomId) {
+ ws.send(JSON.stringify({
+ type: 'room_list',
+ rooms: roomList
+ }));
+ }
+ }
+}
+
+// =============================================================================
+// ЗАПУСК СЕРВЕРА
+// =============================================================================
+
+const PORT = process.env.PORT || 3000;
+server.listen(PORT, () => {
+ console.log(`
+╔════════════════════════════════════════════════════════════════╗
+║ 🃏 Texas Hold'em Poker Server 🃏 ║
+║ ║
+║ Сервер запущен на http://localhost:${PORT} ║
+║ WebSocket: ws://localhost:${PORT} ║
+╚════════════════════════════════════════════════════════════════╝
+ `);
+});