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:
agomizi1cmandClaude Opus 5.5 committed 2026-10-15 16:51:15 +09:00
commit b2c91062f1
22 files changed
+3450

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "poker",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 4671
}
]
}
+6
View File
@@ -0,0 +1,6 @@
# .env にコピーして値を設定する(chmod 600 推奨)
ACCESS_KEY=000000
PORT=4671
# HOST=127.0.0.1
# 本番のみ設定(ローカル開発では設定しない)
# PUBLIC_ORIGIN=https://poker.kanawo.net
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/
.env
+140
View File
@@ -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
```
+1083
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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"
}
}
+57
View File
@@ -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 };
});
+99
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
})();
+143
View File
@@ -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();
})();
+82
View File
@@ -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();
})();
+72
View File
@@ -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);
})();
+514
View File
@@ -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; }
+187
View File
@@ -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();
})();
+60
View File
@@ -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>
+33
View File
@@ -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>
+34
View File
@@ -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>
+81
View File
@@ -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
View File
@@ -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,
};
+67
View File
@@ -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
View File
@@ -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 のリバースプロキシ経由での公開を推奨します。');
}
});
+271
View File
@@ -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 };