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

+368 -1
View File
@@ -30,6 +30,8 @@
let reviewKey = '';
const reviewSheet = Fluid.Sheet($('review-modal'));
const rankingSheet = Fluid.Sheet($('ranking-modal'));
const seatingSheet = Fluid.Sheet($('seating-modal'));
const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
// 払って閉じた・背景を押した・Esc のときも「戻る」と同じ
const betSheet = Fluid.Sheet($('bet-modal'), {
@@ -117,6 +119,8 @@
$('main').hidden = true;
$('action-bar').hidden = true;
reviewSheet.close();
rankingSheet.close();
seatingSheet.close();
raising = false;
betSheet.close();
$('not-found').hidden = false;
@@ -244,6 +248,10 @@
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
const seated = s.seats.filter(Boolean).length;
$('seating-open').disabled = seated < 3; // 2人までは並びが1通り
$('seating-open').title = seated < 3 ? '3人以上で席順を変えられます' : '席順を変更';
$('seating-dot').hidden = !s.pendingSeating;
// 自分
$('action-bar').hidden = !me;
@@ -259,6 +267,8 @@
if (me) renderActionBar(s, me);
else betSheet.close();
renderReview(s);
if (rankingSheet.isOpen) renderRanking(s);
if (seatingSheet.isOpen) syncSeating(s);
renderTimer();
// 自分の番になったら知らせる
@@ -579,7 +589,9 @@
reviewSheet.close();
return;
}
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
// 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブルIDを渡す
const back = `t=${encodeURIComponent(tableId)}`;
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}&${back}` : `/review?${back}`;
$('review-modal-next').hidden = !r.waiting;
const key = JSON.stringify(r);
if (key !== reviewKey) {
@@ -616,6 +628,361 @@
$('review-next').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand);
// ---------- ランキング(スタック順・収支順を切り替える) ----------
// スタックはまだ降りていない人がこのハンドで出した額を含む(ベットのたびに順位が揺れないように)。
// 収支は持っているチップ − バイインの合計(リバイを含む)。同じ値は同じ順位
const RANK_STORE = 'poker.rankBy';
let rankBy = 'stack';
try {
if (localStorage.getItem(RANK_STORE) === 'net') rankBy = 'net';
} catch { /* プライベートモード等 */ }
const rankRows = new Map(); // 席番号 → 行。並びが変わっても行は使い回し、元の位置から滑らせる
const RANK_NOTE = {
stack: 'ハンド中は、まだ降りていない人がこのハンドで出したチップも含めています。',
net: '収支 = 持っているチップ − バイインの合計(リバイを含む)',
};
function rankingEntries(s) {
const key = rankBy === 'net' ? 'net' : 'held';
const list = s.seats.filter(Boolean).sort((a, b) => b[key] - a[key] || a.seat - b.seat);
let rank = 0;
return list.map((p, i) => {
if (i === 0 || p[key] !== list[i - 1][key]) rank = i + 1;
return { p, rank, value: p[key] };
});
}
// 並べ替えられる行(縦の位置をばねで動かす)
function movableRow(className) {
const el = document.createElement('li');
el.className = className;
el.y = new Fluid.Spring(0, { precision: 0.3 });
el.y.onUpdate = (v) => {
el.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
};
return el;
}
const rowTops = (rows) => new Map([...rows].map((el) => [el, el.getBoundingClientRect().top]));
// 並べ替えた行を、画面上の元の位置(before: 行 → 並べ替える前の top)からばねで新しい位置へ
function slideFrom(before) {
for (const [el, top] of before) {
if (!el.isConnected) continue;
const offset = top - (el.getBoundingClientRect().top - el.y.value);
if (Math.abs(offset - el.y.value) < 0.5) continue;
el.y.set(offset);
el.y.to(0, { damping: 0.86, response: 0.42 });
}
}
function rankRow(seat) {
let el = rankRows.get(seat);
if (el) return el;
el = movableRow('cell rank-row');
rankRows.set(seat, el);
return el;
}
function renderRanking(s) {
const listEl = $('ranking-list');
const empty = listEl.querySelector('.empty');
if (empty) empty.remove();
const before = rowTops(rankRows.values());
const live = new Set();
rankingEntries(s).forEach(({ p, rank, value }, i) => {
live.add(p.seat);
const el = rankRow(p.seat);
const sub = [p.seat === s.mySeat && 'あなた', p.bot && 'CPU', p.sittingOut && '離席中', !p.connected && 'オフライン']
.filter(Boolean)
.join(' ・ ');
const net = rankBy === 'net';
patch(
el,
`<span class="rank-no${rank <= 3 ? ` top-${rank}` : ''}">${rank}</span>` +
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}${sub ? `<span class="cell-sub">${sub}</span>` : ''}</span>` +
`<span class="rank-value${net ? (value > 0 ? ' plus' : value < 0 ? ' minus' : '') : ''}">${net ? signed(value) : chips(value)}</span>`
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', p.seat === s.mySeat);
if (listEl.children[i] !== el) listEl.insertBefore(el, listEl.children[i] || null);
});
for (const [seat, el] of rankRows) {
if (live.has(seat)) continue;
el.remove();
rankRows.delete(seat);
}
slideFrom(before); // 順位が変わった行は元の位置から滑らせる
$('ranking-note').textContent = RANK_NOTE[rankBy];
if (!s.seats.some(Boolean)) listEl.innerHTML = '<li class="empty">まだ誰も座っていません</li>';
}
Fluid.Segmented($('ranking-tabs'));
function syncRankTabs() {
for (const b of $('ranking-tabs').querySelectorAll('[data-rank]')) {
const on = b.dataset.rank === rankBy;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', String(on));
}
}
syncRankTabs();
$('ranking-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-rank]');
if (!b || b.dataset.rank === rankBy) return;
rankBy = b.dataset.rank;
try {
localStorage.setItem(RANK_STORE, rankBy);
} catch { /* プライベートモード等 */ }
syncRankTabs();
Fluid.haptic(6);
if (state) renderRanking(state);
});
$('ranking-open').addEventListener('click', () => {
if (!state) return;
renderRanking(state); // 中身を入れてから開く(シートの高さを測るため)
rankingSheet.open();
});
$('ranking-close').addEventListener('click', () => rankingSheet.close());
// ---------- 席順の変更(ホストのみ) ----------
// 席の並びは輪なので、自分を一番上に固定し、その下に左隣から時計回りの順で並べる(テーブルの画面と同じ向き)。
// 行は右端のつまみをつかむと指に付いてきて、ほかの行がよける。キーボードではつまみにフォーカスして ↑↓。
// ハンド中に決めた席順は次のハンドの始めにサーバが並べ替える
const HANDLE_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h14M5 12h14M5 16h14"/></svg>';
let seatMe = -1; // 自分の席(観戦中のホストは -1)
let seatOrder = []; // 自分以外の席番号(左隣から時計回り)
let seatNames = new Map(); // 席番号 → 名前(開いている間に席番号が振り直されたか確かめる)
let seatDrag = null;
const seatRows = new Map(); // 席番号 → 行
const previewDots = new Map(); // 席番号 → プレビューの点
const occupiedSeats = (s) => s.seats.filter(Boolean).map((p) => p.seat);
const seatingList = () => (seatMe >= 0 ? [seatMe] : []).concat(seatOrder);
// いまの並び(次のハンドからの席順が決めてあればそれ)を、自分から時計回りに読み込む
function loadSeating(s) {
const occupied = occupiedSeats(s);
const pending = s.pendingSeating;
const base = pending
? pending.filter((i) => occupied.includes(i)).concat(occupied.filter((i) => !pending.includes(i)))
: occupied;
const k = base.indexOf(s.mySeat);
seatMe = k >= 0 ? s.mySeat : -1;
seatOrder = k >= 0 ? base.slice(k + 1).concat(base.slice(0, k)) : base;
seatNames = new Map(occupied.map((i) => [i, s.seats[i].name]));
}
// 開いている間に座った人は最後に加え、いなくなった人は外す(並べた順は崩さない)。
// 次のハンドで席番号が振り直されたら読み込み直す
function syncSeating(s) {
if (!s.isHost) return seatingSheet.close();
if (seatDrag) return;
const occupied = occupiedSeats(s);
const renumbered =
(seatMe >= 0 && s.mySeat !== seatMe) || [...seatNames].some(([i, name]) => s.seats[i] && s.seats[i].name !== name);
if (renumbered) {
loadSeating(s);
toast('席が並べ替わったので、いまの席順を読み込み直しました');
} else {
const kept = seatOrder.filter((i) => occupied.includes(i));
const added = occupied.filter((i) => i !== seatMe && !kept.includes(i));
seatOrder = kept.concat(added);
for (const i of added) seatNames.set(i, s.seats[i].name);
}
renderSeating(rowTops(seatRows.values()));
}
function seatRow(seat) {
let el = seatRows.get(seat);
if (el) return el;
el = movableRow('cell seat-row');
el.dataset.seat = seat;
seatRows.set(seat, el);
return el;
}
function whereText(seat) {
if (seat === seatMe) return 'あなた';
const k = seatOrder.indexOf(seat);
const n = seatOrder.length;
if (seatMe < 0) return `${k + 1}番目`;
if (n === 1) return 'あなたの隣';
if (k === 0) return 'あなたの左隣';
if (k === n - 1) return 'あなたの右隣';
return `あなたから時計回りに${k + 1}人目`;
}
// before: 並べ替える前の行の位置(渡すと元の位置から滑らせる)
function renderSeating(before) {
const s = state;
const list = $('seating-list');
const seats = seatingList().filter((i) => s.seats[i]);
seats.forEach((seat, i) => {
const p = s.seats[seat];
const mine = seat === seatMe;
const sub = [whereText(seat), p.bot && 'CPU', p.sittingOut && '離席中'].filter(Boolean).join(' ・ ');
const el = seatRow(seat);
patch(
el,
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}<span class="cell-sub">${sub}</span></span>` +
(mine
? '<span class="seat-pin">固定</span>'
: `<button type="button" class="seat-handle" aria-label="${esc(p.name)}の席を移動(↑↓キーで並べ替え)">${HANDLE_SVG}</button>`)
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', mine);
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
for (const [seat, el] of seatRows) {
if (seats.includes(seat)) continue;
el.remove();
seatRows.delete(seat);
}
if (before) slideFrom(before);
const note = $('seating-pending');
note.hidden = !s.hand && !s.pendingSeating;
note.textContent = s.hand
? 'ハンドの途中なので、次のハンドの始めに並べ替えます。'
: 'まだ反映していない席順があります。';
renderSeatingPreview(seats);
}
// 小さなテーブル: 自分が一番下で時計回り(テーブルの画面と同じ)。点は新しい位置へ滑る
function renderSeatingPreview(seats) {
const box = $('seating-preview');
const n = seats.length;
seats.forEach((seat, idx) => {
const p = state.seats[seat];
let dot = previewDots.get(seat);
if (!dot) {
dot = document.createElement('span');
dot.className = 'seating-dot';
dot.innerHTML = '<span class="rank-avatar"></span><span class="seating-name"></span>';
previewDots.set(seat, dot);
box.append(dot);
}
const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
dot.style.left = `${(50 + 40 * Math.cos(angle)).toFixed(2)}%`;
dot.style.top = `${(50 + 36 * Math.sin(angle)).toFixed(2)}%`;
dot.style.setProperty('--hue', hueOf(p.name));
dot.classList.toggle('me', seat === seatMe);
const face = dot.firstChild;
face.classList.toggle('bot', p.bot);
patch(face, p.bot ? BOT_SVG : esc(initialOf(p.name)));
dot.lastChild.textContent = seat === seatMe ? 'あなた' : p.name;
});
for (const [seat, dot] of previewDots) {
if (seats.includes(seat)) continue;
dot.remove();
previewDots.delete(seat);
}
}
function moveSeat(from, to) {
const before = rowTops(seatRows.values());
if (from !== to) {
const [seat] = seatOrder.splice(from, 1);
seatOrder.splice(to, 0, seat);
}
renderSeating(before);
}
const seatingListEl = $('seating-list');
seatingListEl.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.seat-handle');
if (!handle || seatDrag || (e.pointerType === 'mouse' && e.button !== 0)) return;
const row = handle.closest('[data-seat]');
const seat = Number(row.dataset.seat);
e.preventDefault();
handle.setPointerCapture(e.pointerId);
row.y.stop();
const from = seatOrder.indexOf(seat);
seatDrag = { id: e.pointerId, seat, row, from, to: from, y0: e.clientY - row.y.value, h: row.offsetHeight };
row.classList.add('dragging');
Fluid.haptic(8);
});
seatingListEl.addEventListener('pointermove', (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
const n = seatOrder.length;
// 端より先へはゴムのように抵抗する
const min = -d.from * d.h;
const max = (n - 1 - d.from) * d.h;
let dy = e.clientY - d.y0;
if (dy < min) dy = min - Fluid.rubberband(min - dy, d.h * 2);
else if (dy > max) dy = max + Fluid.rubberband(dy - max, d.h * 2);
d.row.y.set(dy);
const to = Fluid.clamp(Math.round(d.from + dy / d.h), 0, n - 1);
if (to !== d.to) {
d.to = to;
Fluid.haptic(6);
}
// 間の行は1行ぶんよける
seatOrder.forEach((seat, j) => {
if (seat === d.seat) return;
const shift = d.from < j && j <= d.to ? -d.h : d.to <= j && j < d.from ? d.h : 0;
const el = seatRows.get(seat);
if (el && el.y.target !== shift) el.y.to(shift, { damping: 1, response: 0.28 });
});
});
const dropSeat = (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
seatDrag = null;
d.row.classList.remove('dragging');
moveSeat(d.from, e.type === 'pointercancel' ? d.from : d.to);
};
seatingListEl.addEventListener('pointerup', dropSeat);
seatingListEl.addEventListener('pointercancel', dropSeat);
seatingListEl.addEventListener('keydown', (e) => {
const handle = e.target.closest('.seat-handle');
const dir = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
if (!handle || !dir) return;
e.preventDefault();
const seat = Number(handle.closest('[data-seat]').dataset.seat);
const from = seatOrder.indexOf(seat);
if (from + dir < 0 || from + dir >= seatOrder.length) return;
moveSeat(from, from + dir);
const again = seatRows.get(seat).querySelector('.seat-handle');
if (again) again.focus();
});
$('seating-shuffle').addEventListener('click', () => {
const before = rowTops(seatRows.values());
const r = new Uint32Array(seatOrder.length);
crypto.getRandomValues(r);
for (let i = seatOrder.length - 1; i > 0; i--) {
const j = r[i] % (i + 1);
[seatOrder[i], seatOrder[j]] = [seatOrder[j], seatOrder[i]];
}
Fluid.haptic(10);
renderSeating(before);
});
$('seating-open').addEventListener('click', () => {
if (!state || !state.isHost) return;
loadSeating(state);
renderSeating(null); // 中身を入れてから開く(シートの高さを測るため)
seatingSheet.open();
});
$('seating-cancel').addEventListener('click', () => seatingSheet.close());
$('seating-save').addEventListener('click', () => {
if (!socket || !state) return;
const later = Boolean(state.hand); // ハンドがある間(結果の表示中を含む)は次のハンドから
const btn = $('seating-save');
btn.disabled = true;
socket.emit('table:seating', { order: seatingList() }, (res) => {
btn.disabled = false;
if (!res || !res.ok) {
toast((res && res.error) || '席順を変更できませんでした');
if (state) syncSeating(state);
return;
}
seatingSheet.close();
toast(later ? '次のハンドから新しい席順になります' : '席順を変更しました');
});
});
// ---------- 勝者選択 ----------
function renderAwardPanel(pots) {
const key = `${state.handNumber}:${JSON.stringify(pots)}`;