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

+14 -1
View File
@@ -35,6 +35,7 @@
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
- テーブル上部のトロフィーのボタンで、着席している全員のランキングを開ける。「スタック」(持っているチップ。ハンド中は、まだ降りていない人がそのハンドで出した額も含める)と「収支」(持っているチップ − バイインの合計。リバイを含む)を切り替えられる。同じ値は同じ順位。
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。
- チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。
- ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。
@@ -62,6 +63,15 @@ http://localhost:4671 を開く。
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 |
## 統計
ロビーの「統計」(`/stats`)で、自分の成績を見られる。ハンドが終わるたびに、参加した人間のプレイヤーごと(ブラウザのプレイヤーIDごと。CPU は数えない)に自動で記録する。
- **まとめ**: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブのレーダーと、TAG・LAG・ロック・フィッシュのどれか)、VPIP・PFR・AFq・プレイ時間。VPIP 25% 以上をルーズ、PFR が VPIP の半分以上をアグレッシブとして判定する。レーダーを押すと4つのスタイルの説明を横に送って見られる。
- **すべての統計**: 合計・キャッシュ(キャッシュオンリー)・友人戦・CPU戦(レビュー付きCPU戦を含む)ごとに、VPIP・PFR・ATS(全員が降りて CO・BTN・SB に回ってきたときのレイズ率)・3BET・WTSD・W$SD・WWSF・ストリートごとのフォールド率・アグレッション頻度(全部の行動のうちベット・レイズの割合)・ハンドの勝率・収支(BB と 100ハンドあたり)。各数字の「?」で段階(ニット〜フィッシュなど)と説明を開き、「友達に共有」で文章を共有・コピーできる。
- 離席・退席で自動で行った行動は本人の判断として数えない。VPIP・PFR の分母は、プリフロップで判断したハンド(全員が降りて BB に回ってきたハンドなどは除く)。
- 保存先は `data/stats/`(git 管理外)。ファイル名はプレイヤーIDの SHA-256 で、中身は回数の合計だけ(ハンドの内容は残さない)。プレイヤーIDは URL に載せないよう POST の本文で送る。
## 管理者(ハンドレンジ学習・レビュー付きCPU戦)
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ。レンジ表には CO のオープンを BTN で受けたときの対応も枠で示す。3bet は水色、コールは黄)と「CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーのテーブルの行を左へスワイプすると「削除」が出て(キーボードでは Tab で削除ボタンに移ると開く)、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
@@ -83,6 +93,7 @@ http://localhost:4671 を開く。
- **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。
- **AIに質問する用にコピー**: テーブルのレビューとリプレイ画面のボタンで、そのハンドの状況(ゲームの条件・席とスタック・自分の手札・ストリートごとの場札とポット・全員のアクション・ショーダウンで公開された手札・結果・サイトの判定・質問文)を、ChatGPT や Claude などに貼り付けてそのまま質問できる文章にしてコピーする。判断の時点で見えていた情報だけを書き、公開されなかった相手の手札は含めない(`server/review/prompt.js`)。自動でコピーできないブラウザでは文章を表示するので手でコピーする。
- レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。
- テーブルのレビューの「対局全体のレビュー」は同じタブで開き、左上の「テーブル」でそのテーブルに戻れる(リプレイを開いても引き継ぐ。URL の `t=テーブルID`)。
- 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。
- 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。
@@ -112,8 +123,9 @@ http://localhost:4671 を開く。
| `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) |
| `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し |
| `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `prompt` AI に質問する文章 / `store` 保存) |
| `server/stats/*.js` | プレイヤーごとの統計(`hand` 1ハンドから数える / `store` 保存 / `summary` 割合にまとめる) |
| `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) |
| `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) |
| `test/` | チップ処理・役の判定・CPU・レビュー・統計のテスト(`npm test`) |
| `server/config.js` | `.env` の読み込みと各種上限値 |
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
| `public/*.html`, `public/assets/*.js` | 各画面 |
@@ -123,6 +135,7 @@ http://localhost:4671 を開く。
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
- ホストはテーブル名の右の「席順を変更」(↑↓のボタン、3人以上のとき)で、着席者の並びを変えられる。自分を一番上に固定し、その下に左隣から時計回りの順で並び、行の右端のつまみをドラッグ(キーボードでは ↑↓)して並べ替える。小さなテーブルの図で仕上がりを確かめられ、「シャッフル」でランダムにもできる。ハンドの途中(結果の表示中を含む)に決めた席順は次のハンドの始めに反映し(ボタンに印が付く)、決めた後に座った人は並びの最後に入る。ディーラーボタンは同じ人に付いたまま、次のハンドで新しい並びの次の人へ回る。
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
+22 -10
View File
@@ -40,14 +40,26 @@
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号) ----------
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号&t=テーブルID) ----------
function show(view) {
for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view;
window.scrollTo(0, 0);
}
// テーブルのレビューから開いたときのテーブルID。どの画面に移っても引き継ぎ、対局の画面からテーブルへ戻れるようにする
function fromTable() {
const t = new URLSearchParams(location.search).get('t');
return t && /^[0-9a-f]{8}$/.test(t) ? t : null;
}
function setBack(href, label) {
$('back').href = href;
$('back-label').textContent = label;
}
function go(params, push = true) {
const q = new URLSearchParams(params).toString();
const table = fromTable();
const q = new URLSearchParams(table ? { ...params, t: table } : params).toString();
const url = location.pathname + (q ? `?${q}` : '');
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
@@ -58,10 +70,11 @@
const p = new URLSearchParams(location.search);
const id = p.get('s');
const hand = Number(p.get('h'));
const table = fromTable();
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
$('back').href = '/';
$('back-label').textContent = 'ロビー';
if (table) setBack(`/table/${table}`, 'テーブル');
else setBack('/', 'ロビー');
show('list');
return loadList();
}
@@ -77,22 +90,21 @@
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
$('back').href = `?s=${encodeURIComponent(id)}`;
$('back-label').textContent = '対局';
setBack(`?s=${encodeURIComponent(id)}`, '対局');
return openReplay(h);
}
}
$('back').href = '/review';
$('back-label').textContent = '一覧';
if (table) setBack(`/table/${table}`, 'テーブル');
else setBack('/review', '一覧');
show('session');
renderSession();
}
window.addEventListener('popstate', route);
// 戻るボタンは履歴を積まずに一つ上へ
// 戻るボタンは履歴を積まずに一つ上へ(ロビー・テーブルへはそのままページを移る)
$('back').addEventListener('click', (e) => {
const href = $('back').getAttribute('href');
if (href === '/') return;
if (href === '/' || href.startsWith('/table/')) return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
+612
View File
@@ -0,0 +1,612 @@
// 統計: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブ)と、VPIP・PFR などの数字。
// まとめの画面と、テーブルの種類ごとにすべての数字を出す画面(?v=all)。数字の「?」で段階と説明のシートを開く
(function () {
'use strict';
const { Store, esc, api, toast } = App;
const $ = (id) => document.getElementById(id);
const clamp = Fluid.clamp;
Fluid.navBar($('nav'), $('title'));
let data = null; // { since, modes: { total, chips, friends, cpu } }
let mode = 'total';
// ---------- 段階と色 ----------
// upper: 各段階の上限(整数の %)。最後の段階は上限なし。tone: 段階の色の並び
const STYLE = ['ニット', 'タイト', 'バランス', 'ルーズ', 'フィッシュ'];
const AGGR = ['マウス', 'パッシブ', 'バランス', 'アグレッシブ', 'マニアック'];
const QUALITY = ['低い', '平均', '良い', '高い'];
const QUALITY_TOP = ['低い', '平均', '良い', '優秀'];
const ICON = {
vpip: '✌️',
pfr: '🤞',
ats: '🫳',
threeBet: '☝️',
wtsd: '🤘',
wsd: '🫰',
wwsf: '👌',
fold: '🙅',
afq: '👊',
hands: '✋',
net: '💰',
time: '⏱️',
};
const STATS = {
vpip: {
short: 'VPIP',
title: '自発的な参加率(VPIP)',
upper: [10, 20, 30, 40],
labels: STYLE,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップで自分からチップを入れた(コール・レイズした)ハンドの割合です。BB のチェックは含みません。いちばん基本の数字で、たくさんの手で参加するか(ルーズ)、強い手だけで参加するか(タイト)が分かります。',
},
pfr: {
short: 'PFR',
title: 'プリフロップレイズ(PFR)',
upper: [5, 12, 20, 30],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップでコールではなくレイズしたハンドの割合です。VPIP と組み合わせると打ち方が分かります。よくレイズする人は相手に圧力をかけ、ハンドの主導権を握ります。',
},
ats: {
short: 'ATS',
title: 'スチールの試み(ATS)',
upper: [15, 25, 40, 55],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.atsOpp}回`,
desc: '全員が降りて CO・BTN・SB の自分に回ってきたときに、レイズした割合です。後ろの位置の有利を使ってブラインドを奪いにいけているかが分かります。',
},
threeBet: {
short: '3BET',
title: 'スリーベット(3BET)',
upper: [2, 4, 7, 12],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.threeBetOpp}回`,
desc: 'プリフロップで誰かのレイズに直面したときに、リレイズした割合です。強い手でポットを大きくしたり、ブラフで圧力をかけたりする積極的なプレイで、相手のレイズへの向き合い方が分かります。',
},
wtsd: {
short: 'WTSD',
title: 'ショーダウン到達率(WTSD)',
upper: [20, 24, 28, 32],
labels: STYLE,
tone: 'style',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ショーダウンまで行った割合です。最後まで手を見に行くか、途中で降りるかの傾向が分かります。高すぎるとコールしすぎ、低すぎると降りすぎの目安です。',
},
wsd: {
short: 'W$SD',
title: 'ショーダウン勝率(W$SD)',
upper: [40, 45, 56],
labels: QUALITY,
tone: 'quality',
sample: (m) => `ショーダウン ${m.showdowns}回`,
desc: 'ショーダウンまで行ったハンドのうち、ポットを取った(山分けを含む)割合です。ショーダウンに持っていく手の質が分かります。高いほど、強い手でバリューを取り、弱い手でのショーダウンを避けられています。',
},
wwsf: {
short: 'WWSF',
title: 'フロップ後の勝率(WWSF)',
upper: [37, 42, 47],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ポットを取った割合です。相手を降ろして取った場合もショーダウンで勝った場合も含みます。フロップ以降の上手さを総合的に表す数字です。',
},
afq: {
short: 'AFq',
title: 'アグレッション頻度(AFq)',
upper: [20, 40, 60, 80],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: '自分の行動(フォールド・チェック・コール・ベット・レイズ)のうち、ベット・レイズの割合です。積極的に打つと相手に圧力がかかり、ショーダウンなしで取れるポットが増え、強い手でポットを大きくできます。',
},
hands: {
short: 'ハンド',
title: 'ハンドの勝率',
upper: [8, 14, 20],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `${m.hands}ハンド`,
desc: '配られたハンドのうち、ポットを取った(山分けを含む)割合と、プレイしたハンド数・勝ったハンド数です。プレイの量と結果の移り変わりを追えます。',
},
fold: {
short: 'フォールド率',
title: 'フォールド率',
sample: (m) => `${m.hands}ハンド`,
desc: '各ストリート(プリフロップ・フロップ・ターン・リバー)で自分が判断したハンドのうち、降りた割合です。すぐにあきらめすぎていないか、逆にコールしすぎていないかが分かります。ストリートごとのバランスを見てみましょう。',
},
net: {
short: '収支',
title: '収支(BB)',
sample: (m) => `${m.hands}ハンド`,
desc: 'チップの増減をそのハンドの BB の何倍かに直して合計したものです。ブラインドの大きさが違うテーブルの結果もまとめて比べられます。「100ハンドあたり」は勝ちの速さの目安です(BB/100)。',
},
time: {
short: 'プレイ時間',
title: 'プレイ時間',
sample: (m) => `${m.hands}ハンド`,
desc: 'ハンドを遊んでいた時間の合計です。続けて遊んでいるあいだ(前のハンドから3分以内)は、ハンドの間の待ち時間も含めます。',
},
};
const MODE_LABEL = { total: '合計', chips: 'キャッシュ', friends: '友人戦', cpu: 'CPU戦' };
const STREETS = ['プリフロップ', 'フロップ', 'ターン', 'リバー'];
// 値(%)がどの段階か。端数は四捨五入してから比べる
function bandOf(def, v) {
const r = Math.round(v);
const i = def.upper.findIndex((u) => r <= u);
return i < 0 ? def.upper.length : i;
}
const rangeText = (def, i) => {
const lo = i === 0 ? 0 : def.upper[i - 1] + 1;
return i < def.upper.length ? `${lo}–${def.upper[i]}` : `${lo}+`;
};
const toneClass = (def, i) => `tone-${def.tone}-${i}`;
const pctNum = (v) => (v == null ? '—' : String(Math.round(v)));
const bb = (v) => `${v > 0 ? '+' : v < 0 ? '−' : '±'}${Math.abs(Math.round(v * 10) / 10).toLocaleString('ja-JP')}`;
// 段階のバー(カード用): 今の段階まで塗り、今の段階は中の位置まで塗る
function bandBar(def, v) {
const n = def.upper.length + 1;
if (v == null) return `<div class="st-bar">${'<span class="st-seg"></span>'.repeat(n)}</div>`;
const band = bandOf(def, v);
const segs = [];
for (let i = 0; i < n; i++) {
let fill = 0;
if (i < band) fill = 1;
else if (i === band) {
const lo = i === 0 ? 0 : def.upper[i - 1];
const width = i < def.upper.length ? def.upper[i] - lo : def.upper[i - 1] - (i >= 2 ? def.upper[i - 2] : 0);
fill = v > 0 ? clamp((v - lo) / width, 0.12, 1) : 0;
}
segs.push(`<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${fill.toFixed(3)}"></span></span>`);
}
return `<div class="st-bar">${segs.join('')}</div>`;
}
// 段階の一覧(説明シート用): 名前・バー・範囲。今の段階だけ塗る
function bandScale(def, v) {
const band = v == null ? -1 : bandOf(def, v);
const cols = def.labels
.map(
(label, i) => `
<div class="st-scale-col${i === band ? ' on' : ''}">
<span class="st-scale-label">${label}</span>
<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${i === band ? 1 : 0}"></span></span>
<span class="st-scale-range">${rangeText(def, i)}</span>
</div>`
)
.join('');
return `<div class="st-scale st-scale-${def.labels.length}">${cols}</div>`;
}
// ---------- 図形(SVG) ----------
// 角度は真上を 0 度として時計回り
function arcPath(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.sin((a * Math.PI) / 180), cy - r * Math.cos((a * Math.PI) / 180)];
const [x0, y0] = p(a0);
const [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
// 小さなメーター(フォールド率): 270度の弧
function miniGauge(v) {
const track = arcPath(40, 40, 32, -135, 135);
const value = v ? `<path class="st-gauge-val" d="${arcPath(40, 40, 32, -135, -135 + (270 * clamp(v, 0, 100)) / 100)}"/>` : '';
return `<svg class="st-gauge" viewBox="0 0 80 80" aria-hidden="true"><path class="st-gauge-track" d="${track}"/>${value}</svg>`;
}
// 区切りのある輪(アグレッション頻度・勝率)。値の段階の色で塗る
function ring(v, def) {
const SEGS = 6;
const START = -150;
const SPAN = 300;
const GAP = 7;
const size = SPAN / SEGS;
const tone = v == null ? '' : toneClass(def, bandOf(def, v));
let out = '';
for (let i = 0; i < SEGS; i++) {
const a0 = START + i * size + GAP / 2;
const a1 = START + (i + 1) * size - GAP / 2;
out += `<path class="st-ring-track" d="${arcPath(60, 60, 50, a0, a1)}"/>`;
if (v == null) continue;
const f = clamp(((v / 100) * SPAN - i * size) / size, 0, 1);
if (f > 0) out += `<path class="st-ring-val ${tone}" d="${arcPath(60, 60, 50, a0, a0 + (a1 - a0) * f)}"/>`;
}
return `<svg class="st-ring" viewBox="0 0 120 120" aria-hidden="true">${out}</svg>`;
}
// ---------- プレイスタイル ----------
// ルーズさ = VPIP(25% でちょうど真ん中)、積極性 = PFR ÷ VPIP(半分でちょうど真ん中)
const TYPES = [
{
id: 'TAG',
chip: 'TAG',
emoji: '🦅',
name: 'TAG',
full: 'タイト・アグレッシブ',
desc: '参加するハンドは少ないものの、参加したら強気にベット・レイズする打ち方です。強い手をじっくり待ち、来たら攻めます。いちばん勝ちやすいスタイルの一つとされますが、手の選び方が硬すぎると相手に読まれやすくなります。',
},
{
id: 'LAG',
chip: 'LAG',
emoji: '🐒',
name: 'LAG',
full: 'ルーズ・アグレッシブ',
desc: '広い範囲のハンドで参加し、大きなベットもためらわない打ち方です。何を持っているか読みにくく、良い手でもタイミングの良いブラフでもポットを取れます。大きく勝てる可能性がある一方、結果の振れ幅が大きく、高い技術が必要です。',
},
{
id: 'ROCK',
chip: 'ロック',
emoji: '🦥',
name: 'ロック',
full: 'タイト・パッシブ',
desc: 'めったに参加せず、ベットよりコールを好むとても慎重な打ち方です。ブラフで降ろすのは難しい相手ですが、強い手で取りきるチャンスを逃しがちです。負けは小さく抑えられますが勝ちも伸びにくく、とてもルーズな卓でいちばん効果を発揮します。',
},
{
id: 'FISH',
chip: 'フィッシュ',
emoji: '🐟',
name: 'フィッシュ',
full: 'ルーズ・パッシブ',
desc: 'たくさんのハンドに参加するものの、自分からベット・レイズすることは少ない打ち方です。微妙な手でもコールし、ドローを追いかけがちです。たまに大きな手を当てますが、長い目で見ると負けやすいスタイルです。ブラフが効きにくいので、相手にすると厄介でもあります。',
},
];
function playStyle(m) {
if (!m || m.vpip == null || m.pfr == null) return null;
const loose = clamp(m.vpip / 50, 0.12, 0.92);
const aggr = clamp(m.vpip > 0 ? m.pfr / m.vpip : 0, 0.12, 0.92);
const id = loose >= 0.5 ? (aggr >= 0.5 ? 'LAG' : 'FISH') : aggr >= 0.5 ? 'TAG' : 'ROCK';
return { loose, aggr, id };
}
// 菱形のレーダー(上: アグレッシブ、右: ルーズ、下: パッシブ、左: タイト)と、四隅のスタイル
function styleCardHtml(m) {
const st = playStyle(m);
const W = 360;
const H = 300;
const cx = W / 2;
const cy = 150;
const R = 92;
const pt = (dir, f) => {
const d = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[dir];
return [cx + d[0] * R * f, cy + d[1] * R * f];
};
const diamond = (f) => ['top', 'right', 'bottom', 'left'].map((d) => pt(d, f).map((x) => x.toFixed(1)).join(',')).join(' ');
const grid = [1 / 3, 2 / 3]
.map((f) => `<polygon class="st-radar-ring" points="${diamond(f)}"/>`)
.concat(`<polygon class="st-radar-outer" points="${diamond(1)}"/>`)
.join('');
const axes = `<path class="st-radar-axis" d="M${cx} ${cy - R}V${cy + R}M${cx - R} ${cy}H${cx + R}"/>`;
// 四隅のスタイル
const CHIP_W = 74;
const CHIP_H = 34;
const corners = { TAG: [14, 16], LAG: [W - 14 - CHIP_W, 16], ROCK: [14, H - 16 - CHIP_H], FISH: [W - 14 - CHIP_W, H - 16 - CHIP_H] };
const chips = TYPES.map((t) => {
const [x, y] = corners[t.id];
const on = st && st.id === t.id;
return `<g class="st-chip${on ? ' on' : ''}" data-type="${t.id}">
<rect x="${x}" y="${y}" width="${CHIP_W}" height="${CHIP_H}" rx="12"/>
<text x="${x + CHIP_W / 2}" y="${y + CHIP_H / 2 + 5}" text-anchor="middle">${esc(t.chip)}</text></g>`;
}).join('');
let shape = '';
let link = '';
if (st) {
const poly = [pt('top', st.aggr), pt('right', st.loose), pt('bottom', 1 - st.aggr), pt('left', 1 - st.loose)];
const points = poly.map((p) => p.map((x) => x.toFixed(1)).join(',')).join(' ');
shape = `<polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-v)"/><polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-h)"/>`;
// 自分のスタイルの隅へ線を引く(菱形の、その隅の側の辺の中点から)
const side = { TAG: [0, 3], LAG: [0, 1], ROCK: [2, 3], FISH: [1, 2] }[st.id];
const from = [(poly[side[0]][0] + poly[side[1]][0]) / 2, (poly[side[0]][1] + poly[side[1]][1]) / 2];
const [x, y] = corners[st.id];
const to = [st.id === 'TAG' || st.id === 'ROCK' ? x + CHIP_W - 8 : x + 8, st.id === 'TAG' || st.id === 'LAG' ? y + CHIP_H - 6 : y + 6];
link = `<path class="st-radar-link" d="M${from[0].toFixed(1)} ${from[1].toFixed(1)}L${to[0]} ${to[1]}"/>`;
}
const label = (x, y, cls, a, b) =>
`<text class="st-axis ${cls}" x="${x}" y="${y}" text-anchor="middle">${a}</text><text class="st-axis-sub" x="${x}" y="${y + 15}" text-anchor="middle">${b}</text>`;
return `
<svg class="st-radar" viewBox="0 0 ${W} ${H}" role="img" aria-label="${st ? `プレイスタイル: ${esc(TYPES.find((t) => t.id === st.id).full)}` : 'プレイスタイル: データなし'}">
<defs>
<linearGradient id="st-grad-v" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff4f9a"/><stop offset="1" stop-color="#3fc8f4"/>
</linearGradient>
<linearGradient id="st-grad-h" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b45cf5" stop-opacity="0.95"/><stop offset="0.5" stop-color="#b45cf5" stop-opacity="0"/>
<stop offset="0.5" stop-color="#ffab3d" stop-opacity="0"/><stop offset="1" stop-color="#ffab3d" stop-opacity="0.95"/>
</linearGradient>
</defs>
${grid}${axes}${link}${shape}${chips}
${label(cx, 36, 'aggr', 'アグレッシブ', 'ベット・レイズが多い')}
${label(cx, H - 22, 'passive', 'パッシブ', 'コールが多い')}
${label(46, cy - 2, 'tight', 'タイト', '参加が少ない')}
${label(W - 46, cy - 2, 'loose', 'ルーズ', '参加が多い')}
</svg>
${st ? '' : '<div class="st-empty-note">まだデータがありません</div>'}`;
}
// ---------- カード ----------
const helpBtn = (key) =>
`<button type="button" class="st-help" data-info="${key}" aria-label="${esc(STATS[key].title)}の説明"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.6.3-1 .8-1 1.5v.3M12 16.6v.01"/></svg></button>`;
const head = (key, label = STATS[key].short) =>
`<div class="st-head"><span class="st-icon" aria-hidden="true">${ICON[key]}</span><span class="st-name">${esc(label)}</span>${helpBtn(key)}</div>`;
const value = (v, unit = '%') => `<div class="st-value">${pctNum(v)}${v == null ? '' : `<small>${unit}</small>`}</div>`;
function metricCard(key, m, compact = false) {
const def = STATS[key];
const v = m[key];
return `<div class="st-card st-metric${compact ? ' compact' : ''}">${head(key)}${value(v)}${bandBar(def, v)}
<div class="st-band">${v == null ? 'データなし' : def.labels[bandOf(def, v)]}</div></div>`;
}
function liquidCard(key, m) {
const v = m[key];
return `<div class="st-card st-liquid-card"><div class="st-liquid" data-level="${v == null ? 0 : clamp(v, 0, 100)}"></div>${head(key)}${value(v)}</div>`;
}
// ストリートごとのフォールド率(4つのメーター)
const foldMinis = (m) =>
`<div class="st-minis">${m.fold
.map(
(v, i) =>
`<div class="st-mini"><div class="st-mini-gauge">${miniGauge(v)}<span><span>${pctNum(v)}<small>${v == null ? '' : '%'}</small></span></span></div><span class="st-mini-label">${STREETS[i]}</span></div>`
)
.join('')}</div>`;
function foldCard(m) {
return `<div class="st-card">${head('fold')}${foldMinis(m)}</div>`;
}
function ringCard(key, m, v, sub, rows) {
const def = STATS[key];
const list = rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('');
return `<div class="st-card">${head(key)}
<div class="st-split">
<div class="st-ring-box">${ring(v, def)}<div class="st-ring-text">${value(v)}<span>${esc(sub)}</span></div></div>
<div class="st-rows">${list}</div>
</div></div>`;
}
function netCard(m) {
const rows = [
['100ハンドあたり', m.bb100 == null ? '—' : `${bb(m.bb100)} BB`],
['プレイ時間', `${m.hours} 時間`],
];
return `<div class="st-card">${head('net')}
<div class="st-split">
<div class="st-net"><b class="${m.netBB > 0 ? 'plus' : m.netBB < 0 ? 'minus' : ''}">${m.hands ? bb(m.netBB) : '—'}</b><span>BB</span></div>
<div class="st-rows">${rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('')}</div>
</div></div>`;
}
// 塗る量・水位は CSSOM で入れる(CSP でインラインスタイルを禁止しているため)。
// 入れた直後は 0 から伸ばす
function paintFills(root) {
const fills = root.querySelectorAll('[data-fill]');
const levels = root.querySelectorAll('[data-level]');
requestAnimationFrame(() =>
requestAnimationFrame(() => {
for (const el of fills) el.style.transform = `scaleX(${el.dataset.fill})`;
for (const el of levels) el.style.setProperty('--level', `${el.dataset.level}%`);
})
);
}
// ---------- まとめ ----------
function renderSummary() {
const m = data.modes.total;
$('style-card').innerHTML = styleCardHtml(m);
const since = data.since ? `${new Date(data.since).toLocaleDateString('ja-JP')} から` : 'まだ記録がありません';
$('summary-tiles').innerHTML = `
${metricCard('vpip', m, true)}
<div class="st-card st-metric compact">${head('time', 'プレイ時間')}<div class="st-value">${m.hours}<small>時間</small></div><div class="st-band">${since}</div></div>
${metricCard('pfr', m, true)}
${metricCard('afq', m, true)}`;
paintFills($('summary-tiles'));
}
// ---------- すべての統計 ----------
function renderAll() {
const m = data.modes[mode];
for (const b of $('mode-tabs').querySelectorAll('[data-mode]')) b.classList.toggle('active', b.dataset.mode === mode);
modeSeg.update();
const afqRows = STREETS.map((s, i) => [s, m.afqStreets[i] == null ? '—' : `${pctNum(m.afqStreets[i])}%`]);
$('all-body').innerHTML = `
${m.hands ? '' : `<div class="st-card st-empty">${MODE_LABEL[mode] === '合計' ? 'まだ' : `${MODE_LABEL[mode]}では`}記録がありません。テーブルで遊ぶとハンドごとに記録されます。</div>`}
<div class="st-grid st-grid-2">
${metricCard('vpip', m)}${metricCard('pfr', m)}${metricCard('ats', m)}${metricCard('threeBet', m)}
</div>
<div class="st-grid st-grid-3">${liquidCard('wtsd', m)}${liquidCard('wsd', m)}${liquidCard('wwsf', m)}</div>
${foldCard(m)}
${ringCard('afq', m, m.afq, m.afq == null ? 'データなし' : STATS.afq.labels[bandOf(STATS.afq, m.afq)], afqRows)}
${ringCard('hands', m, m.winRate, '勝率', [['プレイ', m.hands.toLocaleString('ja-JP')], ['勝ち', m.won.toLocaleString('ja-JP')]])}
${netCard(m)}`;
paintFills($('all-body'));
}
const modeSeg = Fluid.Segmented($('mode-tabs'));
$('mode-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-mode]');
if (!b || b.dataset.mode === mode) return;
mode = b.dataset.mode;
Fluid.haptic(6);
renderAll();
});
// ---------- 説明シート ----------
const infoSheet = Fluid.Sheet($('info-modal'));
let shareText = '';
function openInfo(key) {
if (!data) return;
const def = STATS[key];
const m = data.modes[$('view-all').hidden ? 'total' : mode];
const where = $('view-all').hidden || mode === 'total' ? '' : `(${MODE_LABEL[mode]})`;
let body = '';
let v = null;
if (key === 'fold') {
body = foldMinis(m);
shareText = `Hold'em Night のフォールド率${where}: ${STREETS.map((s, i) => `${s} ${pctNum(m.fold[i])}%`).join(' / ')}`;
} else if (key === 'net') {
body = `<div class="st-info-value">${m.hands ? `${bb(m.netBB)} BB` : '—'}</div>`;
shareText = `Hold'em Night の収支${where}: ${bb(m.netBB)} BB(${m.hands}ハンド)`;
} else if (key === 'time') {
body = `<div class="st-info-value">${m.hours} 時間</div>`;
shareText = `Hold'em Night のプレイ時間: ${m.hours} 時間(${m.hands}ハンド)`;
} else {
v = key === 'hands' ? m.winRate : m[key];
body = `<div class="st-info-value">${pctNum(v)}${v == null ? '' : '<small>%</small>'}</div>${bandScale(def, v)}`;
shareText =
v == null
? `Hold'em Night の ${def.title}${where}: まだデータがありません`
: `Hold'em Night の ${def.title}${where}: ${Math.round(v)}%(${def.labels[bandOf(def, v)]})`;
}
$('info-body').innerHTML = `
<div class="st-info-icon" aria-hidden="true">${ICON[key]}</div>
<h2 class="st-info-title" id="info-title">${esc(def.title)}</h2>
${body}
<p class="st-info-desc">${esc(def.desc)}</p>
<p class="st-info-sample">${esc(MODE_LABEL[$('view-all').hidden ? 'total' : mode])} ・ ${esc(def.sample(m))}</p>`;
paintFills($('info-body'));
infoSheet.open();
}
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-info]');
if (b) openInfo(b.dataset.info);
});
async function share(text) {
if (!text) return;
if (navigator.share) {
try {
await navigator.share({ text });
return;
} catch (err) {
if (err && err.name === 'AbortError') return; // 共有シートを閉じた
}
}
try {
if (await App.copyText(() => Promise.resolve(text))) toast('コピーしました。友達に送れます');
} catch {
toast('コピーできませんでした');
}
}
$('info-share').addEventListener('click', () => share(shareText));
// ---------- プレイスタイルのシート(横に送る) ----------
const typeSheet = Fluid.Sheet($('type-modal'));
$('type-carousel').innerHTML = TYPES.map(
(t) => `
<article class="st-type" data-type="${t.id}">
<div class="st-type-emoji" aria-hidden="true">${t.emoji}</div>
<h2 class="st-type-name">${esc(t.name)}</h2>
<div class="st-type-full">(${esc(t.full)})</div>
<p class="st-type-desc">${esc(t.desc)}</p>
<p class="st-type-mine" hidden>あなたのいまのスタイル</p>
</article>`
).join('');
$('type-dots').innerHTML = TYPES.map(() => '<span></span>').join('');
const typeCards = [...$('type-carousel').children];
function currentType() {
const c = $('type-carousel');
return clamp(Math.round(c.scrollLeft / (typeCards[1].offsetLeft - typeCards[0].offsetLeft || 1)), 0, TYPES.length - 1);
}
function syncDots() {
const i = currentType();
[...$('type-dots').children].forEach((d, k) => d.classList.toggle('on', k === i));
}
$('type-carousel').addEventListener('scroll', syncDots, { passive: true });
function openTypes(id) {
const st = data && playStyle(data.modes.total);
for (const el of typeCards) el.querySelector('.st-type-mine').hidden = !(st && st.id === el.dataset.type);
typeSheet.open();
const i = Math.max(0, TYPES.findIndex((t) => t.id === (id || (st && st.id))));
const c = $('type-carousel');
c.scrollLeft = typeCards[i].offsetLeft - (c.clientWidth - typeCards[i].offsetWidth) / 2;
syncDots();
}
$('style-card').addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('[data-type]');
openTypes(chip ? chip.dataset.type : null);
});
$('style-card').addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
openTypes(null);
});
$('type-share').addEventListener('click', () => {
const t = TYPES[currentType()];
const st = data && playStyle(data.modes.total);
share(
st && st.id === t.id
? `Hold'em Night での私のプレイスタイルは「${t.name}(${t.full})」${t.emoji} VPIP ${pctNum(data.modes.total.vpip)}% / PFR ${pctNum(data.modes.total.pfr)}%`
: `${t.emoji} ${t.name}(${t.full}): ${t.desc}`
);
});
// ---------- 画面の切り替え(?v=all ですべての統計) ----------
function route() {
const all = new URLSearchParams(location.search).get('v') === 'all';
$('view-summary').hidden = all;
$('view-all').hidden = !all;
$('title').textContent = all ? 'すべての統計' : '統計';
$('back').href = all ? '/stats' : '/';
$('back-label').textContent = all ? '統計' : 'ロビー';
if (!data) return;
if (all) renderAll();
else renderSummary();
}
window.addEventListener('popstate', () => {
route();
window.scrollTo(0, 0);
});
$('see-all').addEventListener('click', () => {
history.pushState({ fromSummary: true }, '', '/stats?v=all');
route();
window.scrollTo(0, 0);
});
$('back').addEventListener('click', (e) => {
if ($('view-all').hidden) return; // ロビーへはそのまま移る
e.preventDefault();
if (history.state && history.state.fromSummary) history.back();
else {
history.replaceState(null, '', '/stats');
route();
}
window.scrollTo(0, 0);
});
// ---------- 読み込み ----------
async function load() {
const btn = $('refresh');
btn.classList.add('spinning');
btn.disabled = true;
try {
data = await api('POST', '/api/stats', { token: Store.token });
route();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
btn.classList.remove('spinning');
}
}
$('refresh').addEventListener('click', load);
route();
load();
})();
+877
View File
@@ -3016,6 +3016,876 @@ body.page-table {
}
}
/* ---------- 席順の変更(ホスト) ---------- */
.glass-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* 次のハンドから反映する席順があるときの印 */
.glass-dot {
position: absolute;
top: 5px;
right: 5px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 0 2px var(--material-thick);
}
/* 小さなテーブル(自分が一番下で時計回り) */
.seating-preview {
position: relative;
width: min(100%, 300px);
aspect-ratio: 1.45;
margin: 2px auto 6px;
}
.seating-felt {
position: absolute;
inset: 16% 14%;
border-radius: 999px;
background: radial-gradient(120% 120% at 50% 30%, var(--felt-hi), var(--felt) 55%, var(--felt-lo));
box-shadow: 0 0 0 5px #2a2a2c, var(--shadow-s);
}
.seating-dot {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 64px;
transform: translate(-50%, -50%);
transition: left 0.5s var(--ease-spring), top 0.5s var(--ease-spring);
}
.seating-dot .rank-avatar {
width: 30px;
height: 30px;
font-size: 0.875rem;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
}
.seating-dot .rank-avatar.bot {
border-radius: 9px;
}
.seating-dot .rank-avatar svg {
width: 17px;
height: 17px;
}
.seating-dot.me .rank-avatar {
box-shadow: 0 0 0 2px var(--sheet-bg), 0 0 0 4px var(--tint);
}
.seating-name {
max-width: 100%;
padding: 0 5px;
border-radius: 6px;
background: var(--material-thick);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.seating-tools {
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 10px;
}
.seating-tools .hint {
flex: 1;
margin: 0;
}
.seating-tools .btn {
flex: none;
}
.seating-list {
margin: 0;
padding: 0;
list-style: none;
overflow: visible;
}
.seat-row {
gap: 12px;
min-height: 60px;
background: var(--bg-2);
will-change: transform;
}
.seat-row:first-child {
border-radius: 14px 14px 0 0;
}
.seat-row:last-child {
border-radius: 0 0 14px 14px;
}
.seat-row:only-child {
border-radius: 14px;
}
.seat-row.me {
background: var(--tint-soft);
}
.cell.seat-row + .cell::before {
left: 64px;
}
/* つかんでいる行は持ち上げて手前に */
.seat-row.dragging {
z-index: 2;
border-radius: 14px;
box-shadow: var(--shadow-l);
}
.seat-row.dragging::before,
.seat-row.dragging + .cell::before {
opacity: 0;
}
.seat-row .cell-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.seat-pin {
flex: none;
padding: 2px 9px;
border-radius: 999px;
background: var(--fill-3);
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
}
.seat-handle {
-webkit-appearance: none;
appearance: none;
flex: none;
width: 44px;
height: 44px;
margin: -8px -10px -8px 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--label-3);
cursor: grab;
touch-action: none;
}
.seat-row.dragging .seat-handle {
color: var(--label);
cursor: grabbing;
}
.seating-pending {
padding: 10px 16px 0;
}
/* ---------- テーブル内のランキング ---------- */
.ranking-list {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.rank-row {
gap: 12px;
background: var(--bg-2);
will-change: transform;
}
.rank-row.me {
background: var(--tint-soft);
}
.cell.rank-row:has(.rank-avatar) + .cell::before {
left: 98px;
}
.rank-no {
flex: none;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
font-family: var(--font-num);
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--label-2);
}
.rank-no.top-1 {
background: linear-gradient(180deg, #ffd96a, #e7a913);
color: #4a3200;
}
.rank-no.top-2 {
background: linear-gradient(180deg, #e9ecf0, #aeb5bf);
color: #2f343b;
}
.rank-no.top-3 {
background: linear-gradient(180deg, #f0b98a, #c47a3e);
color: #3d1f06;
}
.rank-avatar {
--hue: 210;
flex: none;
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(180deg, hsl(var(--hue) 48% 64%), hsl(var(--hue) 46% 42%));
color: #fff;
font-size: 1rem;
font-weight: 600;
line-height: 1;
}
.rank-avatar.bot {
border-radius: 10px;
}
.rank-avatar svg {
width: 20px;
height: 20px;
fill: #fff;
}
.rank-row .cell-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.rank-value {
flex: none;
font-family: var(--font-num);
font-size: 1.0625rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ranking-note {
padding: 8px 16px 0;
}
/* ---------- 統計 ---------- */
:root {
/* レーダーの軸の色と、自分のスタイルの印 */
--st-aggr: #e8378a;
--st-passive: #1a9fd0;
--st-tight: #9a45dd;
--st-loose: #e08700;
--st-pick: #f0896a;
--st-on-pick: #2b1206;
}
@media (prefers-color-scheme: dark) {
:root {
--st-aggr: #ff4f9a;
--st-passive: #3fc8f4;
--st-tight: #c278ff;
--st-loose: #ffab3d;
}
}
/* 段階の色: スタイル(参加・積極性)は緑 → ピンク、成績(勝率など)は赤 → 緑 */
.tone-style-0 { --tone: #5fd38f; --tone-2: #85e0aa; }
.tone-style-1 { --tone: #8fcfa3; --tone-2: #aac9b0; }
.tone-style-2 { --tone: #b8b0b9; --tone-2: #c9a3b9; }
.tone-style-3 { --tone: #d48db5; --tone-2: #e07fb1; }
.tone-style-4 { --tone: #e5649f; --tone-2: #f04a95; }
.tone-quality-0 { --tone: #ef5f73; --tone-2: #e7998f; }
.tone-quality-1 { --tone: #e5a564; --tone-2: #e2c17c; }
.tone-quality-2 { --tone: #a9cf74; --tone-2: #96d98c; }
.tone-quality-3 { --tone: #4fcd88; --tone-2: #3cc38a; }
.nav-action {
-webkit-appearance: none;
appearance: none;
grid-column: 3;
justify-self: end;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--tint);
cursor: pointer;
transition: opacity 0.25s;
}
.nav-action:active {
opacity: 0.4;
transition: none;
}
.nav-action.spinning .i {
animation: spin 0.8s linear infinite;
}
.st-card {
position: relative;
padding: 14px 16px 16px;
border-radius: 18px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1px var(--fill-3);
}
.st-grid {
display: grid;
gap: 12px;
}
.st-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.st-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
#all-body > * {
margin-top: 12px;
}
.st-tabs {
margin-bottom: 4px;
}
.st-empty {
color: var(--label-2);
font-size: 0.9375rem;
text-align: center;
}
.st-foot {
margin: 20px 4px 0;
}
/* 見出し(アイコン・名前・?) */
.st-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--label-2);
font-size: 0.875rem;
font-weight: 500;
}
.st-icon {
font-size: 0.9375rem;
line-height: 1;
filter: grayscale(1);
opacity: 0.85;
}
.st-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-help {
-webkit-appearance: none;
appearance: none;
flex: none;
width: 30px;
height: 30px;
margin: -6px -8px -6px 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--label-3);
cursor: pointer;
}
.st-help .i {
width: 19px;
height: 19px;
stroke-width: 1.7;
}
.st-help:active {
background: var(--fill-3);
}
.st-value {
position: relative;
z-index: 1;
margin-top: 6px;
font-family: var(--font-num);
font-size: 2.75rem;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.st-value small {
margin-left: 2px;
font-size: 1.0625rem;
font-weight: 500;
letter-spacing: 0;
}
.st-metric.compact .st-value {
font-size: 1.75rem;
}
.st-metric.compact .st-value small {
font-size: 0.9375rem;
}
.st-band {
margin-top: 8px;
font-size: 0.9375rem;
font-weight: 500;
}
.st-metric.compact .st-band {
margin-top: 6px;
font-size: 0.8125rem;
color: var(--label-2);
}
/* 段階のバー */
.st-bar {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
margin-top: 12px;
}
.st-seg {
display: block;
height: 6px;
border-radius: 3px;
background: var(--fill-2);
overflow: hidden;
}
.st-seg-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--tone), var(--tone-2));
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.7s var(--ease-spring);
}
/* 水位で見せるタイル(WTSD・W$SD・WWSF) */
.st-liquid-card {
min-height: 118px;
overflow: hidden;
padding: 12px 12px 14px;
}
/* 3つ並ぶので見出しを詰める(WTSD などが省略されないように) */
.st-liquid-card .st-head {
gap: 4px;
font-size: 0.8125rem;
}
.st-liquid-card .st-icon {
font-size: 0.8125rem;
}
.st-liquid-card .st-name {
overflow: visible;
text-overflow: clip;
}
.st-liquid-card .st-help {
width: 24px;
height: 24px;
margin: -4px -6px -4px 0;
}
.st-liquid-card .st-help .i {
width: 17px;
height: 17px;
}
.st-liquid-card .st-value {
font-size: 2rem;
}
.st-liquid-card .st-value small {
font-size: 0.875rem;
}
.st-liquid {
--level: 0%;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--level);
background: var(--fill-2);
transition: height 1s var(--ease-spring);
}
.st-liquid::before {
content: "";
position: absolute;
left: 0;
bottom: 100%;
width: 200%;
height: 8px;
background: inherit;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x;
-webkit-mask-size: 50% 100%;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x;
mask-size: 50% 100%;
animation: st-wave 6s linear infinite;
}
@keyframes st-wave {
to {
transform: translateX(-50%);
}
}
/* 小さなメーター(フォールド率) */
.st-minis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
margin-top: 10px;
text-align: center;
}
.st-mini-gauge {
position: relative;
width: 100%;
max-width: 76px;
margin: 0 auto;
aspect-ratio: 1;
}
.st-gauge {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.st-gauge path {
fill: none;
stroke-width: 6;
stroke-linecap: round;
}
.st-gauge-track {
stroke: var(--fill);
}
.st-gauge-val {
stroke: var(--label);
}
.st-mini-gauge > span {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-family: var(--font-num);
font-size: 1.0625rem;
font-variant-numeric: tabular-nums;
}
.st-mini-gauge small {
font-size: 0.6875rem;
}
.st-mini-label {
display: block;
margin-top: -2px;
font-size: 0.8125rem;
color: var(--label-2);
}
/* 輪と一覧(アグレッション頻度・ハンド・収支) */
.st-split {
display: grid;
grid-template-columns: minmax(0, 132px) minmax(0, 1fr);
gap: 16px;
align-items: center;
margin-top: 8px;
}
.st-ring-box {
position: relative;
aspect-ratio: 1;
}
.st-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.st-ring path {
fill: none;
stroke-width: 8;
stroke-linecap: round;
}
.st-ring-track {
stroke: var(--fill-2);
}
.st-ring-val {
stroke: var(--tone);
}
.st-ring-text {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.st-ring-text .st-value {
margin: 0;
font-size: 2rem;
}
.st-ring-text .st-value small {
font-size: 0.875rem;
}
.st-ring-text > span {
font-size: 0.8125rem;
color: var(--label-2);
}
.st-rows {
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 14px;
border-left: 0.5px solid var(--separator);
}
.st-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 9px 0;
font-size: 0.9375rem;
color: var(--label-2);
}
.st-row + .st-row {
border-top: 0.5px solid var(--separator);
}
.st-row b {
color: var(--label);
font-family: var(--font-num);
font-size: 1.0625rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.st-net {
text-align: center;
}
.st-net b {
display: block;
font-family: var(--font-num);
font-size: 2rem;
font-weight: 500;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.st-net span {
font-size: 0.8125rem;
color: var(--label-2);
}
/* プレイスタイルのレーダー */
.st-style {
padding: 6px;
cursor: pointer;
transition: transform 0.45s var(--ease-spring);
}
.st-style:active {
transform: scale(0.985);
transition: transform 0.1s ease-out;
}
.st-radar {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.st-radar-ring {
fill: none;
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.st-radar-outer {
fill: none;
stroke: var(--label-3);
stroke-width: 1.5;
}
.st-radar-axis {
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 2 4;
}
.st-radar-shape {
stroke: none;
}
.st-radar-link {
stroke: var(--st-pick);
stroke-width: 2;
}
.st-chip rect {
fill: transparent;
stroke: var(--separator);
stroke-width: 1;
}
.st-chip text {
fill: var(--label-2);
font-size: 14px;
font-weight: 500;
}
.st-chip.on rect {
fill: var(--st-pick);
stroke: none;
}
.st-chip.on text {
fill: var(--st-on-pick);
font-weight: 600;
}
.st-axis {
font-size: 17px;
font-weight: 600;
}
.st-axis.aggr {
fill: var(--st-aggr);
}
.st-axis.passive {
fill: var(--st-passive);
}
.st-axis.tight {
fill: var(--st-tight);
}
.st-axis.loose {
fill: var(--st-loose);
}
.st-axis-sub {
fill: var(--label-2);
font-size: 11.5px;
}
.st-empty-note {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--label-2);
font-size: 0.9375rem;
pointer-events: none;
}
/* 説明シート */
.st-info-icon {
margin-top: 4px;
font-size: 3.25rem;
line-height: 1.1;
text-align: center;
}
.st-info-title {
margin: 8px 0 14px;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.015em;
text-align: center;
}
.st-info-value {
margin-bottom: 16px;
font-family: var(--font-num);
font-size: 2.25rem;
font-weight: 500;
line-height: 1.1;
text-align: center;
font-variant-numeric: tabular-nums;
}
.st-info-value small {
font-size: 1rem;
}
.st-scale {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
text-align: center;
}
.st-scale-col {
display: grid;
gap: 6px;
min-width: 0;
}
.st-scale-col .st-seg {
height: 10px;
border-radius: 5px;
}
.st-scale-label,
.st-scale-range {
font-size: 0.75rem;
line-height: 1.2;
color: var(--label-2);
white-space: nowrap;
}
.st-scale-col.on .st-scale-label,
.st-scale-col.on .st-scale-range {
color: var(--label);
font-weight: 600;
}
.st-info-desc {
margin-top: 20px;
color: var(--label-2);
font-size: 1rem;
line-height: 1.65;
text-align: center;
}
.st-info-sample {
margin: 10px 0 4px;
color: var(--label-3);
font-size: 0.8125rem;
text-align: center;
}
.btn.st-share {
background: var(--label);
color: var(--bg-2);
}
/* プレイスタイルのシート(横に送る) */
.st-carousel {
display: flex;
gap: 12px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
padding: 4px 9% 2px;
scrollbar-width: none;
}
.st-carousel::-webkit-scrollbar {
display: none;
}
.st-type {
flex: 0 0 82%;
scroll-snap-align: center;
padding: 26px 20px 24px;
border-radius: 22px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1px var(--fill-2);
text-align: center;
}
.st-type-emoji {
font-size: 4rem;
line-height: 1.1;
}
.st-type-name {
margin-top: 10px;
font-size: 1.625rem;
font-weight: 600;
letter-spacing: -0.015em;
}
.st-type-full {
color: var(--label-2);
}
.st-type-desc {
margin-top: 14px;
color: var(--label-2);
font-size: 0.9375rem;
line-height: 1.65;
}
.st-type-mine {
display: inline-block;
margin-top: 14px;
padding: 4px 12px;
border-radius: 999px;
background: var(--st-pick);
color: var(--st-on-pick);
font-size: 0.8125rem;
font-weight: 600;
}
.st-dots {
display: flex;
justify-content: center;
gap: 8px;
padding: 14px 0 2px;
}
.st-dots span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--label-3);
transition: background-color 0.2s;
}
.st-dots span.on {
background: var(--label);
}
/* ---------- 視差効果を減らす: 動きは短いクロスフェードに ---------- */
@media (prefers-reduced-motion: reduce) {
.pcard.deal,
@@ -3054,6 +3924,13 @@ body.page-table {
.nav-title {
transform: none;
}
.st-liquid::before {
animation: none;
}
.st-seg-fill,
.st-liquid {
transition: none;
}
}
@keyframes fade-in {
from {
+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)}`;
+5
View File
@@ -33,6 +33,11 @@
<label for="name">名前</label>
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
</div>
<a class="cell cell-link" href="/stats">
<span class="cell-icon tone-blue"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5l8 8.5-8 8.5-8-8.5z"/><path d="M12 8l4 4-4 4-4-4z"/></svg></span>
<span class="cell-title">統計<span class="cell-sub">プレイスタイル・VPIP・PFR など</span></span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
<div class="error-text" id="name-error" role="alert"></div>
</section>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>統計 - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="page-stats">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" id="back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg><span id="back-label">ロビー</span></a>
<span class="nav-title"></span>
<button type="button" class="nav-action" id="refresh" aria-label="更新" title="更新">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4.2h-4.2"/></svg>
</button>
</div>
</header>
<main class="wrap stats-wrap">
<h1 class="large-title" id="title">統計</h1>
<!-- まとめ: プレイスタイルと主な数字 -->
<section id="view-summary">
<div class="st-card st-style" id="style-card" role="button" tabindex="0" aria-label="プレイスタイルの説明を開く"></div>
<div class="st-grid st-grid-2 mt-12" id="summary-tiles"></div>
<button type="button" class="btn btn-lg btn-block mt-12" id="see-all">すべての統計を見る</button>
</section>
<!-- すべての統計(テーブルの種類ごと) -->
<section id="view-all" hidden>
<div class="segmented st-tabs" id="mode-tabs" aria-label="テーブルの種類">
<button type="button" class="seg-btn active" data-mode="total">合計</button>
<button type="button" class="seg-btn" data-mode="chips">キャッシュ</button>
<button type="button" class="seg-btn" data-mode="friends">友人戦</button>
<button type="button" class="seg-btn" data-mode="cpu">CPU戦</button>
</div>
<div id="all-body"></div>
</section>
<p class="hint st-foot">統計はこのブラウザのプレイヤーIDごとに、ハンドが終わるたびに記録しています。別の端末やブラウザで遊んだ分は別に数えます。CPU戦にはレビュー付きCPU戦を含みます。</p>
</main>
<!-- 1つの数字の説明(段階・説明・共有) -->
<div class="sheet-backdrop" id="info-modal" hidden>
<div class="sheet st-info-sheet" role="dialog" aria-modal="true" aria-labelledby="info-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span></div>
<div class="sheet-scroll" id="info-body"></div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block st-share" id="info-share">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5v11M8 7l4-3.5L16 7"/><path d="M8.5 10.5H7A2.5 2.5 0 0 0 4.5 13v5A2.5 2.5 0 0 0 7 20.5h10a2.5 2.5 0 0 0 2.5-2.5v-5a2.5 2.5 0 0 0-2.5-2.5h-1.5"/></svg>友達に共有
</button>
</div>
</div>
</div>
<!-- プレイスタイル4種類(横に送って見る) -->
<div class="sheet-backdrop" id="type-modal" hidden>
<div class="sheet st-info-sheet" role="dialog" aria-modal="true" aria-label="プレイスタイル" tabindex="-1">
<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span></div>
<div class="st-carousel" id="type-carousel"></div>
<div class="st-dots" id="type-dots" aria-hidden="true"></div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block st-share" id="type-share">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5v11M8 7l4-3.5L16 7"/><path d="M8.5 10.5H7A2.5 2.5 0 0 0 4.5 13v5A2.5 2.5 0 0 0 7 20.5h10a2.5 2.5 0 0 0 2.5-2.5v-5a2.5 2.5 0 0 0-2.5-2.5h-1.5"/></svg>友達に共有
</button>
</div>
</div>
</div>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/stats.js"></script>
</body>
</html>
+56 -1
View File
@@ -27,11 +27,18 @@
<svg class="i i-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3"/><rect x="13.5" y="5" width="4" height="14" rx="1.3"/></svg>
<svg class="i i-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.2-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z"/></svg>
</button>
<button class="glass-btn" id="seating-open" aria-label="席順を変更" title="席順を変更">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M7.5 20V5M4 8.5L7.5 5 11 8.5M16.5 4v15M13 15.5l3.5 3.5 3.5-3.5"/></svg>
<span class="glass-dot" id="seating-dot" hidden></span>
</button>
</span>
<!-- レビュー付きCPU戦のみ -->
<button class="glass-btn" id="review-open" aria-label="直前のハンドのレビュー" title="直前のハンドのレビュー" hidden>
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>
</button>
<button class="glass-btn" id="ranking-open" aria-label="ランキング" title="ランキング">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4h8v5a4 4 0 0 1-8 0z"/><path d="M8 5.5H5.5v1A3 3 0 0 0 8 9.5M16 5.5h2.5v1A3 3 0 0 1 16 9.5M12 13v3.5M8.5 20h7M9.5 20l.5-3.5h4l.5 3.5"/></svg>
</button>
<button class="glass-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/></svg>
</button>
@@ -150,6 +157,54 @@
</div>
</div>
<!-- 席順の変更(ホストのみ)。自分から時計回りの並びを、行をつかんで並べ替える -->
<div class="sheet-backdrop" id="seating-modal" hidden>
<div class="sheet seating-sheet" role="dialog" aria-modal="true" aria-labelledby="seating-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<h2 class="sheet-title" id="seating-title">席順を変更</h2>
</div>
<div class="sheet-scroll">
<div class="seating-preview" id="seating-preview" aria-hidden="true"><div class="seating-felt"></div></div>
<div class="seating-tools">
<p class="hint" id="seating-note">右端のつまみをつかんで並べ替えます。上から時計回り(あなたの左隣から)に座ります。</p>
<button type="button" class="btn btn-sm" id="seating-shuffle">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h3.5c4.5 0 4.5 10 9 10H20M4 17h3.5c1.6 0 2.6-1.2 3.4-2.8M13.1 9.8C13.9 8.2 14.9 7 16.5 7H20M17.5 4.5 20 7l-2.5 2.5M17.5 14.5 20 17l-2.5 2.5"/></svg>シャッフル
</button>
</div>
<ol class="list seating-list" id="seating-list"></ol>
<p class="hint seating-pending" id="seating-pending" hidden></p>
</div>
<div class="sheet-footer">
<div class="btn-row">
<button type="button" class="btn btn-lg" id="seating-cancel">キャンセル</button>
<button type="button" class="btn btn-tint btn-lg" id="seating-save">この順に座る</button>
</div>
</div>
</div>
</div>
<!-- テーブル内のランキング(スタック順・収支順を切り替える) -->
<div class="sheet-backdrop" id="ranking-modal" hidden>
<div class="sheet ranking-sheet" role="dialog" aria-modal="true" aria-labelledby="ranking-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<h2 class="sheet-title" id="ranking-title">ランキング</h2>
</div>
<div class="sheet-scroll">
<div class="segmented" id="ranking-tabs" aria-label="並べ替え">
<button type="button" class="seg-btn active" data-rank="stack" aria-pressed="true">スタック</button>
<button type="button" class="seg-btn" data-rank="net" aria-pressed="false">収支</button>
</div>
<ol class="list ranking-list mt-12" id="ranking-list"></ol>
<p class="hint ranking-note" id="ranking-note"></p>
</div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block" id="ranking-close">閉じる</button>
</div>
</div>
</div>
<!-- 直前のハンドのレビュー(下から出るシート。つまみを下へ払うと閉じる) -->
<div class="sheet-backdrop" id="review-modal" hidden>
<div class="sheet review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-title" tabindex="-1">
@@ -163,7 +218,7 @@
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>AIに質問する用にコピー
</button>
<div class="list mt-12">
<a class="cell cell-link" id="review-link" href="/review" target="_blank" rel="noopener">
<a class="cell cell-link" id="review-link" href="/review">
<span class="cell-title">対局全体のレビュー<span class="cell-sub">点数表・成績・リプレイ</span></span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
+4 -1
View File
@@ -56,6 +56,8 @@ class Hand {
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
this.startedAt = Date.now(); // 統計のプレイ時間用
this.endedAt = null;
this.players = new Map();
for (const i of order) {
@@ -413,7 +415,8 @@ class Hand {
}
end(showdown, results) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー・統計用
this.endedAt = Date.now();
this.finished = true;
this.runout = false;
this.showdown = showdown;
+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) => {
+90
View File
@@ -0,0 +1,90 @@
'use strict';
// 1ハンドから、1人のプレイヤーの統計の材料(数えるだけ。割合は summary.js で出す)を取り出す。
// 離席・退席などで自動で行った行動は本人の判断として数えない。キャッシュオンリー(場札なし)でも同じように数える
const { positionOf, ACTION_KIND } = require('../review/preflop');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// スチール(ATS)を試みられる位置: 全員が降りて回ってきた CO・BTN・SB
const STEAL_POSITIONS = new Set(['CO', 'BTN', 'SB', 'BTN/SB']);
function handStats(hand, seat) {
const p = hand.players.get(seat);
const mine = hand.log.filter((e) => e.seat === seat && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const kind = (e) => ACTION_KIND[e.type];
// フロップを見た: プリフロップで降りておらず、ハンドがフロップまで進んだ(プリフロップのオールインで
// 残りをめくったときも含む)
const foldedPre = hand.log.some((e) => e.seat === seat && e.street === 'PREFLOP' && e.type === 'FOLD');
const reachedFlop = hand.showdown || hand.board.length >= 3 || hand.log.some((e) => e.street !== 'PREFLOP');
const sawFlop = !foldedPre && reachedFlop;
const showdown = Boolean(hand.showdown && !p.folded);
const won = p.won > 0;
// スチールと 3bet の機会(プリフロップを順に見る)
const position = positionOf(hand, seat);
let entered = false; // 自分より前に誰かがコール・レイズした
let raises = 0;
let first = true;
let atsOpp = false;
let ats = false;
let threeBetOpp = false;
let threeBet = false;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
const k = kind(e);
if (e.seat === seat && !e.auto) {
if (first && !entered && STEAL_POSITIONS.has(position)) {
atsOpp = true;
ats = k === 'raise';
}
first = false;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
if (raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = k === 'raise';
}
}
if (k === 'call' || k === 'raise') entered = true;
if (k === 'raise') raises++;
}
// ストリートごと: 判断した(そのストリートに来た)・降りた・全部の行動のうちベット・レイズ
const seen = [0, 0, 0, 0];
const folds = [0, 0, 0, 0];
const actions = [0, 0, 0, 0];
const aggressive = [0, 0, 0, 0];
for (const e of mine) {
const i = STREETS.indexOf(e.street);
if (i < 0) continue;
seen[i] = 1;
actions[i]++;
if (kind(e) === 'raise') aggressive[i]++;
if (e.type === 'FOLD') folds[i] = 1;
}
const start = hand.startStacks.get(seat);
const end = hand.endStacks ? hand.endStacks.get(seat) : start;
return {
preflop: pre.length > 0, // VPIP・PFR の分母(BB で全員が降りてきたハンドなど、判断の無いハンドは除く)
vpip: pre.some((e) => kind(e) === 'call' || kind(e) === 'raise'),
pfr: pre.some((e) => kind(e) === 'raise'),
atsOpp,
ats,
threeBetOpp,
threeBet,
sawFlop,
showdown,
showdownWon: showdown && won,
wonSawFlop: sawFlop && won,
won,
seen,
folds,
actions,
aggressive,
netBB: hand.bb > 0 ? (end - start) / hand.bb : 0,
};
}
module.exports = { handStats, STREETS };
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// プレイヤーごとの統計の保存先(data/stats/<プレイヤーIDのハッシュ>.json)。
// プレイヤーID(ブラウザの localStorage にある席のトークン)はそのまま残さず、SHA-256 の先頭で名前を付ける。
// 中身はテーブルの種類ごとの回数の合計だけで、ハンドの内容は持たない。
// 同じプレイヤーの読み書きは順番に行う(ハンドが続けて終わっても数え漏らさない)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.STATS_DIR || path.join(__dirname, '..', '..', 'data', 'stats');
const MODES = ['chips', 'friends', 'cpu'];
// 続けて遊んでいるとみなす間隔(前のハンドの終わりからこれ以内に始まったら、間の時間もプレイ時間に入れる)
const CONTINUOUS_MS = 3 * 60 * 1000;
const queue = new Map(); // key → 直前の読み書きの Promise
const cache = new Map(); // key → 中身(読んだもの・書いたもの)
const keyOf = (token) => crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 32);
const fileOf = (key) => path.join(DIR, `${key}.json`);
const streets = () => [0, 0, 0, 0];
function emptyBucket() {
return {
hands: 0,
won: 0,
preflop: 0,
vpip: 0,
pfr: 0,
atsOpp: 0,
ats: 0,
threeBetOpp: 0,
threeBet: 0,
sawFlop: 0,
showdown: 0,
showdownWon: 0,
wonSawFlop: 0,
seen: streets(),
folds: streets(),
actions: streets(),
aggressive: streets(),
netBB: 0,
ms: 0,
};
}
const empty = () => ({ version: 1, firstAt: null, lastAt: null, modes: {} });
async function load(key) {
if (cache.has(key)) return cache.get(key);
let data;
try {
data = JSON.parse(await fs.promises.readFile(fileOf(key), 'utf8'));
if (!data || typeof data !== 'object' || !data.modes) data = empty();
} catch {
data = empty();
}
cache.set(key, data);
return data;
}
async function write(key, data) {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(key)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(key));
}
// 同じ key の処理を順番に行う
function serial(key, fn) {
const prev = queue.get(key) || Promise.resolve();
const next = prev.then(fn);
const settled = next.catch((err) => console.error('[stats] 保存に失敗しました', err.message));
queue.set(key, settled);
settled.then(() => {
if (queue.get(key) === settled) queue.delete(key);
});
return next;
}
function add(bucket, entry, ms) {
bucket.hands++;
for (const k of ['won', 'preflop', 'vpip', 'pfr', 'atsOpp', 'ats', 'threeBetOpp', 'threeBet', 'sawFlop', 'showdown', 'showdownWon', 'wonSawFlop']) {
if (entry[k]) bucket[k]++;
}
for (const k of ['seen', 'folds', 'actions', 'aggressive']) {
for (let i = 0; i < 4; i++) bucket[k][i] += entry[k][i] || 0;
}
bucket.netBB = Math.round((bucket.netBB + entry.netBB) * 100) / 100;
bucket.ms += ms;
}
// 1ハンド分を足す。startedAt / endedAt はハンドの始まりと終わりの時刻
function record(token, mode, entry, { startedAt, endedAt }) {
if (!MODES.includes(mode)) return Promise.resolve();
const key = keyOf(token);
return serial(key, async () => {
const data = await load(key);
// プレイ時間: ハンドの長さ。前のハンドから続けて遊んでいれば、間の待ち時間も入れる
const continuous = data.lastAt != null && startedAt - data.lastAt >= 0 && startedAt - data.lastAt <= CONTINUOUS_MS;
const ms = Math.max(0, endedAt - (continuous ? data.lastAt : startedAt));
if (!data.modes[mode]) data.modes[mode] = emptyBucket();
add(data.modes[mode], entry, ms);
if (data.firstAt == null) data.firstAt = startedAt;
data.lastAt = endedAt;
await write(key, data);
});
}
// 書き込み中のものを待ってから返す
function get(token) {
const key = keyOf(token);
return serial(key, () => load(key));
}
module.exports = { record, get, emptyBucket, MODES, DIR };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// 保存してある回数から、画面に出す割合を作る。テーブルの種類ごとと、すべてを合わせた合計(total)
const { emptyBucket, MODES } = require('./store');
const pct = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
function merge(buckets) {
const out = emptyBucket();
for (const b of buckets) {
for (const [k, v] of Object.entries(b)) {
if (Array.isArray(v)) v.forEach((x, i) => (out[k][i] += x));
else out[k] += v;
}
}
out.netBB = Math.round(out.netBB * 100) / 100;
return out;
}
function view(b) {
return {
hands: b.hands,
won: b.won,
winRate: pct(b.won, b.hands),
vpip: pct(b.vpip, b.preflop),
pfr: pct(b.pfr, b.preflop),
ats: pct(b.ats, b.atsOpp),
atsOpp: b.atsOpp,
threeBet: pct(b.threeBet, b.threeBetOpp),
threeBetOpp: b.threeBetOpp,
sawFlop: b.sawFlop,
showdowns: b.showdown,
wtsd: pct(b.showdown, b.sawFlop),
wsd: pct(b.showdownWon, b.showdown),
wwsf: pct(b.wonSawFlop, b.sawFlop),
// ストリートごとの降りた割合(そのストリートで判断したハンドのうち)
fold: b.folds.map((f, i) => pct(f, b.seen[i])),
// アグレッション頻度: 全部の行動(フォールド・チェック・コール・ベット・レイズ)のうちベット・レイズ
afq: pct(sum(b.aggressive), sum(b.actions)),
afqStreets: b.aggressive.map((a, i) => pct(a, b.actions[i])),
netBB: b.netBB,
bb100: b.hands > 0 ? Math.round((b.netBB / b.hands) * 1000) / 10 : null,
hours: Math.round((b.ms / 3600000) * 10) / 10,
};
}
function summarize(data) {
const modes = {};
for (const m of MODES) modes[m] = view(data.modes[m] || emptyBucket());
modes.total = view(merge(MODES.map((m) => data.modes[m]).filter(Boolean)));
return { since: data.firstAt, modes };
}
module.exports = { summarize };
+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,
};
+5 -1
View File
@@ -1,7 +1,11 @@
'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー)
// node --test で実行(ACCESS_KEY はテスト用のダミー、統計の保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
const test = require('node:test');
const assert = require('node:assert/strict');
+2 -1
View File
@@ -1,11 +1,12 @@
'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー、レビューの保存先は一時ディレクトリ)
// node --test で実行(ACCESS_KEY はテスト用のダミー、レビューと統計の保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.REVIEW_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-review-'));
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
const test = require('node:test');
const assert = require('node:assert/strict');
+90
View File
@@ -0,0 +1,90 @@
'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー、統計の保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
const test = require('node:test');
const assert = require('node:assert/strict');
const { Table } = require('../server/tables');
const T = (n) => 'q'.repeat(31) + n; // 32文字のトークン
function makeTable(n) {
const t = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'friends',
});
for (let i = 0; i < n; i++) t.join(T(i), 'p' + i);
return t;
}
// 席の並び(名前)
const names = (t) => t.seats.filter(Boolean).map((s) => s.name);
test('席順: 開始前はすぐに並べ替え、ホスト以外や着席者と合わない指定は受け付けない', () => {
const t = makeTable(4);
assert.equal(t.setSeating(T(1), [3, 2, 1, 0]), 'ホストのみ操作できます');
assert.match(t.setSeating(T(0), [3, 2, 1]), /もう一度/);
assert.match(t.setSeating(T(0), [3, 3, 1, 0]), /もう一度/);
assert.match(t.setSeating(T(0), [3, 2, 1, 5]), /もう一度/);
assert.equal(t.setSeating(T(0), [0, 3, 1, 2]), null);
assert.deepEqual(names(t), ['p0', 'p3', 'p1', 'p2']);
assert.equal(t.pendingSeating, null);
// 席は詰めて座らせ、新しく座った人は空いている次の席(並びの最後)に入る
t.leave(T(1));
t.join(T(9), 'p9');
assert.deepEqual(names(t), ['p0', 'p3', 'p9', 'p2']);
});
test('席順: ハンド中は次のハンドの始めに並べ替え、ボタンは同じ人に付いたまま次へ回る', () => {
const t = makeTable(4);
t.start(T(0), 0);
const h = t.hand;
const buttonName = t.seats[h.button].name;
const order = [0, 2, 1, 3]; // p0 → p2 → p1 → p3
assert.equal(t.setSeating(T(0), order), null);
assert.deepEqual(names(t), ['p0', 'p1', 'p2', 'p3']); // まだ変わらない
assert.deepEqual(t.publicState(T(0), new Set()).pendingSeating, order);
assert.equal(t.publicState(T(1), new Set()).pendingSeating, null); // ホスト以外には送らない
// 全員降りてハンドを終わらせ、次のハンドへ
for (let i = 0; i < 10 && !h.finished; i++) t.act(t.seats[h.toAct].token, 'fold', 0);
assert.ok(h.finished);
t.tick(10 ** 6);
t.tick(10 ** 7);
assert.equal(t.hand.number, 2);
assert.deepEqual(names(t), ['p0', 'p2', 'p1', 'p3']);
assert.equal(t.pendingSeating, null);
// 前のハンドのボタンの人の、新しい並びでの次の人がボタン
const after = ['p0', 'p2', 'p1', 'p3'];
const next = after[(after.indexOf(buttonName) + 1) % 4];
assert.equal(t.seats[t.hand.button].name, next);
// 参加者の順もボタンの次から新しい並びどおり
const seq = t.hand.order.map((s) => t.seats[s].name);
const k = after.indexOf(next);
assert.deepEqual(seq, [1, 2, 3, 4].map((d) => after[(k + d) % 4]));
});
test('席順: 決めた後に座った人は最後に加え、いなくなった人は外す', () => {
const t = makeTable(3);
t.start(T(0), 0);
const h = t.hand;
assert.equal(t.setSeating(T(0), [2, 1, 0]), null);
t.join(T(5), 'p5'); // ハンド中に新しく座る
t.leave(T(1)); // ハンド中に退席
for (let i = 0; i < 10 && !h.finished; i++) t.act(t.seats[h.toAct].token, 'fold', 0);
t.tick(10 ** 6);
t.tick(10 ** 7);
assert.deepEqual(names(t), ['p2', 'p0', 'p5']);
});
+201
View File
@@ -0,0 +1,201 @@
'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー、統計とレビューの保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
process.env.REVIEW_DIR = process.env.REVIEW_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-review-'));
const test = require('node:test');
const assert = require('node:assert/strict');
const { Table } = require('../server/tables');
const { positionOf } = require('../server/review/preflop');
const { handStats } = require('../server/stats/hand');
const stats = require('../server/stats/store');
const { summarize } = require('../server/stats/summary');
const T = (n) => 's'.repeat(31) + n; // 32文字のトークン
function makeTable(n, mode = 'friends', extra = {}) {
const t = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode,
...extra,
});
for (let i = 0; i < n; i++) t.join(T(i), 'p' + i);
t.start(T(0), 0);
return t;
}
const act = (t, type, amount = 0) => {
const seat = t.hand.toAct;
assert.equal(t.act(t.seats[seat].token, type, amount), null);
return seat;
};
const pos = (h, s) => positionOf(h, s);
test('統計: BTN のスチールが通り、BB がフロップで降りたハンド', () => {
const t = makeTable(3);
const h = t.hand;
const btn = act(t, 'raise', 25); // BTN オープン
const sb = act(t, 'fold');
const bb = act(t, 'call');
assert.equal(pos(h, btn), 'BTN');
assert.equal(pos(h, sb), 'SB');
assert.equal(pos(h, bb), 'BB');
act(t, 'check'); // BB
act(t, 'raise', 30); // BTN ベット
act(t, 'fold'); // BB
assert.ok(h.finished);
const a = handStats(h, btn);
assert.equal(a.preflop, true);
assert.equal(a.vpip, true);
assert.equal(a.pfr, true);
assert.equal(a.atsOpp, true);
assert.equal(a.ats, true);
assert.equal(a.threeBetOpp, false);
assert.equal(a.sawFlop, true);
assert.equal(a.showdown, false);
assert.equal(a.won, true);
assert.equal(a.wonSawFlop, true);
assert.deepEqual(a.aggressive, [1, 1, 0, 0]);
assert.equal(a.netBB, 3); // SB 5 + BB の 25
// SB: BTN のレイズの後なのでスチールの機会ではないが、3bet の機会
const s = handStats(h, sb);
assert.equal(s.atsOpp, false);
assert.equal(s.threeBetOpp, true);
assert.equal(s.threeBet, false);
assert.equal(s.vpip, false);
assert.equal(s.sawFlop, false);
assert.deepEqual(s.folds, [1, 0, 0, 0]);
assert.equal(s.netBB, -0.5);
const b = handStats(h, bb);
assert.equal(b.vpip, true);
assert.equal(b.pfr, false);
assert.equal(b.sawFlop, true);
assert.equal(b.won, false);
assert.deepEqual(b.seen, [1, 1, 0, 0]);
assert.deepEqual(b.folds, [0, 1, 0, 0]);
assert.deepEqual(b.actions, [1, 2, 0, 0]);
assert.deepEqual(b.aggressive, [0, 0, 0, 0]);
assert.equal(b.netBB, -2.5);
});
test('統計: キャッシュオンリーのショーダウン(場札なし)でもフロップを見た・ショーダウンを数える', () => {
const t = makeTable(2, 'chips');
const h = t.hand;
const first = act(t, 'call'); // BTN/SB
const second = act(t, 'check'); // BB
for (let i = 0; i < 3; i++) {
act(t, 'check');
act(t, 'check');
}
assert.equal(h.street, 'SHOWDOWN');
assert.equal(t.award(T(0), [[second]]), null);
const w = handStats(h, second);
const l = handStats(h, first);
assert.equal(w.sawFlop, true);
assert.equal(w.showdown, true);
assert.equal(w.showdownWon, true);
assert.equal(w.vpip, false); // BB のチェックは参加に数えない
assert.equal(l.vpip, true); // SB のコールは参加
assert.equal(l.showdown, true);
assert.equal(l.showdownWon, false);
// ヘッズアップのボタン(SB)は全員が降りて回ってきたのでスチールの機会
assert.equal(l.atsOpp, true);
assert.equal(l.ats, false);
});
test('統計: 保存して合計・種類ごとの割合を出す。プレイ時間は続けて遊んだ間を含める', async () => {
const token = 'a'.repeat(32);
const base = {
preflop: true, vpip: true, pfr: true, atsOpp: false, ats: false, threeBetOpp: true, threeBet: true,
sawFlop: true, showdown: true, showdownWon: true, wonSawFlop: true, won: true,
seen: [1, 1, 1, 1], folds: [0, 0, 0, 0], actions: [1, 1, 1, 1], aggressive: [1, 1, 0, 0], netBB: 12.5,
};
const lose = {
...base, vpip: false, pfr: false, threeBetOpp: false, threeBet: false, sawFlop: false, showdown: false,
showdownWon: false, wonSawFlop: false, won: false, seen: [1, 0, 0, 0], folds: [1, 0, 0, 0],
actions: [1, 0, 0, 0], aggressive: [0, 0, 0, 0], netBB: -1,
};
await stats.record(token, 'friends', base, { startedAt: 0, endedAt: 60000 });
await stats.record(token, 'friends', lose, { startedAt: 90000, endedAt: 120000 }); // 30秒後(続けて遊んでいる)
await stats.record(token, 'cpu', lose, { startedAt: 10 ** 7, endedAt: 10 ** 7 + 30000 }); // 間が空いた
const s = summarize(await stats.get(token));
assert.equal(s.since, 0);
const f = s.modes.friends;
assert.equal(f.hands, 2);
assert.equal(f.vpip, 50);
assert.equal(f.threeBet, 100);
assert.equal(f.wtsd, 100);
assert.deepEqual(f.fold, [50, 0, 0, 0]);
assert.equal(f.afq, 40); // 行動5回のうちベット・レイズ2回
assert.equal(f.netBB, 11.5);
assert.equal(f.hours, Math.round((120000 / 3600000) * 10) / 10);
const tot = s.modes.total;
assert.equal(tot.hands, 3);
assert.equal(tot.won, 1);
assert.equal(tot.winRate, 33.3);
assert.equal(tot.netBB, 10.5);
assert.equal(tot.bb100, 350);
assert.equal(s.modes.chips.hands, 0);
assert.equal(s.modes.chips.vpip, null);
// ファイル名にプレイヤーIDそのものは使わない
const files = fs.readdirSync(stats.DIR);
assert.ok(files.length > 0 && files.every((f) => !f.includes(token)));
});
test('統計: テーブルはハンドが終わると人間のプレイヤーだけを数える(CPU は数えない)', async () => {
const t = new Table({
id: 'y',
name: 'cpu',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: 'c'.repeat(32),
mode: 'cpu',
cpuCount: 2,
});
t.join('c'.repeat(32), 'me');
t.start('c'.repeat(32), 0);
// 人間の手番が来たら降りる。CPU は時間を進めて動かす
let now = 0;
for (let i = 0; i < 200 && !t.hand.finished; i++) {
if (t.seats[t.hand.toAct] && !t.seats[t.hand.toAct].bot) t.act('c'.repeat(32), t.hand.options(t.hand.toAct).canCheck ? 'check' : 'fold');
t.tick((now += 10 ** 4));
}
assert.ok(t.hand.finished);
t.tick((now += 1));
assert.ok(t.hand.statsRecorded);
const s = summarize(await stats.get('c'.repeat(32)));
assert.equal(s.modes.cpu.hands, 1);
assert.equal(s.modes.total.hands, 1);
for (const seat of t.seats.filter((x) => x && x.bot)) {
assert.equal(summarize(await stats.get(seat.token)).modes.total.hands, 0);
}
});
test('ランキング: 進行中のハンドで出した額は降りていなければ持ち分に含め、収支はバイインとの差', () => {
const t = makeTable(3);
const btn = act(t, 'raise', 30);
const sb = act(t, 'fold');
const st = t.publicState(T(0), new Set());
assert.equal(st.seats[btn].held, 1000);
assert.equal(st.seats[btn].net, 0);
assert.equal(st.seats[sb].held, 995); // 降りた SB のブラインドはもう戻らない
assert.equal(st.seats[sb].net, -5);
});