Files
Poker/server/index.js
T
agomizi1cmandClaude Opus 5.5 3f2515a9fa Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by
admins. Only the creator and CPUs sit there: friends cannot join, and the
table is hidden from non-admin lobbies. After every hand a review opens,
and the next hand waits until the creator presses "次のハンドへ".

- Preflop: shows which color of the registered hand range the hole cards
  are and whether the action followed it. With no entrant before you,
  open with the color for the number of players still to act; facing an
  entrant, estimate their range from their position, call one color
  above it and raise two above. Purple ranks just below white (SB only).
- Flop onward: equity vs ranges estimated from those colors (narrowed by
  later bets/calls) gives a rough EV per option. The score is the EV lost
  against the best option, relative to the pot. It is a guide, not GTO.
- Also: pot-odds call check, all-in equity and luck-adjusted P&L,
  VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance,
  habits to watch, worst-5 hands, a P&L chart and a step-by-step replay
  on the admin-only /review page.

Hand now logs every action for the review. The range data moves to
admin/range-data.js, shared by the range page and the server. Reviews are
saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are
no longer cached for a day, so updates show up immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00

368 lines
15 KiB
JavaScript

'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, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
const ADMIN = path.join(__dirname, '..', 'admin');
const page = (file, dir = PUBLIC) => (req, res) => res.sendFile(path.join(dir, 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('/favicon.ico', (req, res) => res.sendFile(path.join(PUBLIC, 'assets', 'favicon.ico')));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
app.get('/key', (req, res) => {
// 入室済みなら飛ばす。ただし /key?admin は管理者キーを入れるために開ける
const wantsAdmin = req.query.admin !== undefined && !auth.isAdmin(req);
if (auth.isAuthed(req) && !wantsAdmin) 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('/range', auth.requireAdmin, page('range.html', ADMIN));
app.use(
'/range/files',
auth.requireAdmin,
express.static(ADMIN, {
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
// (スクリプトと CSS は更新がすぐ反映されるよう毎回確認させる)
setHeaders: (res, file) =>
res.setHeader('Cache-Control', file.endsWith('.png') ? 'private, max-age=86400' : 'private, no-cache'),
})
);
// ---------- 管理者専用: レビュー付きCPU戦の振り返り ----------
app.get('/review', auth.requireAdmin, page('review.html', ADMIN));
app.get('/api/reviews', auth.requireAdmin, async (req, res, next) => {
try {
res.json(await reviews.list());
} catch (err) {
next(err);
}
});
app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
if (!session) return res.status(404).json({ error: '見つかりません' });
res.json({ ...session, summary: summarize(session.hands) });
} catch (err) {
next(err);
}
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
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 mode = b.mode;
const cpuCount = b.cpuCount;
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 + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' });
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
// レビュー付きCPU戦は管理者だけが作れる
if (!MODES.includes(mode) || (mode === 'review' && !auth.isAdmin(req))) {
return res.status(400).json({ error: 'テーブルの種類が不正です' });
}
const withBots = mode === 'cpu' || mode === 'review';
if (withBots && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
return res.status(400).json({ error: `CPU の人数は1〜${config.MAX_SEATS - 1}人にしてください` });
}
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
const table = tables.create({
name,
startingStack,
smallBlind,
bigBlind,
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
mode,
cpuCount: withBots ? cpuCount : 0,
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));
}
// レビュー付きCPU戦は管理者のロビーにだけ出す
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list(false));
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
}
function changed(table) {
broadcastTable(table);
broadcastLobby();
}
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
function closeTable(table, leaver) {
tables.delete(table.id);
for (const s of roomSockets(table.id)) {
s.leave(roomOf(table.id));
if (s.data.tableId === table.id) s.data.tableId = null;
if (s !== leaver) s.emit('table:closed');
}
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);
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
// イベントの連打を制限
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(socket.data.admin ? 'lobby:admin' : 'lobby');
socket.emit('lobby:tables', tables.list(socket.data.admin));
});
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' });
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
return reply(ack, { ok: false, error: 'NOT_FOUND' });
}
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);
});
// プレイヤー操作・ホスト操作は同じ形で処理する(操作者は join 時のトークンで決まる)
const ACTION_TYPES = new Set(['fold', 'check', 'call', 'raise']);
const actions = {
'table:leave': (t, token) => (t.leave(token) ? null : '着席していません'),
'table:start': (t, token) => t.start(token),
'table:pause': (t, token) => t.togglePause(token),
'table:action': (t, token, p) => {
if (!p || typeof p !== 'object' || !ACTION_TYPES.has(p.type)) return '不正なアクションです';
const amount = p.type === 'raise' ? p.amount : 0;
if (!Number.isSafeInteger(amount)) return '額が不正です';
return t.act(token, p.type, amount);
},
'table:award': (t, token, p) => {
// winners: ポットごとの勝者の席番号の配列
const w = p && typeof p === 'object' ? p.winners : null;
const ok =
Array.isArray(w) &&
w.length <= config.MAX_SEATS &&
w.every((a) => Array.isArray(a) && a.length <= config.MAX_SEATS && a.every((s) => Number.isInteger(s)));
return ok ? t.award(token, w) : '勝者の指定が不正です';
},
'table:sitOut': (t, token) => t.setSittingOut(token, true),
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(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, p);
reply(ack, error ? { ok: false, error } : { ok: true });
if (error) return;
if (event === 'table:leave' && table.shouldClose()) return closeTable(table, socket);
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);
}
}, 250);
// 誰も見ていないテーブルを片付ける
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 のリバースプロキシ経由での公開を推奨します。');
}
});