Add player stats, in-table ranking, host seating and review return

- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR,
  ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression
  frequency, hand win rate and net in BB, per table type, each with a
  bands-and-explanation sheet. Hands are counted per human player when
  they finish and stored as totals under data/stats/, keyed by a hash
  of the player ID (sent in a POST body, never in the URL).
- Ranking sheet at the table: everyone seated, ordered by stack or by
  net (stack minus buy-ins), updating live.
- The host can reorder seats by dragging (with a mini table preview and
  shuffle). Takes effect immediately between hands, otherwise at the
  start of the next hand; the dealer button stays with the same player.
- The full-session review opens in the same tab and its back button
  returns to the table it was opened from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-22 16:04:33 +09:00
1 parent 49203f202c
commit dd39c043c4
18 files changed
+2699 -18

No files matched your search

+20
View File
@@ -10,6 +10,8 @@ const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const { buildHandPrompt } = require('./review/prompt');
const stats = require('./stats/store');
const { summarize: summarizeStats } = require('./stats/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -89,6 +91,7 @@ app.use(auth.requireSameOrigin);
app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
app.get('/stats', page('stats.html'));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
@@ -136,6 +139,17 @@ app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req,
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
// 自分の統計。プレイヤーID(席のトークン)を URL やアクセスログに残さないよう POST の本文で受け取る
app.post('/api/stats', async (req, res, next) => {
const token = cleanToken(req.body && req.body.token);
if (!token) return res.status(400).json({ error: 'プレイヤーIDがありません' });
try {
res.json(summarizeStats(await stats.get(token)));
} catch (err) {
next(err);
}
});
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() }));
@@ -335,6 +349,12 @@ io.on('connection', (socket) => {
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(token),
'table:seating': (t, token, p) => {
// order: 時計回りに並べた席番号
const order = p && typeof p === 'object' ? p.order : null;
const ok = Array.isArray(order) && order.length <= config.MAX_SEATS && order.every((i) => Number.isInteger(i));
return ok ? t.setSeating(token, order) : '席順の指定が不正です';
},
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {