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

+84 -2
View File
@@ -7,6 +7,8 @@ const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
const { handStats } = require('./stats/hand');
const stats = require('./stats/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
@@ -37,6 +39,7 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
@@ -180,6 +183,7 @@ class Table {
startNewHand() {
this.removeLeftSeats();
this.applySeating();
// チップがなくなった CPU は自動でリバイ
for (const s of this.seats) {
if (s && s.bot && s.stack === 0) {
@@ -259,6 +263,45 @@ class Table {
// ---- ホスト操作 ----
// 席順を変える。order = 着席している全員の席番号を時計回りに並べたもの。
// 進行中(結果の表示中を含む)のハンドは席番号で参加者を覚えているので、そのときは次のハンドの始めに並べ替える
setSeating(token, order) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
const current = this.seats.map((s, i) => (s && !s.left ? i : -1)).filter((i) => i >= 0);
const valid =
Array.isArray(order) &&
order.length === current.length &&
new Set(order).size === order.length &&
order.every((i) => current.includes(i));
if (!valid) return '着席している人が変わりました。もう一度並べてください';
this.pendingSeating = order.map((i) => this.seats[i].token);
if (!this.hand) this.applySeating();
return null;
}
// 決めてある席順に並べ替える。席は詰めて 0 番から座らせる(空席は画面に出ないので、並びだけが意味を持つ)。
// 決めた後に座った人は最後(ホストの並びの後ろ)に、元の順のまま加える。ボタンは同じ人に付いたまま
applySeating() {
const tokens = this.pendingSeating;
if (!tokens) return;
this.pendingSeating = null;
const rank = (s) => {
const k = tokens.indexOf(s.token);
return k < 0 ? tokens.length : k;
};
const button = this.buttonSeat != null && this.seats[this.buttonSeat] ? this.seats[this.buttonSeat].token : null;
const players = this.seats
.map((s, k) => ({ s, k }))
.filter((x) => x.s)
.sort((a, b) => rank(a.s) - rank(b.s) || a.k - b.k)
.map((x) => x.s);
this.seats = new Array(this.maxSeats).fill(null);
players.forEach((s, i) => {
this.seats[i] = s;
});
this.buttonSeat = button ? this.seatOf(button) : null;
}
start(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ開始できます';
if (this.started) return 'すでに開始しています';
@@ -340,6 +383,7 @@ class Table {
}
}
if (this.hand && this.hand.finished && !this.hand.statsRecorded) this.recordStats();
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
@@ -356,6 +400,7 @@ class Table {
this.hand = null;
this.nextHandAt = null;
this.removeLeftSeats();
this.applySeating();
}
changed = true;
}
@@ -370,6 +415,25 @@ class Table {
return changed;
}
// ---- 統計 ----
// 終わったハンドを、参加した人間のプレイヤーごとの統計に足す(CPU は数えない)。
// レビュー付きCPU戦は CPU戦としてまとめる
recordStats() {
const h = this.hand;
h.statsRecorded = true;
const mode = this.mode === 'review' ? 'cpu' : this.mode;
for (const s of h.order) {
const seat = this.seats[s];
if (!seat || seat.bot) continue;
try {
stats.record(seat.token, mode, handStats(h, s), { startedAt: h.startedAt, endedAt: h.endedAt || Date.now() });
} catch (err) {
console.error('[stats] 統計を数えられませんでした', err);
}
}
}
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
@@ -457,6 +521,18 @@ class Table {
};
}
inLiveHand(seat) {
return Boolean(this.handActive && this.hand.players.has(seat) && !this.hand.players.get(seat).folded);
}
// ランキング用: 持っているチップ(進行中のハンドで出した額は、降りていなければまだ本人の分として含める)と
// 収支(それ − バイインの合計)
standing(seat) {
const s = this.seats[seat];
const held = s.stack + (this.inLiveHand(seat) ? this.hand.players.get(seat).committed : 0);
return { held, net: held - s.buyIns * this.startingStack };
}
// token = 閲覧者のトークン(他人のトークンや手札は送らない)
publicState(token, connectedTokens) {
const now = Date.now();
@@ -466,7 +542,7 @@ class Table {
const h = this.hand;
const hv = h ? h.view(mySeat, token) : null;
const me = mySeat >= 0 ? this.seats[mySeat] : null;
const inLiveHand = Boolean(this.handActive && h.players.has(mySeat) && !h.players.get(mySeat).folded);
const inLiveHand = this.inLiveHand(mySeat);
return {
id: this.id,
@@ -498,6 +574,7 @@ class Table {
seat: i,
name: s.name,
stack: s.stack,
...this.standing(i),
connected: Boolean(s.bot) || connectedTokens.has(s.token),
bot: Boolean(s.bot),
sittingOut: s.sittingOut,
@@ -510,7 +587,7 @@ class Table {
me && !me.left
? {
stack: me.stack,
net: me.stack + (inLiveHand ? h.players.get(mySeat).committed : 0) - me.buyIns * this.startingStack,
net: this.standing(mySeat).net,
buyIns: me.buyIns,
sittingOut: me.sittingOut,
canRebuy: me.stack === 0 && !inLiveHand,
@@ -519,6 +596,11 @@ class Table {
}
: null,
isHost: this.isHost(token),
// ホストだけに: 次のハンドから使う席順(席番号を時計回りに)
pendingSeating:
this.isHost(token) && this.pendingSeating
? this.pendingSeating.map((t) => this.seatOf(t)).filter((i) => i >= 0 && !this.seats[i].left)
: null,
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};