From b2c91062f195da1ac4e4ed5daaf34c1de2f54924 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Thu, 15 Oct 2026 16:51:15 +0900 Subject: [PATCH] Initial commit: private Texas Hold'em ring game site (lobby + table stub) - 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits - Lobby with saved player name and live table list - Table creation (name, starting stack, blinds, blind timer) - Table play screen stub (seats, button/SB/BB, phase, blinds, timer) - Security hardening: strict CSP, origin checks, host pinning, input sanitizing Co-Authored-By: Claude Opus 5.5 --- .claude/launch.json | 11 + .env.example | 6 + .gitattributes | 1 + .gitignore | 3 + README.md | 140 +++++ package-lock.json | 1083 +++++++++++++++++++++++++++++++++++++++ package.json | 18 + public/assets/blinds.js | 57 +++ public/assets/common.js | 99 ++++ public/assets/create.js | 143 ++++++ public/assets/key.js | 82 +++ public/assets/lobby.js | 72 +++ public/assets/style.css | 514 +++++++++++++++++++ public/assets/table.js | 187 +++++++ public/create.html | 60 +++ public/key.html | 33 ++ public/lobby.html | 34 ++ public/table.html | 81 +++ server/auth.js | 214 ++++++++ server/config.js | 67 +++ server/index.js | 274 ++++++++++ server/tables.js | 271 ++++++++++ 22 files changed, 3450 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/assets/blinds.js create mode 100644 public/assets/common.js create mode 100644 public/assets/create.js create mode 100644 public/assets/key.js create mode 100644 public/assets/lobby.js create mode 100644 public/assets/style.css create mode 100644 public/assets/table.js create mode 100644 public/create.html create mode 100644 public/key.html create mode 100644 public/lobby.html create mode 100644 public/table.html create mode 100644 server/auth.js create mode 100644 server/config.js create mode 100644 server/index.js create mode 100644 server/tables.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..3e641c6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "poker", + "runtimeExecutable": "node", + "runtimeArgs": ["server/index.js"], + "port": 4671 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ff8fd0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# .env にコピーして値を設定する(chmod 600 推奨) +ACCESS_KEY=000000 +PORT=4671 +# HOST=127.0.0.1 +# 本番のみ設定(ローカル開発では設定しない) +# PUBLIC_ORIGIN=https://poker.kanawo.net diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd029cf --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Hold'em Night + +友達専用のテキサスホールデム(リングゲーム)サイト。Node.js + Express + Socket.IO。 + +現状は「鍵入力 → ロビー → テーブル作成 → プレイ画面(スタブ)」まで。カード配布・ベッティングなどのゲーム処理は未実装で、プレイ画面ではホストが「次へ(仮)」でフェーズを手動で進められる。 + +## ローカルで動かす + +```bash +cp .env.example .env # ACCESS_KEY を設定 +npm install +npm start +``` + +http://localhost:4671 を開く。 + +## 設定(.env) + +秘密情報はソースコードに書かず、プロジェクト直下の `.env`(git 管理外)か環境変数で渡す。`ACCESS_KEY` が無いとサーバは起動しない。 + +| 変数 | 既定値 | 内容 | +| --- | --- | --- | +| `ACCESS_KEY` | なし(必須) | 6桁の参加キー | +| `PORT` | `4671` | 待ち受けポート | +| `HOST` | `127.0.0.1` | 待ち受けアドレス。既定では外部から直接つながらない(nginx 経由で公開する) | +| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない | +| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 | + +## セキュリティ + +- **参加キー**: 正しいキーを入れたブラウザに HttpOnly / SameSite=Lax(HTTPS 時は Secure)の Cookie を保存。アクセスのたびに有効期限(400日)が延長されるので、以降は入力不要。 +- **総当たり対策**: 同じIP(IPv6 は /64 単位)から30分に5回間違えるとロック。サーバ全体で1時間に30回間違えると新規の鍵入力を一時停止(入室済みの人は影響なし)。失敗はログに出る(`journalctl -u holdem`)。 +- **入室状態の取り消し**: キーを変えるか、`data/session-secret` を削除して再起動すると、全員の Cookie が無効になる。 +- **CSRF / Cross-Site WebSocket**: 状態を変える API と WebSocket 接続は Origin が自サイトのときだけ受け付ける。Socket.IO は WebSocket のみ。 +- **XSS**: 厳格な CSP(インラインスクリプト・インラインスタイル禁止)、表示時のエスケープ、名前から制御文字・不可視文字を除去。 +- **その他**: クリックジャッキング防止(`frame-ancestors 'none'`)、`nosniff`、`no-referrer`、HTML/API は `no-store`、検索エンジン除外、エラー時に内部情報を返さない、リクエストサイズ・イベント連打・テーブル数の上限。 +- 他人の席を操作できないよう、席の持ち主を示すプレイヤーIDはブラウザ内だけに保存し、他のプレイヤーには送らない。 + +6桁のキーはそれ自体が強い秘密ではないので、上記の回数制限が守りの要になっている。キーは友達にだけ直接伝えること。 + +## 構成 + +| パス | 内容 | +| --- | --- | +| `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO | +| `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック | +| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・フェーズ・タイマー) | +| `server/config.js` | `.env` の読み込みと各種上限値 | +| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | +| `public/*.html`, `public/assets/*.js` | 各画面 | + +- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 +- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 +- 作成者がホスト(👑)。ホストが退席すると次の着席者に移る。 + +## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS) + +公開URLは https://poker.kanawo.net 。HTTPS なしで公開するとキーと Cookie が平文で流れるので、必ず nginx + Let's Encrypt 経由で公開する。 + +事前に DNS で `poker.kanawo.net` の A レコード(IPv6 があれば AAAA も)を VPS の IP に向けておく。 + +```bash +# 専用ユーザーで動かす +sudo useradd --system --home /opt/holdem --shell /usr/sbin/nologin holdem +sudo git clone <このリポジトリ> /opt/holdem +cd /opt/holdem +sudo npm ci --omit=dev + +# 秘密情報(本人のみ読める) +sudo cp .env.example .env +sudo nano .env # ACCESS_KEY=197107, PUBLIC_ORIGIN=https://poker.kanawo.net を設定 +sudo mkdir -p data +sudo chown -R holdem:holdem .env data +sudo chmod 600 .env && sudo chmod 700 data +``` + +`/etc/systemd/system/holdem.service`: + +```ini +[Unit] +Description=Hold'em Night +After=network.target + +[Service] +User=holdem +WorkingDirectory=/opt/holdem +ExecStart=/usr/bin/node server/index.js +Environment=NODE_ENV=production +Restart=always + +# サンドボックス +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ReadWritePaths=/opt/holdem/data +CapabilityBoundingSet= +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +[Install] +WantedBy=multi-user.target +``` + +nginx(`/etc/nginx/sites-available/holdem`、WebSocket を通す): + +```nginx +# poker.kanawo.net 以外のホスト名(IP直打ち等)は接続ごと切る +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 444; +} + +server { + server_name poker.kanawo.net; + client_max_body_size 8k; + + location / { + proxy_pass http://127.0.0.1:4671; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1h; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/holdem /etc/nginx/sites-enabled/ +sudo certbot --nginx -d poker.kanawo.net # HTTPS 化(HTTP→HTTPS リダイレクトも設定される) +sudo systemctl daemon-reload && sudo systemctl enable --now holdem + +# ファイアウォール: 4671 は開けない(nginx だけが 127.0.0.1 経由で使う) +sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..307e4fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1083 @@ +{ + "name": "friends-holdem", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "friends-holdem", + "version": "0.1.0", + "dependencies": { + "express": "^4.22.3", + "socket.io": "^4.8.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "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.16.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/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/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/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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "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.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "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-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/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/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/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/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/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/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/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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..1a6ecc4 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "friends-holdem", + "version": "0.1.0", + "private": true, + "description": "友達用テキサスホールデム リングゲーム", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "express": "^4.22.3", + "socket.io": "^4.8.4" + } +} diff --git a/public/assets/blinds.js b/public/assets/blinds.js new file mode 100644 index 0000000..c5e0ebb --- /dev/null +++ b/public/assets/blinds.js @@ -0,0 +1,57 @@ +// ブラインド計算(サーバ・ブラウザ共用) +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.Blinds = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + // 1, 2, 3, 4, 5, 6, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 150, ... + const MANTISSAS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5]; + const LADDER = []; + for (let e = 0; e <= 9; e++) { + for (const m of MANTISSAS) { + const v = Math.round(m * Math.pow(10, e) * 100) / 100; + if (Number.isInteger(v)) LADDER.push(v); + } + } + + function nearestNice(x) { + if (!(x >= 1)) return 1; + let best = LADDER[0]; + for (const v of LADDER) if (Math.abs(v - x) < Math.abs(best - x)) best = v; + return best; + } + + // 開始スタックに合うブラインド(BB = スタックの約1/100) + function defaultBlinds(stack) { + const sb = nearestNice(stack / 200); + return { sb, bb: sb * 2 }; + } + + // 1レベルごとに約1.5倍 + function nextSmallBlind(sb) { + const target = sb * 1.4; + for (const v of LADDER) if (v >= target && v > sb) return v; + return Math.ceil(sb * 1.5); + } + + // level は 1 始まり + function blindsAt(sb0, bb0, level) { + const ratio = bb0 / sb0; + let sb = sb0; + let bb = bb0; + for (let i = 2; i <= level; i++) { + sb = nextSmallBlind(sb); + bb = Math.round(sb * ratio); + } + return { sb, bb }; + } + + function schedule(sb0, bb0, levels) { + const out = []; + for (let i = 1; i <= levels; i++) out.push(Object.assign({ level: i }, blindsAt(sb0, bb0, i))); + return out; + } + + return { defaultBlinds, blindsAt, schedule }; +}); diff --git a/public/assets/common.js b/public/assets/common.js new file mode 100644 index 0000000..03b498c --- /dev/null +++ b/public/assets/common.js @@ -0,0 +1,99 @@ +// 各ページ共通のユーティリティ +(function () { + 'use strict'; + + function lsGet(key) { + try { return localStorage.getItem(key); } catch { return null; } + } + function lsSet(key, value) { + try { localStorage.setItem(key, value); } catch { /* プライベートモード等 */ } + } + + // 非HTTPS環境でも動くよう crypto.randomUUID ではなく getRandomValues を使う + function randomHex(bytes) { + const a = new Uint8Array(bytes); + crypto.getRandomValues(a); + return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + const Store = { + get name() { return lsGet('poker.name') || ''; }, + set name(v) { lsSet('poker.name', v); }, + // 席の再接続用。ブラウザごとに固定 + get token() { + let t = lsGet('poker.token'); + if (!t || !/^[0-9a-f]{32}$/.test(t)) { + t = randomHex(16); + lsSet('poker.token', t); + } + return t; + }, + }; + + function esc(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + function chips(n) { + return '$' + Number(n).toLocaleString('en-US'); + } + + let toastTimer; + function toast(msg) { + let el = document.querySelector('.toast'); + if (!el) { + el = document.createElement('div'); + el.className = 'toast'; + document.body.appendChild(el); + } + el.textContent = msg; + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, 2500); + } + + async function api(method, url, body) { + const res = await fetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + if (res.status === 401 && url !== '/api/auth') { + location.href = '/key?next=' + encodeURIComponent(location.pathname); + throw new Error('unauthorized'); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'エラーが発生しました'); + return data; + } + + // サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する + function connectSocket() { + const socket = io({ transports: ['websocket'] }); + socket.on('connect_error', () => { + fetch('/api/tables', { credentials: 'same-origin' }) + .then((res) => { + if (res.status === 401) location.href = '/key?next=' + encodeURIComponent(location.pathname); + }) + .catch(() => {}); + }); + return socket; + } + + // 自サイト内のパスだけ許可("//x" や "/\x" は外部サイト扱いになるので拒否) + function safeNext(next) { + return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/'; + } + + const PHASE_LABELS = { + WAITING: 'WAITING', + PREFLOP: 'PREFLOP', + FLOP: 'FLOP', + TURN: 'TURN', + RIVER: 'RIVER', + SHOWDOWN: 'SHOWDOWN', + }; + + window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS }; +})(); diff --git a/public/assets/create.js b/public/assets/create.js new file mode 100644 index 0000000..0505be1 --- /dev/null +++ b/public/assets/create.js @@ -0,0 +1,143 @@ +(function () { + 'use strict'; + + const { Store, api, chips } = App; + if (!Store.name) { + location.replace('/'); + return; + } + + const $ = (id) => document.getElementById(id); + const form = $('form'); + const nameInput = $('table-name'); + const stackInput = $('stack'); + const sbInput = $('sb'); + const bbInput = $('bb'); + const timerInput = $('timer'); + const intervalInput = $('interval'); + const scheduleEl = $('schedule'); + const blindHint = $('blind-hint'); + const errorEl = $('error'); + const submitBtn = $('submit'); + + // ブラインドを手で変えるまではスタックに追従させる + let blindsTouched = false; + + async function suggestName() { + try { + const { name } = await api('GET', '/api/tables/suggest-name'); + nameInput.value = name; + } catch { /* 失敗時は手入力 */ } + } + + const int = (el) => { + const n = Number(el.value); + return Number.isInteger(n) ? n : NaN; + }; + + function applyDefaultBlinds() { + const stack = int(stackInput); + if (!(stack >= 10)) return; + const { sb, bb } = Blinds.defaultBlinds(stack); + sbInput.value = sb; + bbInput.value = bb; + } + + function updateHint() { + const stack = int(stackInput); + const bb = int(bbInput); + if (blindsTouched && stack > 0 && bb > 0) { + blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ おすすめに戻す`; + } else { + blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)'; + } + } + + function updateSchedule() { + const sb = int(sbInput); + const bb = int(bbInput); + const on = timerInput.checked; + intervalInput.disabled = !on; + scheduleEl.hidden = !on || !(sb > 0 && bb >= sb); + if (scheduleEl.hidden) return; + const mins = int(intervalInput) > 0 ? int(intervalInput) : 0; + scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8) + .map((l, i) => `Lv.${l.level} ${chips(l.sb)}/${chips(l.bb)}${mins ? ` · ${i * mins}分〜` : ''}`) + .join(''); + } + + function refresh() { + updateHint(); + updateSchedule(); + } + + stackInput.addEventListener('input', () => { + if (!blindsTouched) applyDefaultBlinds(); + refresh(); + }); + [sbInput, bbInput].forEach((el) => + el.addEventListener('input', () => { + blindsTouched = true; + if (el === sbInput && int(sbInput) > 0) bbInput.value = int(sbInput) * 2; + refresh(); + }) + ); + blindHint.addEventListener('click', (e) => { + if (e.target.id !== 'reset-blinds') return; + e.preventDefault(); + blindsTouched = false; + applyDefaultBlinds(); + refresh(); + }); + timerInput.addEventListener('change', refresh); + intervalInput.addEventListener('input', refresh); + $('reroll').addEventListener('click', suggestName); + + function validate() { + const name = nameInput.value.trim(); + const stack = int(stackInput); + const sb = int(sbInput); + const bb = int(bbInput); + const interval = int(intervalInput); + if (!name) return [nameInput, 'テーブル名を入力してください']; + if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください']; + if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください']; + if (!(bb >= sb)) return [bbInput, 'BBはSB以上にしてください']; + if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです']; + if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください']; + return null; + } + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid')); + const problem = validate(); + if (problem) { + problem[0].classList.add('invalid'); + problem[0].focus(); + errorEl.textContent = problem[1]; + return; + } + errorEl.textContent = ''; + submitBtn.disabled = true; + try { + const { id } = await api('POST', '/api/tables', { + name: nameInput.value.trim(), + startingStack: int(stackInput), + smallBlind: int(sbInput), + bigBlind: int(bbInput), + blindTimerEnabled: timerInput.checked, + blindIntervalMin: int(intervalInput), + token: Store.token, + }); + location.href = '/table/' + encodeURIComponent(id); + } catch (err) { + errorEl.textContent = err.message; + submitBtn.disabled = false; + } + }); + + applyDefaultBlinds(); + refresh(); + suggestName(); +})(); diff --git a/public/assets/key.js b/public/assets/key.js new file mode 100644 index 0000000..fcc0e56 --- /dev/null +++ b/public/assets/key.js @@ -0,0 +1,82 @@ +(function () { + 'use strict'; + + const form = document.getElementById('form'); + const box = document.getElementById('digits'); + const inputs = [...box.querySelectorAll('input')]; + const errorEl = document.getElementById('error'); + const submitBtn = document.getElementById('submit'); + let busy = false; + + const value = () => inputs.map((i) => i.value).join(''); + + function fill(digits, from) { + let i = from; + for (const d of digits) { + if (i >= inputs.length) break; + inputs[i++].value = d; + } + inputs[Math.min(i, inputs.length - 1)].focus(); + if (value().length === 6) submit(); + } + + inputs.forEach((input, idx) => { + input.addEventListener('input', () => { + const digits = input.value.replace(/\D/g, ''); + input.value = ''; + if (digits) fill(digits, idx); + errorEl.textContent = ''; + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !input.value && idx > 0) { + inputs[idx - 1].value = ''; + inputs[idx - 1].focus(); + e.preventDefault(); + } else if (e.key === 'ArrowLeft' && idx > 0) { + inputs[idx - 1].focus(); + } else if (e.key === 'ArrowRight' && idx < inputs.length - 1) { + inputs[idx + 1].focus(); + } + }); + input.addEventListener('paste', (e) => { + const digits = (e.clipboardData.getData('text') || '').replace(/\D/g, ''); + if (!digits) return; + e.preventDefault(); + fill(digits, idx); + }); + input.addEventListener('focus', () => input.select()); + }); + + async function submit() { + if (busy) return; + const key = value(); + if (key.length !== 6) { + errorEl.textContent = '6桁すべて入力してください。'; + return; + } + busy = true; + submitBtn.disabled = true; + try { + await App.api('POST', '/api/auth', { key }); + const next = new URLSearchParams(location.search).get('next'); + location.replace(App.safeNext(next)); + } catch (err) { + errorEl.textContent = err.message; + box.classList.remove('shake'); + void box.offsetWidth; + box.classList.add('shake'); + inputs.forEach((i) => { i.value = ''; }); + inputs[0].focus(); + } finally { + busy = false; + submitBtn.disabled = false; + } + } + + form.addEventListener('submit', (e) => { + e.preventDefault(); + submit(); + }); + + inputs[0].focus(); +})(); diff --git a/public/assets/lobby.js b/public/assets/lobby.js new file mode 100644 index 0000000..5c5abfd --- /dev/null +++ b/public/assets/lobby.js @@ -0,0 +1,72 @@ +(function () { + 'use strict'; + + const { Store, esc, chips } = App; + const nameInput = document.getElementById('name'); + const nameError = document.getElementById('name-error'); + const listEl = document.getElementById('list'); + const countEl = document.getElementById('count'); + + nameInput.value = Store.name; + nameInput.addEventListener('input', () => { + Store.name = nameInput.value.trim(); + nameInput.classList.remove('invalid'); + nameError.textContent = ''; + }); + + function requireName() { + if (Store.name) return true; + nameInput.classList.add('invalid'); + nameError.textContent = '先に名前を入力してください。'; + nameInput.focus(); + return false; + } + + document.getElementById('create').addEventListener('click', () => { + if (requireName()) location.href = '/create'; + }); + + listEl.addEventListener('click', (e) => { + const btn = e.target.closest('[data-join]'); + if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join); + }); + + function phaseBadge(t) { + if (t.paused) return 'PAUSED'; + if (t.phase === 'WAITING') return 'WAITING'; + return 'PLAYING'; + } + + function render(tables) { + countEl.textContent = tables.length ? `${tables.length} テーブル` : ''; + if (!tables.length) { + listEl.innerHTML = '
開催中のテーブルはありません。
テーブルを作成して友達を招待しよう。
'; + return; + } + listEl.innerHTML = tables + .map((t) => { + const full = t.playerCount >= t.maxSeats; + const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし'; + return ` +
+
+
${esc(t.name)}${phaseBadge(t)}
+
+ 👤 ${t.playerCount}/${t.maxSeats} + 開始 ${chips(t.startingStack)} + Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)} + ${timer} +
+
+ +
`; + }) + .join(''); + } + + const socket = App.connectSocket(); + socket.on('connect', () => socket.emit('lobby:subscribe')); + socket.on('lobby:tables', render); +})(); diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..138fbbb --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,514 @@ +:root { + --bg: #24493b; + --bg-deep: #1b3a2e; + --panel: #1a2620; + --panel-2: #26332c; + --input: #303a35; + --line: #3a4a42; + --text: #f2f5f3; + --muted: #9fb2a8; + --felt: #2ecc71; + --felt-dark: #23a85b; + --gold: #f6c744; + --orange: #f0a43a; + --blue: #4a90e2; + --green: #2ecc71; + --red: #ff5a5a; + --radius: 14px; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +body { min-height: 100dvh; } + +.wrap { + max-width: 480px; + margin: 0 auto; + padding: 24px 16px 40px; +} + +h1, h2, h3 { margin: 0; } + +.logo { + text-align: center; + font-weight: 900; + line-height: 0.95; + letter-spacing: -0.02em; + font-size: 48px; + margin: 28px 0 32px; +} +.logo small { + display: block; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.3em; + color: var(--gold); + margin-top: 10px; +} + +.section-title { + font-size: 18px; + font-weight: 800; + margin: 28px 0 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.section-title .sub { font-size: 12px; font-weight: 600; color: var(--muted); } + +label, .label { + display: block; + font-size: 14px; + color: var(--muted); + margin: 18px 0 8px; +} + +input[type="text"], input[type="number"], input[type="tel"] { + width: 100%; + font: inherit; + font-size: 18px; + color: var(--text); + background: var(--input); + border: 2px solid transparent; + border-radius: 10px; + padding: 14px 16px; + outline: none; + transition: border-color 0.15s; +} +input:focus { border-color: var(--felt); } +input:disabled { opacity: 0.4; } +input.invalid { border-color: var(--red); } + +.row { display: flex; gap: 10px; align-items: center; } +.row > * { flex: 1; min-width: 0; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font: inherit; + font-size: 17px; + font-weight: 800; + border: 0; + border-radius: 10px; + padding: 14px 18px; + color: #fff; + cursor: pointer; + text-decoration: none; + transition: transform 0.06s, filter 0.15s, opacity 0.15s; + white-space: nowrap; +} +.btn:active:not(:disabled) { transform: scale(0.97); } +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-orange { background: var(--orange); } +.btn-blue { background: var(--blue); } +.btn-green { background: var(--green); } +.btn-dark { background: var(--panel-2); color: var(--text); } +.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); } +.btn-block { width: 100%; } +.btn-sm { font-size: 14px; padding: 9px 14px; border-radius: 8px; } + +.icon-btn { + width: 44px; + height: 44px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.15); + background: rgba(0, 0, 0, 0.18); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 18px; + text-decoration: none; + flex: none; +} + +.divider { height: 1px; background: var(--line); margin: 24px 0; } + +.error-text { color: var(--red); font-size: 14px; min-height: 20px; margin-top: 10px; } +.hint { color: var(--muted); font-size: 12px; margin-top: 6px; } + +.card { + background: var(--panel); + border-radius: var(--radius); + padding: 16px; +} + +/* ---------- 鍵入力 ---------- */ +.key-inputs { display: flex; gap: 6px; justify-content: center; margin: 8px 0 4px; } +.key-inputs input { + flex: 1; + min-width: 0; + max-width: 52px; + height: 60px; + padding: 0; + text-align: center; + font-size: 28px; + font-weight: 800; +} +.shake { animation: shake 0.35s; } +@keyframes shake { + 20%, 60% { transform: translateX(-8px); } + 40%, 80% { transform: translateX(8px); } +} + +/* ---------- ロビー ---------- */ +.table-list { display: flex; flex-direction: column; gap: 10px; } +.table-item { + background: var(--panel); + border-radius: var(--radius); + padding: 14px 14px 14px 16px; + display: flex; + gap: 12px; + align-items: center; +} +.table-item .info { flex: 1; min-width: 0; } +.table-item .name { + font-weight: 800; + font-size: 17px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.table-item .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + font-size: 13px; + color: var(--muted); + margin-top: 6px; +} +.table-item .meta b { color: var(--text); font-weight: 700; } +.table-item .meta .gold { color: var(--gold); } + +.badge { + display: inline-block; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + padding: 3px 8px; + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + vertical-align: 2px; + margin-left: 6px; +} +.badge.live { background: rgba(46, 204, 113, 0.18); color: var(--green); } +.badge.paused { background: rgba(255, 90, 90, 0.15); color: var(--red); } + +.empty { + text-align: center; + color: var(--muted); + padding: 32px 16px; + border: 2px dashed var(--line); + border-radius: var(--radius); + font-size: 14px; +} + +/* ---------- テーブル作成 ---------- */ +.topbar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } +.topbar h1 { font-size: 20px; flex: 1; } + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 22px; + font-weight: 800; + font-size: 17px; +} +.switch { position: relative; width: 52px; height: 30px; flex: none; } +.switch input { opacity: 0; width: 0; height: 0; position: absolute; } +.switch span { + position: absolute; + inset: 0; + background: var(--input); + border-radius: 999px; + cursor: pointer; + transition: background 0.2s; +} +.switch span::before { + content: ""; + position: absolute; + width: 24px; + height: 24px; + left: 3px; + top: 3px; + background: #ddd; + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} +.switch input:checked + span { background: var(--green); } +.switch input:checked + span::before { transform: translateX(22px); background: #fff; } +.switch input:focus-visible + span { outline: 2px solid var(--gold); } + +.unit-input { position: relative; } +.unit-input input { padding-right: 56px; } +.unit-input .unit { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + font-weight: 700; + pointer-events: none; +} + +.schedule { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} +.schedule span { + font-size: 12px; + background: var(--panel); + color: var(--muted); + padding: 4px 8px; + border-radius: 6px; +} +.schedule span b { color: var(--gold); font-weight: 700; } + +/* ---------- プレイ画面 ---------- */ +.table-page { display: flex; flex-direction: column; min-height: 100dvh; } +.table-page .wrap { padding-top: 16px; padding-bottom: 0; flex: 1; display: flex; flex-direction: column; width: 100%; } + +.table-header { display: flex; align-items: center; gap: 12px; } +.table-header h1 { + flex: 1; + text-align: center; + font-size: 22px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); } + +.host-controls { display: flex; justify-content: center; gap: 8px; margin: 16px 0 12px; flex-wrap: wrap; } +.host-controls .btn { border-radius: 999px; padding: 10px 16px; font-size: 14px; } + +.level-bar { + background: var(--panel); + border-radius: var(--radius); + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; +} +.level-bar .lv { color: var(--gold); font-weight: 800; } +.level-bar .blinds { font-weight: 800; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.level-bar .timer { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 18px; } +.level-bar .timer.warn { color: var(--orange); } +.level-bar .timer.paused { color: var(--red); } +.level-sub { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin: 6px 4px 0; } + +.felt-area { + position: relative; + flex: 1; + min-height: 400px; + margin: 8px 0; +} +.felt { + position: absolute; + left: 50%; + top: 50%; + width: 62%; + height: 72%; + transform: translate(-50%, -50%); + border-radius: 999px; + background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-dark) 100%); + box-shadow: 0 0 0 14px rgba(46, 204, 113, 0.18), inset 0 0 40px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} +.pot-label { font-weight: 900; color: rgba(0, 0, 0, 0.3); letter-spacing: 0.1em; font-size: 14px; } +.pot { + font-size: 40px; + font-weight: 900; + color: var(--gold); + text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3); + line-height: 1; +} +.phase { + margin-top: 8px; + background: rgba(0, 0, 0, 0.18); + color: #fff; + font-weight: 900; + letter-spacing: 0.08em; + font-size: 13px; + padding: 5px 14px; + border-radius: 999px; +} +.hand-no { font-size: 11px; color: rgba(0, 0, 0, 0.45); font-weight: 700; margin-top: 2px; } + +.seat { + position: absolute; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + width: 92px; + transition: left 0.3s, top 0.3s; +} +.avatar { + width: 58px; + height: 58px; + border-radius: 50%; + background: #3b4a5c; + border: 3px solid #8a96a3; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.avatar svg { width: 28px; height: 28px; fill: #fff; } +.seat.me .avatar { background: #d35400; border-color: var(--gold); } +.seat.offline .avatar { opacity: 0.45; } +.nameplate { + margin-top: -10px; + background: rgba(10, 30, 20, 0.88); + border-radius: 10px; + padding: 4px 8px; + min-width: 84px; + max-width: 92px; + text-align: center; + position: relative; +} +.nameplate .pname { + font-size: 12px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.nameplate .pstack { font-size: 13px; font-weight: 800; color: var(--gold); } +.chip-marker { + position: absolute; + width: 24px; + height: 24px; + border-radius: 50%; + font-size: 11px; + font-weight: 900; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #222; +} +.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; } +.blind-marker { + position: absolute; + top: -4px; + left: -10px; + font-size: 10px; + font-weight: 900; + padding: 2px 5px; + border-radius: 6px; + color: #111; +} +.blind-marker.sb { background: #7fd3ff; } +.blind-marker.bb { background: var(--gold); } +.host-mark { position: absolute; bottom: -2px; right: -6px; font-size: 14px; } +.offline-tag { font-size: 10px; color: var(--muted); } + +.spectator-note { + text-align: center; + font-size: 13px; + color: var(--muted); + margin-bottom: 8px; +} + +.action-bar { + background: #161c19; + padding: 12px 16px calc(14px + env(safe-area-inset-bottom)); +} +.action-bar .inner { max-width: 480px; margin: 0 auto; } +.my-stack { + text-align: center; + font-size: 28px; + font-weight: 900; + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} +.my-stack .wallet { color: var(--gold); font-size: 22px; } +.actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.actions .btn { background: #555; color: #bbb; font-size: 18px; } + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 10; +} +.modal { background: var(--bg-deep); border-radius: var(--radius); padding: 20px; width: 100%; max-width: 380px; } +.modal h2 { font-size: 18px; margin-bottom: 4px; } + +.toast { + position: fixed; + left: 50%; + bottom: 160px; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 10px 16px; + border-radius: 10px; + font-size: 14px; + z-index: 20; + pointer-events: none; + max-width: calc(100% - 32px); + text-align: center; +} + +.center-message { text-align: center; padding: 60px 16px; color: var(--muted); } +.center-message h2 { color: var(--text); margin-bottom: 12px; } + +.conn-lost { + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--red); + color: #fff; + text-align: center; + font-size: 13px; + font-weight: 700; + padding: 6px; + z-index: 30; +} + +[hidden] { display: none !important; } + +/* ---------- ユーティリティ(CSP で style 属性を使わないため) ---------- */ +.text-center { text-align: center; } +.mt-4 { margin-top: 4px; } +.mt-8 { margin-top: 8px; } +.mt-12 { margin-top: 12px; } +.key-title { font-size: 18px; text-align: center; } +.key-hint { text-align: center; margin: 6px 0 16px; } +.slash { flex: none !important; color: var(--muted); font-weight: 800; } +label.switch { margin: 0; display: inline-block; } +.link-gold { color: var(--gold); } +.host-spacer { height: 12px; } diff --git a/public/assets/table.js b/public/assets/table.js new file mode 100644 index 0000000..d967930 --- /dev/null +++ b/public/assets/table.js @@ -0,0 +1,187 @@ +// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ +(function () { + 'use strict'; + + const { Store, esc, chips, toast } = App; + const $ = (id) => document.getElementById(id); + const tableId = decodeURIComponent(location.pathname.split('/').pop()); + + const AVATAR_SVG = + ''; + + let socket = null; + let state = null; + let clockOffset = 0; // serverNow - clientNow + + // ---------- 名前 ---------- + function ensureName() { + if (Store.name) return connect(); + $('name-modal').hidden = false; + $('name-input').focus(); + } + $('name-form').addEventListener('submit', (e) => { + e.preventDefault(); + const name = $('name-input').value.trim(); + if (!name) return $('name-input').classList.add('invalid'); + Store.name = name; + $('name-modal').hidden = true; + connect(); + }); + + // ---------- 通信 ---------- + function connect() { + socket = App.connectSocket(); + socket.on('connect', () => { + $('conn-lost').hidden = true; + socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => { + if (res && res.ok) return; + if (res && res.error === 'NOT_FOUND') return showNotFound(); + toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー')); + }); + }); + socket.on('disconnect', () => { $('conn-lost').hidden = false; }); + socket.on('connect_error', () => { $('conn-lost').hidden = false; }); + socket.on('table:state', (s) => { + state = s; + clockOffset = s.serverNow - Date.now(); + render(); + }); + } + + function emit(event) { + if (!socket) return; + socket.emit(event, null, (res) => { + if (res && !res.ok) toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error); + }); + } + + function showNotFound() { + $('main').hidden = true; + $('action-bar').hidden = true; + $('not-found').hidden = false; + if (socket) socket.disconnect(); + } + + // ---------- 描画 ---------- + function render() { + const s = state; + $('main').hidden = false; + document.title = `${s.name} - Hold'em Night`; + $('table-name').textContent = s.name; + + $('level').textContent = `Lv.${s.level}`; + $('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`; + $('next-blinds').textContent = s.timer.enabled + ? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)` + : 'ブラインドタイマーなし'; + $('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`; + + $('pot').textContent = chips(s.pot); + $('phase').textContent = App.PHASE_LABELS[s.phase] || s.phase; + $('hand-no').textContent = s.handNumber ? `Hand #${s.handNumber}` : ''; + + // ホスト操作 + const players = s.seats.filter(Boolean).length; + $('host-controls').hidden = !s.isHost; + $('host-spacer').hidden = s.isHost; + $('start').disabled = s.phase !== 'WAITING' || players < 2; + $('start').title = players < 2 ? '2人以上で開始できます' : ''; + $('next-phase').disabled = s.phase === 'WAITING'; + $('pause').disabled = !s.started; + $('pause').textContent = s.paused ? '再開' : '一時停止'; + + // 自分 + const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null; + $('action-bar').hidden = !me; + $('spectator-note').hidden = Boolean(me); + $('leave').title = me ? '退席する' : 'ロビーへ戻る'; + if (me) $('my-stack').textContent = chips(me.stack); + + renderSeats(s); + renderTimer(); + } + + // 着席者を楕円上に配置。自分が一番下、そこから時計回り。 + function renderSeats(s) { + const occupied = s.seats.filter(Boolean); + const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat)); + const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx)); + const n = ordered.length; + + const container = $('seats'); + container.innerHTML = ordered + .map((p) => { + const classes = ['seat']; + if (p.seat === s.mySeat) classes.push('me'); + if (!p.connected) classes.push('offline'); + const markers = [ + p.seat === s.buttonSeat ? 'D' : '', + p.seat === s.sbSeat ? 'SB' : '', + p.seat === s.bbSeat ? 'BB' : '', + ].join(''); + return ` +
+
${AVATAR_SVG}${markers}
+
+
${p.seat === s.hostSeat ? '👑' : ''}${esc(p.name)}
+
${chips(p.stack)}
+ ${p.connected ? '' : '
オフライン
'} +
+
`; + }) + .join(''); + + // 位置は CSSOM で設定(CSP で style 属性を禁止しているため) + [...container.children].forEach((el, i) => { + const angle = Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1); + el.style.left = `${(50 + 38 * Math.cos(angle)).toFixed(2)}%`; + el.style.top = `${(50 + 41 * Math.sin(angle)).toFixed(2)}%`; + }); + } + + function renderTimer() { + const el = $('timer'); + if (!state) return; + const t = state.timer; + el.classList.toggle('paused', state.paused); + if (!t.enabled) { + el.textContent = state.paused ? '⏸ PAUSED' : ''; + return; + } + const remaining = t.running ? t.endsAt - (Date.now() + clockOffset) : t.remainingMs; + const sec = Math.max(0, Math.ceil(remaining / 1000)); + const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; + el.textContent = state.paused ? `⏸ ${time}` : time; + el.classList.toggle('warn', t.running && sec <= 60); + } + setInterval(renderTimer, 250); + + // ---------- 操作 ---------- + $('start').addEventListener('click', () => emit('table:start')); + $('next-phase').addEventListener('click', () => emit('table:nextPhase')); + $('pause').addEventListener('click', () => emit('table:pause')); + + $('leave').addEventListener('click', () => { + const seated = state && state.mySeat >= 0; + if (!seated) return (location.href = '/'); + if (!confirm('テーブルから退席しますか?(チップは失われます)')) return; + socket.emit('table:leave', null, () => { location.href = '/'; }); + }); + + $('share').addEventListener('click', async () => { + const url = location.origin + '/'; + try { + await navigator.clipboard.writeText(url); + } catch { + const ta = document.createElement('textarea'); + ta.value = url; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + toast('サイトのリンクをコピーしました'); + }); + + ensureName(); +})(); diff --git a/public/create.html b/public/create.html new file mode 100644 index 0000000..d04329c --- /dev/null +++ b/public/create.html @@ -0,0 +1,60 @@ + + + + + + + + テーブル作成 - Hold'em Night + + + +
+
+ ← +

テーブルを作成

+
+ +
+ +
+ + +
+ + + + +
開始ブラインド(SB / BB)
+
+ + / + +
+
スタックに合わせて自動設定(BB ≒ スタックの1/100)
+ +
+ ブラインドタイマー + +
+ + +
+ + min +
+ + +
+ +
+
+ + + + + + diff --git a/public/key.html b/public/key.html new file mode 100644 index 0000000..e2b33ce --- /dev/null +++ b/public/key.html @@ -0,0 +1,33 @@ + + + + + + + + Hold'em Night + + + +
+ + +
+

参加キーを入力

+

オーナーから教えてもらった6桁の数字

+
+ + + + + + +
+
+ +
+
+ + + + diff --git a/public/lobby.html b/public/lobby.html new file mode 100644 index 0000000..b6426c0 --- /dev/null +++ b/public/lobby.html @@ -0,0 +1,34 @@ + + + + + + + + Hold'em Night + + + +
+ + + + +
+ + + +
+ 開催中のテーブル + +
+
+
読み込み中…
+
+
+ + + + + + diff --git a/public/table.html b/public/table.html new file mode 100644 index 0000000..8c20dc2 --- /dev/null +++ b/public/table.html @@ -0,0 +1,81 @@ + + + + + + + + テーブル - Hold'em Night + + + + + +
+
+ +

+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+
+
POT
+
$0
+
WAITING
+
+
+
+
+ + +
+ + + + + + + + + + + + diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..d4cf145 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,214 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const config = require('./config'); + +const COOKIE_NAME = 'pk_auth'; +// ブラウザの上限が400日なので、アクセスのたびに延長する(実質無期限) +const COOKIE_MAX_AGE_SEC = 400 * 24 * 60 * 60; + +// Cookie 署名用の秘密値。再起動してもログイン状態が保たれるようファイルに保存する。 +// data/session-secret を削除して再起動すると、全員の入室状態を取り消せる。 +function loadSecret() { + if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET; + const file = path.join(__dirname, '..', 'data', 'session-secret'); + try { + const s = fs.readFileSync(file, 'utf8').trim(); + if (s.length >= 32) return s; + } catch { /* 初回 */ } + const secret = crypto.randomBytes(32).toString('hex'); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, secret, { mode: 0o600 }); + return secret; +} + +// 鍵を変えると既存の Cookie はすべて無効になる +const AUTH_TOKEN = crypto + .createHmac('sha256', loadSecret()) + .update('access:' + config.ACCESS_KEY) + .digest('base64url'); + +function parseCookies(header) { + const out = {}; + if (typeof header !== 'string') return out; + for (const part of header.split(';')) { + const i = part.indexOf('='); + if (i < 0) continue; + try { + out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); + } catch { /* 不正なエンコードは無視 */ } + } + return out; +} + +function safeEqual(a, b) { + // 長さの違いも漏らさないようハッシュ同士を比較する + const ha = crypto.createHash('sha256').update(String(a)).digest(); + const hb = crypto.createHash('sha256').update(String(b)).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +function isAuthedCookieHeader(cookieHeader) { + const value = parseCookies(cookieHeader)[COOKIE_NAME]; + return Boolean(value) && safeEqual(value, AUTH_TOKEN); +} + +function isAuthed(req) { + return isAuthedCookieHeader(req.headers.cookie); +} + +function setAuthCookie(req, res) { + const parts = [ + `${COOKIE_NAME}=${AUTH_TOKEN}`, + 'Path=/', + `Max-Age=${COOKIE_MAX_AGE_SEC}`, + 'HttpOnly', + 'SameSite=Lax', + ]; + if (req.secure) parts.push('Secure'); + res.setHeader('Set-Cookie', parts.join('; ')); +} + +// ブラウザからのリクエストが同じサイト発かどうか(CSRF / Cross-Site WebSocket 対策) +function isSameOrigin(headers) { + const origin = headers.origin; + if (!origin) return false; + if (config.PUBLIC_ORIGIN) return origin === config.PUBLIC_ORIGIN && isAllowedHost(headers); + try { + return new URL(origin).host === headers.host; + } catch { + return false; + } +} + +// PUBLIC_ORIGIN 設定時は、そのドメイン宛て以外のリクエスト(IP直打ち等)を拒否する +const PUBLIC_HOST = config.PUBLIC_ORIGIN ? new URL(config.PUBLIC_ORIGIN).host : null; +function isAllowedHost(headers) { + return !PUBLIC_HOST || String(headers.host || '').toLowerCase() === PUBLIC_HOST; +} + +function requireAllowedHost(req, res, next) { + if (isAllowedHost(req.headers)) return next(); + res.status(421).type('text/plain').send('Misdirected Request'); +} + +// ---- 失敗回数制限 ---- + +// IPv6 は1人が /64 を丸ごと持っていることが多いので /64 単位でまとめる +function clientKey(ip) { + let addr = String(ip || ''); + if (addr.startsWith('::ffff:')) addr = addr.slice(7); + if (net.isIPv6(addr)) { + const full = expandIPv6(addr); + return full ? full.slice(0, 4).join(':') + '::/64' : addr; + } + return addr; +} + +function expandIPv6(addr) { + const [head, tail] = addr.split('::'); + const h = head ? head.split(':') : []; + const t = tail !== undefined && tail ? tail.split(':') : []; + if (tail === undefined && h.length !== 8) return null; + const fill = new Array(8 - h.length - t.length).fill('0'); + return [...h, ...fill, ...t]; +} + +class Window { + constructor(ms) { + this.ms = ms; + this.count = 0; + this.resetAt = Date.now() + ms; + } + get expired() { + return this.resetAt <= Date.now(); + } +} + +const perClient = new Map(); // clientKey -> Window +let global = new Window(config.AUTH_GLOBAL_WINDOW_MS); + +function clientWindow(key) { + let w = perClient.get(key); + if (!w || w.expired) { + w = new Window(config.AUTH_FAILURE_WINDOW_MS); + perClient.set(key, w); + } + return w; +} + +setInterval(() => { + for (const [k, w] of perClient) if (w.expired) perClient.delete(k); +}, 60 * 1000).unref(); + +function tooMany(res, w) { + const retryMin = Math.max(1, Math.ceil((w.resetAt - Date.now()) / 60000)); + res.setHeader('Retry-After', String(retryMin * 60)); + return res.status(429).json({ error: `試行回数が多すぎます。${retryMin}分後に再度お試しください。` }); +} + +function handleLogin(req, res) { + if (!isSameOrigin(req.headers)) return res.status(403).json({ error: 'forbidden' }); + + const key = clientKey(req.ip); + const w = clientWindow(key); + if (global.expired) global = new Window(config.AUTH_GLOBAL_WINDOW_MS); + if (w.count >= config.AUTH_MAX_FAILURES) return tooMany(res, w); + if (global.count >= config.AUTH_GLOBAL_MAX_FAILURES) return tooMany(res, global); + + const input = typeof (req.body && req.body.key) === 'string' ? req.body.key.trim() : ''; + if (!/^\d{6}$/.test(input)) { + return res.status(400).json({ error: '6桁の数字を入力してください。' }); + } + if (!safeEqual(input, config.ACCESS_KEY)) { + w.count++; + global.count++; + console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`); + if (global.count === config.AUTH_GLOBAL_MAX_FAILURES) { + console.warn('[auth] 全体の失敗回数が上限に達したため、新規の鍵入力を一時停止します'); + } + const left = config.AUTH_MAX_FAILURES - w.count; + return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' }); + } + perClient.delete(key); + console.log(`[auth] 入室 from ${key}`); + setAuthCookie(req, res); + res.json({ ok: true }); +} + +// /key 以外のページ・API を保護する +function requireAuth(req, res, next) { + if (isAuthed(req)) { + if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res); + return next(); + } + if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' }); + if (req.method === 'GET') return res.redirect('/key?next=' + encodeURIComponent(req.originalUrl)); + res.status(401).end(); +} + +// 状態を変える API は同じサイトからのみ受け付ける +function requireSameOrigin(req, res, next) { + if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next(); + res.status(403).json({ error: 'forbidden' }); +} + +// オープンリダイレクト防止: 自サイト内の通常のパスだけ許可("//x" や "/\x" は外部扱いされるので拒否) +function safeNext(next) { + return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/'; +} + +module.exports = { + isAuthed, + isAuthedCookieHeader, + isSameOrigin, + isAllowedHost, + requireAllowedHost, + handleLogin, + requireAuth, + requireSameOrigin, + safeNext, +}; diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..c3fe7cd --- /dev/null +++ b/server/config.js @@ -0,0 +1,67 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// プロジェクト直下の .env を読み込む(既存の環境変数は上書きしない)。 +// 秘密情報はソースコードに書かず .env(git 管理外)か環境変数で渡す。 +function loadDotEnv(file) { + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + return; + } + if (process.platform !== 'win32' && (fs.statSync(file).mode & 0o077) !== 0) { + console.warn(`[config] ${file} が他ユーザーから読める状態です。chmod 600 してください。`); + } + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m || line.trim().startsWith('#')) continue; + const value = m[2].replace(/^(['"])(.*)\1$/, '$2'); + if (process.env[m[1]] === undefined) process.env[m[1]] = value; + } +} +loadDotEnv(path.join(__dirname, '..', '.env')); + +const ACCESS_KEY = process.env.ACCESS_KEY; +if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) { + console.error('[config] ACCESS_KEY(6桁の数字)が未設定です。.env か環境変数で指定してください。'); + process.exit(1); +} + +module.exports = { + PORT: Number(process.env.PORT) || 4671, + + // 既定では自分自身(127.0.0.1)でのみ待ち受け、外部には nginx 等の HTTPS 経由で公開する。 + // どうしても直接公開する場合のみ HOST=0.0.0.0 にする(通信が平文になるので非推奨)。 + HOST: process.env.HOST || '127.0.0.1', + + // 公開URL(例: https://poker.kanawo.net)。設定するとこのドメイン以外からのアクセスを拒否する。 + // ローカル開発時は未設定のままにする。 + PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN ? new URL(process.env.PUBLIC_ORIGIN).origin : null, + + // 参加キー(6桁の数字)。.env の ACCESS_KEY + ACCESS_KEY, + + // 1テーブルの最大着席数 + MAX_SEATS: 9, + + // 同時に存在できるテーブル数の上限 + MAX_TABLES: 30, + + // 誰も接続していないテーブルを削除するまでの時間 + EMPTY_TABLE_TTL_MS: 30 * 60 * 1000, + + // 鍵入力のブルートフォース対策 + // IP(IPv6 は /64 単位)ごと: WINDOW 内で MAX_FAILURES 回まで + AUTH_MAX_FAILURES: 5, + AUTH_FAILURE_WINDOW_MS: 30 * 60 * 1000, + // サーバ全体: 1時間に GLOBAL_MAX_FAILURES 回失敗したら新規の鍵入力を一時停止 + // (入室済みの人は Cookie があるので影響なし) + AUTH_GLOBAL_MAX_FAILURES: 30, + AUTH_GLOBAL_WINDOW_MS: 60 * 60 * 1000, + + // Socket.IO のイベント数上限(1接続あたり / 秒) + SOCKET_EVENTS_PER_SEC: 10, +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..b4f7f06 --- /dev/null +++ b/server/index.js @@ -0,0 +1,274 @@ +'use strict'; + +const path = require('path'); +const http = require('http'); +const express = require('express'); +const { Server } = require('socket.io'); +const config = require('./config'); +const auth = require('./auth'); +const { TableManager } = require('./tables'); + +const PUBLIC = path.join(__dirname, '..', 'public'); +const page = (file) => (req, res) => res.sendFile(path.join(PUBLIC, file)); + +const app = express(); +app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく +app.disable('x-powered-by'); + +app.use(auth.requireAllowedHost); + +// ---------- セキュリティヘッダ ---------- +const CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data:", + "connect-src 'self'", + "font-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", +].join('; '); + +app.use((req, res, next) => { + res.setHeader('Content-Security-Policy', CSP); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + res.setHeader('X-Robots-Tag', 'noindex, nofollow'); + if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000'); + // HTML と API はキャッシュさせない(共有端末やプロキシに残さない) + if (!req.path.startsWith('/assets/')) res.setHeader('Cache-Control', 'no-store'); + next(); +}); + +app.use(express.json({ limit: '4kb', strict: true })); + +const server = http.createServer(app); +server.headersTimeout = 20 * 1000; +server.requestTimeout = 30 * 1000; + +const io = new Server(server, { + // WebSocket のみ(ハンドシェイクに必ず Origin が付くので Cross-Site WebSocket を確実に弾ける) + transports: ['websocket'], + maxHttpBufferSize: 4 * 1024, + allowRequest: (req, callback) => { + callback( + null, + auth.isAllowedHost(req.headers) && auth.isSameOrigin(req.headers) && auth.isAuthedCookieHeader(req.headers.cookie) + ); + }, +}); +const tables = new TableManager(); + +// ---------- 認証不要 ---------- +app.use('/assets', express.static(path.join(PUBLIC, 'assets'), { index: false, dotfiles: 'deny' })); +app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n')); +app.get('/key', (req, res) => { + if (auth.isAuthed(req)) return res.redirect(auth.safeNext(req.query.next)); + page('key.html')(req, res); +}); +app.post('/api/auth', auth.handleLogin); + +// ---------- ここから先は鍵が必要 ---------- +app.use(auth.requireAuth); +app.use(auth.requireSameOrigin); + +app.get('/', page('lobby.html')); +app.get('/create', page('create.html')); +app.get('/table/:id', page('table.html')); + +app.get('/api/tables', (req, res) => res.json(tables.list())); +app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() })); + +const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max; + +app.post('/api/tables', (req, res) => { + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const name = cleanText(b.name, 24); + const startingStack = b.startingStack; + const smallBlind = b.smallBlind; + const bigBlind = b.bigBlind; + const blindTimerEnabled = b.blindTimerEnabled === true; + const blindIntervalMin = b.blindIntervalMin; + const hostToken = cleanToken(b.token); + + if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' }); + if (!name) return res.status(400).json({ error: 'テーブル名は1〜24文字で入力してください' }); + if (tables.nameTaken(name)) return res.status(409).json({ error: '同じ名前のテーブルがすでにあります' }); + if (!intIn(startingStack, 10, 100000000)) return res.status(400).json({ error: '開始スタックが不正です' }); + if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' }); + if (!intIn(bigBlind, smallBlind, startingStack)) return res.status(400).json({ error: 'BBはSB以上・開始スタック以下にしてください' }); + if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' }); + if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' }); + + const table = tables.create({ + name, + startingStack, + smallBlind, + bigBlind, + blindTimerEnabled, + blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10, + hostToken, + }); + broadcastLobby(); + res.json({ id: table.id }); +}); + +app.use((req, res) => res.status(404).type('text/plain').send('Not Found')); + +// 内部情報(スタックトレース等)を返さない +app.use((err, req, res, _next) => { + const status = err.status || err.statusCode || 500; + if (status >= 500) console.error(err); + res.status(status).json({ error: status >= 500 ? 'サーバエラー' : '不正なリクエストです' }); +}); + +// ---------- 入力の正規化 ---------- + +function cleanToken(t) { + return typeof t === 'string' && /^[0-9a-f]{32}$/.test(t) ? t : null; +} + +// 制御文字・不可視文字(方向制御など)を除き、空白をまとめて文字数で切る +function cleanText(s, max) { + if (typeof s !== 'string') return ''; + const chars = Array.from(s.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/gu, '').replace(/\s+/g, ' ').trim()); + return chars.slice(0, max).join('').trim(); +} + +// ---------- Socket.IO ---------- + +const roomOf = (id) => 'table:' + id; + +function roomSockets(tableId) { + const ids = io.sockets.adapter.rooms.get(roomOf(tableId)); + return ids ? [...ids].map((id) => io.sockets.sockets.get(id)).filter(Boolean) : []; +} + +function broadcastTable(table) { + const sockets = roomSockets(table.id); + const connected = new Set(sockets.map((s) => s.data.token)); + for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected)); +} + +function broadcastLobby() { + io.to('lobby').emit('lobby:tables', tables.list()); +} + +function changed(table) { + broadcastTable(table); + broadcastLobby(); +} + +// 二重チェック(allowRequest を通った後も念のため) +io.use((socket, next) => { + if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next(); + else next(new Error('unauthorized')); +}); + +io.on('connection', (socket) => { + const reply = (ack, payload) => typeof ack === 'function' && ack(payload); + + // イベントの連打を制限 + let windowStart = Date.now(); + let count = 0; + socket.use((_packet, next) => { + const now = Date.now(); + if (now - windowStart >= 1000) { + windowStart = now; + count = 0; + } + if (++count > config.SOCKET_EVENTS_PER_SEC) return socket.disconnect(true); + next(); + }); + + socket.on('lobby:subscribe', () => { + socket.join('lobby'); + socket.emit('lobby:tables', tables.list()); + }); + + socket.on('table:join', (p, ack) => { + if (!p || typeof p !== 'object') return reply(ack, { ok: false, error: 'BAD_REQUEST' }); + const table = typeof p.tableId === 'string' && tables.get(p.tableId); + if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' }); + const token = cleanToken(p.token); + const name = cleanText(p.name, 12); + if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' }); + if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' }); + + const prev = socket.data.tableId && tables.get(socket.data.tableId); + if (prev && prev !== table) { + socket.leave(roomOf(prev.id)); + broadcastTable(prev); + } + socket.data.token = token; + socket.data.tableId = table.id; + socket.join(roomOf(table.id)); + table.emptySince = null; + + const seat = table.join(token, name); + reply(ack, { ok: true, seat }); + changed(table); + }); + + // ホスト操作・退席は同じ形で処理する + const actions = { + 'table:leave': (t, token) => (t.leave(token) ? null : '着席していません'), + 'table:start': (t, token) => t.start(token), + 'table:nextPhase': (t, token) => t.nextPhase(token), + 'table:pause': (t, token) => t.togglePause(token), + }; + for (const [event, fn] of Object.entries(actions)) { + socket.on(event, (_p, ack) => { + const table = socket.data.tableId && tables.get(socket.data.tableId); + if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' }); + const error = fn(table, socket.data.token); + reply(ack, error ? { ok: false, error } : { ok: true }); + if (!error) changed(table); + }); + } + + socket.on('disconnect', () => { + const table = socket.data.tableId && tables.get(socket.data.tableId); + if (table) broadcastTable(table); // 接続中表示を更新 + }); +}); + +// ブラインドタイマー +setInterval(() => { + const now = Date.now(); + for (const table of tables.tables.values()) { + if (table.tick(now)) changed(table); + } +}, 1000); + +// 誰も見ていないテーブルを片付ける +setInterval(() => { + const now = Date.now(); + let removed = false; + for (const table of [...tables.tables.values()]) { + if (roomSockets(table.id).length > 0) { + table.emptySince = null; + continue; + } + if (table.emptySince == null) table.emptySince = now; + if (now - table.emptySince > config.EMPTY_TABLE_TTL_MS) { + tables.delete(table.id); + removed = true; + } + } + if (removed) broadcastLobby(); +}, 60 * 1000); + +server.listen(config.PORT, config.HOST, () => { + console.log(`Poker server listening on http://${config.HOST}:${config.PORT}`); + if (config.PUBLIC_ORIGIN) console.log(`[config] 公開URL: ${config.PUBLIC_ORIGIN}(他のホスト名は拒否)`); + if (config.HOST !== '127.0.0.1' && config.HOST !== '::1') { + console.warn('[config] 外部から直接アクセスできる設定です。HTTPS のリバースプロキシ経由での公開を推奨します。'); + } +}); diff --git a/server/tables.js b/server/tables.js new file mode 100644 index 0000000..c4c9822 --- /dev/null +++ b/server/tables.js @@ -0,0 +1,271 @@ +'use strict'; + +const crypto = require('crypto'); +const config = require('./config'); +const Blinds = require('../public/assets/blinds'); + +// 進行フェーズ。プレイ処理は未実装なので、ホストが手動で進めるスタブ。 +const HAND_PHASES = ['PREFLOP', 'FLOP', 'TURN', 'RIVER', 'SHOWDOWN']; + +class Table { + constructor(opts) { + this.id = opts.id; + this.name = opts.name; + this.startingStack = opts.startingStack; + this.baseSmallBlind = opts.smallBlind; + this.baseBigBlind = opts.bigBlind; + this.timerEnabled = opts.blindTimerEnabled; + this.intervalMin = opts.blindIntervalMin; + this.hostToken = opts.hostToken || null; + this.maxSeats = config.MAX_SEATS; + + this.seats = new Array(this.maxSeats).fill(null); // { token, name, stack } + this.phase = 'WAITING'; + this.started = false; + this.paused = false; + this.level = 1; + this.handNumber = 0; + this.pot = 0; + this.buttonSeat = null; + this.levelEndsAt = null; + this.levelRemainingMs = this.intervalMs; + + this.createdAt = Date.now(); + this.emptySince = null; + } + + get intervalMs() { + return this.intervalMin * 60 * 1000; + } + + get blinds() { + return Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level); + } + + get playerCount() { + return this.seats.filter(Boolean).length; + } + + seatOf(token) { + return this.seats.findIndex((s) => s && s.token === token); + } + + isHost(token) { + return Boolean(token) && token === this.hostToken; + } + + // 着席した席番号を返す。満席なら -1(観戦) + join(token, name) { + const existing = this.seatOf(token); + if (existing >= 0) { + this.seats[existing].name = name; + return existing; + } + const free = this.seats.findIndex((s) => !s); + if (free < 0) return -1; + this.seats[free] = { token, name, stack: this.startingStack }; + // ホスト(通常は作成者)が着席していなければ、この人がホストになる + if (this.seatOf(this.hostToken) < 0) this.hostToken = token; + return free; + } + + leave(token) { + const i = this.seatOf(token); + if (i < 0) return false; + this.seats[i] = null; + if (this.hostToken === token) { + const next = this.seats.find(Boolean); + this.hostToken = next ? next.token : null; + } + if (this.playerCount < 2 && this.phase !== 'WAITING') { + this.phase = 'WAITING'; + } + return true; + } + + // 次に埋まっている席(時計回り = 席番号の昇順) + nextOccupied(from) { + for (let k = 1; k <= this.maxSeats; k++) { + const i = (from + k) % this.maxSeats; + if (this.seats[i]) return i; + } + return null; + } + + blindSeats() { + if (this.buttonSeat == null || this.phase === 'WAITING' || this.playerCount < 2) { + return { sbSeat: null, bbSeat: null }; + } + let sbSeat; + if (this.playerCount === 2 && this.seats[this.buttonSeat]) { + sbSeat = this.buttonSeat; // ヘッズアップはボタンがSB + } else { + sbSeat = this.nextOccupied(this.buttonSeat); + } + return { sbSeat, bbSeat: this.nextOccupied(sbSeat) }; + } + + startNewHand() { + const occupied = this.seats.map((s, i) => (s ? i : -1)).filter((i) => i >= 0); + this.buttonSeat = + this.buttonSeat == null + ? occupied[Math.floor(Math.random() * occupied.length)] + : this.nextOccupied(this.buttonSeat); + this.handNumber++; + this.pot = 0; + this.phase = 'PREFLOP'; + } + + // ---- ホスト操作(エラー時はメッセージを返す) ---- + + start(token) { + if (!this.isHost(token)) return 'ホストのみ開始できます'; + if (this.phase !== 'WAITING') return 'すでに進行中です'; + if (this.playerCount < 2) return '2人以上必要です'; + if (!this.started) { + this.started = true; + if (this.timerEnabled) this.levelEndsAt = Date.now() + this.intervalMs; + } + this.startNewHand(); + return null; + } + + // スタブ: フェーズを1つ進める(SHOWDOWN の次は新しいハンド) + nextPhase(token) { + if (!this.isHost(token)) return 'ホストのみ操作できます'; + if (this.phase === 'WAITING') return 'まだ開始していません'; + if (this.playerCount < 2) return '2人以上必要です'; + const i = HAND_PHASES.indexOf(this.phase); + if (i === HAND_PHASES.length - 1) this.startNewHand(); + else this.phase = HAND_PHASES[i + 1]; + return null; + } + + togglePause(token) { + if (!this.isHost(token)) return 'ホストのみ操作できます'; + if (!this.started) return 'まだ開始していません'; + const now = Date.now(); + if (this.paused) { + this.paused = false; + if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs; + } else { + this.paused = true; + if (this.timerEnabled) { + this.levelRemainingMs = Math.max(0, this.levelEndsAt - now); + this.levelEndsAt = null; + } + } + return null; + } + + // ブラインドタイマー。レベルが上がったら true + tick(now) { + let changed = false; + while (this.levelEndsAt != null && now >= this.levelEndsAt) { + this.level++; + this.levelEndsAt += this.intervalMs; + changed = true; + } + return changed; + } + + summary() { + const { sb, bb } = this.blinds; + return { + id: this.id, + name: this.name, + playerCount: this.playerCount, + maxSeats: this.maxSeats, + startingStack: this.startingStack, + smallBlind: sb, + bigBlind: bb, + level: this.level, + phase: this.phase, + paused: this.paused, + timerEnabled: this.timerEnabled, + intervalMin: this.intervalMin, + createdAt: this.createdAt, + }; + } + + // token = 閲覧者のトークン(他人のトークンは送らない) + publicState(token, connectedTokens) { + const { sb, bb } = this.blinds; + const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1); + const host = this.seatOf(this.hostToken); + return { + id: this.id, + name: this.name, + startingStack: this.startingStack, + maxSeats: this.maxSeats, + level: this.level, + smallBlind: sb, + bigBlind: bb, + nextBlinds: { smallBlind: next.sb, bigBlind: next.bb }, + timer: { + enabled: this.timerEnabled, + intervalMin: this.intervalMin, + endsAt: this.levelEndsAt, + remainingMs: this.levelEndsAt != null ? this.levelEndsAt - Date.now() : this.levelRemainingMs, + running: this.levelEndsAt != null, + }, + phase: this.phase, + started: this.started, + paused: this.paused, + pot: this.pot, + handNumber: this.handNumber, + buttonSeat: this.phase === 'WAITING' ? null : this.buttonSeat, + ...this.blindSeats(), + hostSeat: host >= 0 ? host : null, + seats: this.seats.map((s, i) => + s ? { seat: i, name: s.name, stack: s.stack, connected: connectedTokens.has(s.token) } : null + ), + mySeat: this.seatOf(token), + isHost: this.isHost(token), + serverNow: Date.now(), + }; + } +} + +class TableManager { + constructor() { + this.tables = new Map(); + } + + get(id) { + return this.tables.get(id); + } + + list() { + return [...this.tables.values()] + .sort((a, b) => b.createdAt - a.createdAt) + .map((t) => t.summary()); + } + + nameTaken(name) { + const n = name.toLowerCase(); + return [...this.tables.values()].some((t) => t.name.toLowerCase() === n); + } + + suggestName() { + for (;;) { + const name = `Table #${1000 + crypto.randomInt(9000)}`; + if (!this.nameTaken(name)) return name; + } + } + + create(opts) { + let id; + do id = crypto.randomBytes(4).toString('hex'); + while (this.tables.has(id)); + const table = new Table({ ...opts, id }); + this.tables.set(id, table); + return table; + } + + delete(id) { + this.tables.delete(id); + } +} + +module.exports = { TableManager };