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 <noreply@anthropic.com>
This commit is contained in:
commit
b2c91062f1
22 files changed
+3450
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "poker",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["server/index.js"],
|
||||||
|
"port": 4671
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# .env にコピーして値を設定する(chmod 600 推奨)
|
||||||
|
ACCESS_KEY=000000
|
||||||
|
PORT=4671
|
||||||
|
# HOST=127.0.0.1
|
||||||
|
# 本番のみ設定(ローカル開発では設定しない)
|
||||||
|
# PUBLIC_ORIGIN=https://poker.kanawo.net
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Generated
+1083
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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 ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
|
||||||
|
} 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) => `<span>Lv.${l.level} <b>${chips(l.sb)}/${chips(l.bb)}</b>${mins ? ` · ${i * mins}分〜` : ''}</span>`)
|
||||||
|
.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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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 '<span class="badge paused">PAUSED</span>';
|
||||||
|
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
|
||||||
|
return '<span class="badge live">PLAYING</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(tables) {
|
||||||
|
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
||||||
|
if (!tables.length) {
|
||||||
|
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = tables
|
||||||
|
.map((t) => {
|
||||||
|
const full = t.playerCount >= t.maxSeats;
|
||||||
|
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
|
||||||
|
return `
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
|
||||||
|
<span>開始 <b>${chips(t.startingStack)}</b></span>
|
||||||
|
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
|
||||||
|
<span>${timer}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||||
|
${full ? '観戦' : '参加'}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = App.connectSocket();
|
||||||
|
socket.on('connect', () => socket.emit('lobby:subscribe'));
|
||||||
|
socket.on('lobby:tables', render);
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
@@ -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 =
|
||||||
|
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></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 ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
|
||||||
|
p.seat === s.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
|
||||||
|
p.seat === s.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
|
||||||
|
].join('');
|
||||||
|
return `
|
||||||
|
<div class="${classes.join(' ')}">
|
||||||
|
<div class="avatar">${AVATAR_SVG}${markers}</div>
|
||||||
|
<div class="nameplate">
|
||||||
|
<div class="pname">${p.seat === s.hostSeat ? '<span title="ホスト">👑</span>' : ''}${esc(p.name)}</div>
|
||||||
|
<div class="pstack">${chips(p.stack)}</div>
|
||||||
|
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#24493b">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>テーブル作成 - Hold'em Night</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="icon-btn" href="/" aria-label="戻る">←</a>
|
||||||
|
<h1>テーブルを作成</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="form" novalidate>
|
||||||
|
<label for="table-name">テーブル名</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="table-name" type="text" maxlength="24" required>
|
||||||
|
<button type="button" class="icon-btn" id="reroll" aria-label="別の名前にする">🎲</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="stack">開始スタック</label>
|
||||||
|
<input id="stack" type="number" inputmode="numeric" min="10" step="1" value="1000">
|
||||||
|
|
||||||
|
<div class="label">開始ブラインド(SB / BB)</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="sb" type="number" inputmode="numeric" min="1" step="1" aria-label="スモールブラインド">
|
||||||
|
<span class="slash">/</span>
|
||||||
|
<input id="bb" type="number" inputmode="numeric" min="1" step="1" aria-label="ビッグブラインド">
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
|
||||||
|
|
||||||
|
<div class="switch-row">
|
||||||
|
<span>ブラインドタイマー</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input id="timer" type="checkbox" aria-label="ブラインドタイマー">
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="interval">インターバル</label>
|
||||||
|
<div class="unit-input">
|
||||||
|
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
|
||||||
|
<span class="unit">min</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule" id="schedule" hidden></div>
|
||||||
|
|
||||||
|
<div class="error-text" id="error"></div>
|
||||||
|
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">テーブルを作成</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/blinds.js"></script>
|
||||||
|
<script src="/assets/create.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#24493b">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Hold'em Night</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
||||||
|
|
||||||
|
<form id="form" class="card" autocomplete="off">
|
||||||
|
<h2 class="key-title">参加キーを入力</h2>
|
||||||
|
<p class="hint key-hint">オーナーから教えてもらった6桁の数字</p>
|
||||||
|
<div class="key-inputs" id="digits">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="2桁目">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="3桁目">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="4桁目">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="5桁目">
|
||||||
|
<input type="tel" inputmode="numeric" aria-label="6桁目">
|
||||||
|
</div>
|
||||||
|
<div class="error-text text-center" id="error"></div>
|
||||||
|
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">入室する</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/key.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#24493b">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Hold'em Night</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
||||||
|
|
||||||
|
<label for="name">あなたの名前</label>
|
||||||
|
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
|
||||||
|
<div class="error-text" id="name-error"></div>
|
||||||
|
|
||||||
|
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
|
||||||
|
|
||||||
|
<div class="section-title">
|
||||||
|
<span>開催中のテーブル</span>
|
||||||
|
<span class="sub" id="count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-list" id="list">
|
||||||
|
<div class="empty">読み込み中…</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/lobby.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#24493b">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>テーブル - Hold'em Night</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="table-page">
|
||||||
|
<div class="conn-lost" id="conn-lost" hidden>接続が切れました。再接続中…</div>
|
||||||
|
|
||||||
|
<main class="wrap" id="main" hidden>
|
||||||
|
<div class="table-header">
|
||||||
|
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
|
||||||
|
<h1 id="table-name"></h1>
|
||||||
|
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="host-controls" id="host-controls" hidden>
|
||||||
|
<button class="btn btn-green" id="start">START ▶</button>
|
||||||
|
<button class="btn btn-dark" id="next-phase" title="プレイ未実装のため手動でフェーズを進めます">次へ(仮)</button>
|
||||||
|
<button class="btn btn-dark" id="pause">一時停止</button>
|
||||||
|
</div>
|
||||||
|
<div class="host-spacer" id="host-spacer"></div>
|
||||||
|
|
||||||
|
<div class="level-bar">
|
||||||
|
<span class="lv" id="level"></span>
|
||||||
|
<span class="blinds" id="blinds"></span>
|
||||||
|
<span class="timer" id="timer"></span>
|
||||||
|
</div>
|
||||||
|
<div class="level-sub">
|
||||||
|
<span id="next-blinds"></span>
|
||||||
|
<span id="stack-info"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="felt-area" id="felt-area">
|
||||||
|
<div class="felt">
|
||||||
|
<div class="pot-label">POT</div>
|
||||||
|
<div class="pot" id="pot">$0</div>
|
||||||
|
<div class="phase" id="phase">WAITING</div>
|
||||||
|
<div class="hand-no" id="hand-no"></div>
|
||||||
|
</div>
|
||||||
|
<div id="seats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spectator-note" id="spectator-note" hidden>満席のため観戦中です</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="action-bar" id="action-bar" hidden>
|
||||||
|
<div class="inner">
|
||||||
|
<div class="my-stack"><span class="wallet">👛</span><span id="my-stack">$0</span></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" disabled>Fold</button>
|
||||||
|
<button class="btn" disabled>Check</button>
|
||||||
|
<button class="btn" disabled>Raise</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="center-message" id="not-found" hidden>
|
||||||
|
<h2>テーブルが見つかりません</h2>
|
||||||
|
<p>終了したか、URLが間違っている可能性があります。</p>
|
||||||
|
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="name-modal" hidden>
|
||||||
|
<form class="modal" id="name-form">
|
||||||
|
<h2>名前を入力</h2>
|
||||||
|
<p class="hint">テーブルで表示される名前です</p>
|
||||||
|
<input id="name-input" type="text" maxlength="12" placeholder="例: とらのすけ" class="mt-12" autocomplete="nickname">
|
||||||
|
<button class="btn btn-orange btn-block mt-12" type="submit">参加する</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/table.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+214
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+274
@@ -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 のリバースプロキシ経由での公開を推奨します。');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
Reference in new issue
Block a user