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} ║ +╚════════════════════════════════════════════════════════════════╝ + `); +});