Compare commits

...
15 Commits
Author SHA1 Message Date
野口煌陽andClaude Opus 5.5 f64c502086 Wait for the host to press "next hand" on every table
Previously only the review CPU table paused after each hand; cash-only,
friends and CPU tables moved on automatically after a short delay. Now
every table keeps the result on screen until the host presses 次のハンドへ,
and the next hand is dealt right away. If the host has left, the new host
presses it; with no human seated the table still advances on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:56:37 +09:00
agomizi1cmandClaude Opus 5.5 22ee28ba03 Add a settings page for profit display and default stack
- Per-device toggle to hide your own net and the net ranking at tables
- Default starting stack set in settings; creating a table saves the
  stack, SB and BB used as the next defaults
- Number fields on create and settings select all on focus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:31:32 +09:00
agomizi1cmandClaude Opus 5.5 ed3d1e437e Show the seating editor as a centered popup
Add Fluid.Popup, a centered dialog with the same open/close API as
Sheet: it scales in like the confirm alert, fades out in place, and
closes on backdrop tap or Escape with focus trapped inside. The host's
seating editor now uses it instead of a bottom sheet; its list scrolls
inside the popup on short screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:10 +09:00
agomizi1cmandClaude Opus 5.5 dd39c043c4 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>
2026-10-22 16:04:33 +09:00
agomizi1cmandClaude Opus 5.5 49203f202c Keep nav bar buttons above the blur layer on iOS Safari
iOS Safari can paint a backdrop-filter layer over later siblings even
while it is transparent, which dimmed the back button at the top of the
page. Put the nav content on its own layer above the material and only
apply the blur once the page has scrolled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:40:32 +09:00
agomizi1cmandClaude Opus 5.5 b6ce648b5a Rebuild bet sizing as a bottom sheet with situation-aware presets
Bet/Raise now opens a sheet whose presets follow the spot: open sizes
in BB (adjusted for limpers), 3bet/4bet multiples, postflop pot
fractions and raise multiples. Adds -/+ steppers with hold-to-repeat,
Min/All-in at the slider ends, and a square-curve slider so small sizes
stay precise with deep stacks. The hand view now exposes the street's
raise count so the client can tell a 3bet from a 4bet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:18:26 +09:00
agomizi1cmandClaude Opus 5.5 ed5617a47a Block pinch and double-tap zoom, including on iOS Safari
iOS Safari ignores user-scalable=no, so zoom is blocked in three layers:
- viewport meta: maximum-scale=1, user-scalable=no on every page
  (covers Android, home-screen apps and iOS focus zoom on inputs)
- CSS: touch-action: pan-x pan-y on html, so scrolling still works but
  pinch and double-tap zoom do not
- JS (fluid.js): cancel Safari's gesture* events and two-finger touchmove

Double-tap is left to CSS rather than JS so rapid repeated taps on
buttons still register as clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:53:02 +09:00
agomizi1cmandClaude Opus 5.5 7941406163 Make the nav bar material nearly opaque
Content scrolled beneath the translucent nav bar showed through as
blurry gray patches behind the back button. Give the bar its own denser
material so it reads as a solid surface with only a hint of blur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:49:52 +09:00
agomizi1cmandClaude Opus 5.5 c3fbfdd12e Rebuild the UI in an Apple style with spring-driven motion
Replace the look of every page (key, lobby, create, table, range, review)
with light/dark semantic colors, translucent materials, iOS-style grouped
lists and system typography. Add fluid.js, a small spring engine with
interruptible, velocity-aware animations, and use it for draggable bottom
sheets, sliding segmented controls, a custom bet slider with rubber-banding,
swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are
now reused between renders so animations are not cut off. Honors reduced
motion, reduced transparency and increased contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:44:21 +09:00
agomizi1cmandClaude Opus 5.5 d5ef2d310f Add a button to copy a hand's situation as an AI prompt
Reviews and replays get a "copy for AI" button that builds a text
describing the hand (stacks, positions, actions, pots, board, revealed
cards, result and the site's own judgments) plus questions, ready to
paste into an AI chat. Hidden opponent cards are left out so the
evaluation is based only on what Hero could see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:51:41 +09:00
agomizi1cmandClaude Opus 5.5 3ddb0996e0 Switch hand ranges to Poker Skill's 6-max charts with 3bet/call frames
- Replace the Yokosawa ranges with the 6-max opening ranges (UTG, HJ, CO,
  SB, BTN). Colors keep the old order: navy (strong UTG), red UTG, yellow
  HJ, green CO, blue SB, white BTN. SB is narrower than BTN, so blue sits
  one tier above white; the purple-frame category is gone.
- Show the "BTN vs CO open" chart on the range page as frames: light blue
  for 3bet, yellow for call.
- Grade BTN facing an unlimped CO open by those frames in reviews; other
  spots keep the color-tier rule (BB defends down to green).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:21:39 +09:00
agomizi1cmandClaude Opus 5.5 680aa20d30 Replace native confirm() with an in-site confirmation dialog
Add App.confirm() in common.js: a promise-based dialog styled like the
site's modals, with a title, multi-line message and custom button labels.
Destructive actions use a red OK button and focus Cancel by default;
Escape and backdrop clicks cancel, and Tab stays inside the dialog.

Use it for folding when a check is possible, leaving the table,
confirming showdown winners and deleting tables from the lobby. After the
dialog closes, re-check that the action is still valid (turn not passed,
pot not already awarded, table still listed) before sending it.

Also add a "poker-test" launch config that runs on port 4672 with the
example keys from .env.example, for local browser testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:34:43 +09:00
agomizi1cmandClaude Opus 5.5 d89ce1dd30 Follow Yokosawa hand range rules for 3-bets and BB defense in reviews
When the hero's own raise is re-raised, a hand in the same color as the
re-raiser's estimated range is now a call (raise still needs two above).
In the BB facing a single open of up to 4BB, the hero now defends wider:
blue or better (white vs CO, purple frame vs BTN, one below the opener)
calls, and three above raises. Opponent range estimates follow the same
rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:31:07 +09:00
agomizi1cmandClaude Opus 5.5 0318ee2b5e Let admins delete tables from the lobby
Admins see a "削除" button on every table in the lobby. It calls the new
admin-only DELETE /api/tables/:id (404 for non-admins, same-origin only),
which closes the table the same way as when every human leaves. Players
and spectators still at the table are told "管理者がテーブルを削除しました。"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:03:24 +09:00
agomizi1cmandClaude Opus 5.5 3f2515a9fa Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by
admins. Only the creator and CPUs sit there: friends cannot join, and the
table is hidden from non-admin lobbies. After every hand a review opens,
and the next hand waits until the creator presses "次のハンドへ".

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00
42 changed files with 10926 additions and 1084 deletions

No files matched your search

+7
View File
@@ -6,6 +6,13 @@
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 4671
},
{
"name": "poker-test",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"env": { "ACCESS_KEY": "000000", "ADMIN_KEY": "000001", "PORT": "4672" },
"port": 4672
}
]
}
+39 -5
View File
@@ -13,9 +13,10 @@
| キャッシュオンリー | カードは実物で配り、サイトはチップの代わりに使う(ベット・ポット・スタックの管理)。ショーダウンの勝者はホストが選ぶ |
| 友人戦 | 実物のカードがなくても遊べる。サイトがカードを配り、勝敗とポットの分配まで自動で判定する |
| CPU戦 | 友人戦に CPU プレイヤー(1〜8人)が加わる。空いている席には友達も参加できる |
| レビュー付きCPU戦 | 管理者だけが作れる。作成者と CPU だけで遊び(友達は入れない・ロビーにも出ない)、ハンドごとにレビューが出る(下の「管理者」を参照) |
- ポーカーのルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・ショーダウンの判定と分配)と役の判定は、公開ライブラリ [@pokertools/engine](https://www.npmjs.com/package/@pokertools/engine) / [@pokertools/evaluator](https://www.npmjs.com/package/@pokertools/evaluator)(MIT、バージョン固定)に任せている。
- ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。
- ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すとハンドが始まる。ハンドが終わると結果を表示したまま止まり、ホストが「次のハンドへ」を押すと次のハンドが配られる(ホストが退席したら次のホストが押す。人間が誰も着席していなければ待たずに進む)。
- 画面にディーラーボタン(D)・SB・BB と、いまのストリート(PREFLOP / FLOP / TURN / RIVER)が出るので、それに合わせて実物のカードを配る。
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。BB は SB より大きくする。ヘッズアップはボタンが SB。
- 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。
@@ -34,6 +35,7 @@
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
- テーブル上部のトロフィーのボタンで、着席している全員のランキングを開ける。「スタック」(持っているチップ。ハンド中は、まだ降りていない人がそのハンドで出した額も含める)と「収支」(持っているチップ − バイインの合計。リバイを含む)を切り替えられる。同じ値は同じ順位。
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。
- チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。
- ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。
@@ -61,9 +63,37 @@ http://localhost:4671 を開く。
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 |
## 管理者(ハンドレンジ学習)
## 統計
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」が表示される(`/range`:レンジ表と色・分類クイズ)。管理者は通常のプレイもそのままできる。
ロビーの「統計」(`/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 で削除ボタンに移ると開く)、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
### レビュー付きCPU戦
ハンドが終わるたびにレビューが開き、「次のハンドへ」を押すまで次のハンドは始まらない。
- **ハンドレンジ**: 配られた手が登録してあるハンドレンジ(Poker Skill の 6-max オープンレンジ。`admin/range-data.js`。レンジ学習ページと共用)の何色か、そのとおりに行動できたかを判定する。
- 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)の色以上ならレイズ、それより弱ければフォールド。6-max の席に当てはめて、5人以上は UTG の赤(紺も可)、4人は HJ の黄、3人は CO の緑、2人は BTN の白、1人は SB の青。SB のレンジは BTN より狭いので、青は白の一つ上の段階として扱う。
- 前にコール・レイズした人がいる: その人のレンジを、その人の後ろの人数に対応する色と推定し、自分の手がその一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド。紺はどんな相手にもコールまではできる。
- 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ。
- BTN で CO のオープン(ほかに参加者なし)に直面: レンジ表の枠のとおり。水色枠は 3bet、黄枠はコール、枠なしはフォールド。
- BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。緑以上でコール(CO のオープンには青、BTN・SB には白以上。相手の色の一つ下まで)、相手の三つ上ならレイズ。
- 2人目以降に参加した人のレンジも同じ決まりでコール・レイズできる一番弱い色と推定する(ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。
- チェックできるときのフォールド・コールの推奨はチェックになる。判定は ◎(100点)・△(消極的 50点/攻めすぎ 40点)・✕(参加しすぎ・降りすぎ 0点)。
- **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。
- **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。
- **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(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/` に置いている。
@@ -92,16 +122,20 @@ http://localhost:4671 を開く。
| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う |
| `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` | 各画面 |
| `admin/` | 管理者専用ページ(ハンドレンジ学習・カード画像) |
| `public/assets/style.css`, `public/assets/fluid.js` | 見た目の土台(light / dark の色・半透明の素材・字の大きさ)と、動きの共通部品(ばね・下から出るシート・滑るセグメント・指の速度と勢いの先読み) |
| `admin/` | 管理者専用ページ(ハンドレンジ学習・CPU戦のレビュー・登録してあるハンドレンジ・カード画像) |
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
- ホストはテーブル名の右の「席順を変更」(↑↓のボタン、3人以上のとき)で、着席者の並びを変えられる。自分を一番上に固定し、その下に左隣から時計回りの順で並び、行の右端のつまみをドラッグ(キーボードでは ↑↓)して並べ替える。小さなテーブルの図で仕上がりを確かめられ、「シャッフル」でランダムにもできる。ハンドの途中(結果の表示中を含む)に決めた席順は次のハンドの始めに反映し(ボタンに印が付く)、決めた後に座った人は並びの最後に入る。ディーラーボタンは同じ人に付いたまま、次のハンドで新しい並びの次の人へ回る。
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
+96
View File
@@ -0,0 +1,96 @@
// 登録してあるハンドレンジ(ハンドレンジ学習ページとレビュー付きCPU戦で共用・管理者専用)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.HandRange = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
// 6-max のオープンレンジ(Poker Skill のプリフロップレンジ表、スタック約100BB・全員が降りて回ってきた場面)。
// 強い順。tier が大きいほど強い(レビューでは「一つ上」「二つ上」をこの段階で数える)。
// 各ポジションのレンジは UTG ⊂ HJ ⊂ CO ⊂ SB ⊂ BTN と入れ子になっているので、手ごとに
// 「オープンできる一番狭いポジション」の色を付ける。SB は BTN より狭いので BTN の一つ上に置く。
// 紺は UTG のうち特に強い手
const CATEGORIES = [
{ id: 'navy', name: '紺', label: 'UTG(強)', tier: 6 },
{ id: 'red', name: '赤', label: 'UTG', tier: 5 },
{ id: 'yellow', name: '黄', label: 'HJ', tier: 4 },
{ id: 'green', name: '緑', label: 'CO', tier: 3 },
{ id: 'blue', name: '青', label: 'SB', tier: 2 },
{ id: 'white', name: '白', label: 'BTN', tier: 1 },
{ id: 'gray', name: 'グレー', label: 'フォールド', tier: 0 },
];
// 全169ハンドの分類(載っていないハンドはフォールド)
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'yellow', A8s: 'yellow', A7s: 'yellow', A6s: 'yellow', A5s: 'red', A4s: 'red', A3s: 'red', A2s: 'red',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'red', KTs: 'red', K9s: 'yellow', K8s: 'green', K7s: 'green', K6s: 'green', K5s: 'green', K4s: 'blue', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'red', QQ: 'navy', QJs: 'red', QTs: 'red', Q9s: 'yellow', Q8s: 'green', Q7s: 'blue', Q6s: 'blue', Q5s: 'white', Q4s: 'white',
AJo: 'red', KJo: 'yellow', QJo: 'green', JJ: 'red', JTs: 'red', J9s: 'yellow', J8s: 'green', J7s: 'blue', J6s: 'white',
ATo: 'yellow', KTo: 'green', QTo: 'blue', JTo: 'blue', TT: 'red', T9s: 'red', T8s: 'yellow', T7s: 'blue', T6s: 'white',
A9o: 'green', K9o: 'blue', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'red', '97s': 'green', '96s': 'blue', '95s': 'white',
A8o: 'green', K8o: 'white', '98o': 'white', 88: 'red', '87s': 'yellow', '86s': 'blue', '85s': 'white',
A7o: 'blue', 77: 'red', '76s': 'green', '75s': 'white',
A6o: 'blue', 66: 'red', '65s': 'green', '64s': 'white',
A5o: 'blue', 55: 'red', '54s': 'green',
A4o: 'white', 44: 'red',
A3o: 'white', 33: 'red',
A2o: 'white', 22: 'red',
};
// レイズを受けたときの対応(同じレンジ表の「CO のオープンに対する BTN の対応」)。レンジ表では枠で示す。
// 載っていないハンドはフォールド
const FACING_ACTIONS = [
{ id: '3bet', name: '水色枠', label: '3bet' },
{ id: 'call', name: '黄枠', label: 'コール' },
];
const FACING = {
AA: '3bet', KK: '3bet', QQ: '3bet', JJ: '3bet', AKs: '3bet', AKo: '3bet', A5s: '3bet', A4s: '3bet', A3s: '3bet', A2s: '3bet',
KQs: '3bet', KJs: '3bet', '76s': '3bet', '65s': '3bet', '54s': '3bet',
TT: 'call', 99: 'call', 88: 'call', 77: 'call', 66: 'call', 55: 'call', 44: 'call', 33: 'call', 22: 'call',
AQs: 'call', AJs: 'call', ATs: 'call', A9s: 'call', A8s: 'call', A7s: 'call', A6s: 'call', KTs: 'call', K9s: 'call',
QJs: 'call', QTs: 'call', Q9s: 'call', JTs: 'call', J9s: 'call', T9s: 'call', T8s: 'call', '98s': 'call', '87s': 'call',
AQo: 'call', AJo: 'call', KQo: 'call', KJo: 'call', QJo: 'call',
};
const BY_ID = new Map(CATEGORIES.map((c) => [c.id, c]));
// 13×13 の表の (行, 列) のハンド。右上がスーテッド、左下がオフスート
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const category = (id) => BY_ID.get(id) || BY_ID.get('gray');
const labelOf = (id) => category(id).label;
const tierOf = (hand) => category(categoryOf(hand)).tier;
const categoryOfTier = (tier) => CATEGORIES.find((c) => c.tier === tier) || BY_ID.get('gray');
const facingOf = (hand) => FACING[hand] || null; // '3bet' / 'call' / null(フォールド)
const facingAction = (id) => FACING_ACTIONS.find((a) => a.id === id) || null;
// ["As", "Kd"] → "AKo"
function handKey(cards) {
const [a, b] = cards;
const ia = RANKS.indexOf(a[0]);
const ib = RANKS.indexOf(b[0]);
const hi = ia <= ib ? a : b;
const lo = ia <= ib ? b : a;
if (hi[0] === lo[0]) return hi[0] + lo[0];
return hi[0] + lo[0] + (hi[1] === lo[1] ? 's' : 'o');
}
// 自分より前に誰もコール・レイズしていないとき、後ろの人数(自分を含めず、まだ行動していない人)で
// 参加してよい一番弱い段階。6-max の席に当てはめて、5人以上は UTG の赤(紺は赤の一つ上)、4人は HJ の黄、
// 3人は CO の緑、2人は BTN の白、1人は SB の青。0人(全員降りてBBに回った)は無し
function requiredTier(behind) {
if (behind >= 5) return 5;
if (behind === 4) return 4;
if (behind === 3) return 3;
if (behind === 2) return 1;
if (behind === 1) return 2;
return 0;
}
return {
RANKS, CATEGORIES, RANGE, FACING_ACTIONS, FACING,
handAt, handKey, categoryOf, category, labelOf, tierOf, categoryOfTier, requiredTier, facingOf, facingAction,
};
});
+184 -53
View File
@@ -1,22 +1,8 @@
/* ハンドレンジ学習(管理者専用)。基本のデザインは /assets/style.css を使う */
/* ハンドレンジ学習(管理者専用)。基本のデザインとセグメントは /assets/style.css を使う */
.range-wrap { padding-bottom: 32px; }
/* タブ */
.seg { display: flex; gap: 6px; background: var(--panel); border-radius: 12px; padding: 4px; margin: 12px 0 16px; }
.seg-btn {
flex: 1;
font: inherit;
font-size: 15px;
font-weight: 800;
border: 0;
border-radius: 9px;
padding: 10px 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
.range-tabs {
margin: 0 0 16px;
}
.seg-btn.active { background: var(--panel-2); color: var(--text); }
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
.cat-navy { --c: #20365d; --fg: #fff; }
@@ -25,58 +11,203 @@
.cat-green { --c: #4e8051; --fg: #101910; }
.cat-blue { --c: #6aa5c9; --fg: #14191f; }
.cat-white { --c: #f7f7f7; --fg: #222; }
.cat-gray, .cat-purple { --c: #929292; --fg: #222; }
.cat-gray { --c: #929292; --fg: #222; }
/* レイズを受けたときの対応の枠(3bet は水色、コールは黄)。下地の色と紛れないよう内側に暗い線を添える */
.facing-3bet { --frame: #3fd4ff; }
.facing-call { --frame: #ffe23a; }
.range-cell.facing-3bet,
.range-cell.facing-call {
box-shadow: inset 0 0 0 3px var(--frame), inset 0 0 0 4px rgba(0, 0, 0, 0.45);
}
/* 13×13 の表 */
.range-matrix { display: grid; grid-template-columns: repeat(13, 1fr); gap: 2px; }
.matrix-card {
padding: 6px;
border-radius: 16px;
background: var(--bg-2);
}
.range-matrix {
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 2px;
}
.range-cell {
aspect-ratio: 1;
position: relative;
min-width: 0;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 3px;
border-radius: 4px;
background: var(--c);
color: var(--fg);
font: inherit;
font-size: clamp(8px, 2.4vw, 12px);
font-weight: 800;
font-weight: 700;
letter-spacing: -0.04em;
cursor: pointer;
position: relative;
transition: transform 0.45s var(--ease-spring);
}
.range-cell:active {
transform: scale(0.9);
transition: transform 0.1s ease-out;
}
.range-cell.selected {
z-index: 1;
outline: 2px solid var(--label);
outline-offset: 1px;
transform: scale(1.12);
}
.range-cell.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; }
.range-cell.selected { outline: 2px solid var(--gold); outline-offset: 1px; z-index: 1; }
/* 凡例 */
.range-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 12px; font-size: 12px; color: var(--muted); }
.range-legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch-dot { width: 14px; height: 14px; border-radius: 4px; background: var(--c); flex: none; }
.swatch-dot.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; }
.range-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin: 12px 8px 0;
color: var(--label-2);
font-size: 0.75rem;
}
.range-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.swatch-dot {
width: 14px;
height: 14px;
flex: none;
border-radius: 4px;
background: var(--c);
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2);
}
.swatch-dot.swatch-frame {
background: transparent;
box-shadow: inset 0 0 0 3px var(--frame);
}
/* 選択したハンド */
.sel-hand { font-size: 28px; font-weight: 900; line-height: 1.1; }
.sel-label { display: flex; align-items: center; gap: 8px; font-weight: 800; margin-top: 6px; }
.selected-card {
margin-top: 16px;
}
.sel-hand {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
}
.sel-label {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-weight: 600;
}
.selected-card .hint {
margin-top: 6px;
}
/* クイズ */
.quiz-progress { height: 6px; background: var(--input); border-radius: 999px; overflow: hidden; margin: 16px 0 8px; }
.quiz-progress i { display: block; height: 100%; width: 0; background: var(--green); transition: width 0.2s; }
.quiz-stats { display: flex; justify-content: center; gap: 12px; font-size: 13px; color: var(--muted); }
.quiz-hand { text-align: center; margin: 12px 0; padding: 18px 12px; }
.quiz-cards { display: flex; justify-content: center; gap: 10px; min-height: 0; }
.quiz-cards img { width: min(32%, 120px); height: auto; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); }
.quiz-hand-name { font-size: 40px; font-weight: 900; margin-top: 8px; line-height: 1.1; }
.quiz-answers { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.quiz-answers .btn {
font-size: 15px;
padding: 12px 8px;
justify-content: flex-start;
background: var(--panel-2);
color: var(--text);
border: 2px solid transparent;
.quiz-progress {
height: 6px;
margin: 18px 0 8px;
overflow: hidden;
border-radius: 999px;
background: var(--fill-2);
}
.quiz-progress i {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--tint);
transition: width 0.5s var(--ease-spring);
}
.quiz-stats {
display: flex;
justify-content: center;
gap: 14px;
color: var(--label-2);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.quiz-hand {
margin: 14px 0;
padding: 20px 12px;
text-align: center;
}
.quiz-cards {
display: flex;
justify-content: center;
gap: 10px;
}
.quiz-cards img {
width: min(32%, 120px);
height: auto;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3));
animation: card-in 0.55s var(--ease-spring) both;
}
.quiz-cards img + img {
animation-delay: 70ms;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(-14px) scale(0.92) rotate(-4deg);
}
}
.quiz-hand-name {
margin-top: 10px;
font-size: 2.5rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.025em;
}
.quiz-answers {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.quiz-answers .btn {
justify-content: flex-start;
padding: 12px 12px;
background: var(--bg-2);
color: var(--label);
font-size: 0.9375rem;
box-shadow: inset 0 0 0 0 transparent;
transition: transform 0.45s var(--ease-spring), box-shadow 0.25s ease, background-color 0.25s ease;
}
.quiz-answers .btn:disabled {
opacity: 1;
}
.quiz-answers .btn.correct {
background: var(--tint-soft);
box-shadow: inset 0 0 0 2px var(--tint);
}
.quiz-answers .btn.wrong {
background: rgba(255, 59, 48, 0.12);
box-shadow: inset 0 0 0 2px var(--red);
}
.quiz-feedback {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
animation: fade-up 0.45s var(--ease-spring) both;
}
.quiz-feedback.good {
box-shadow: inset 0 0 0 2px var(--tint);
}
.quiz-feedback.bad {
box-shadow: inset 0 0 0 2px var(--red);
}
@media (prefers-reduced-motion: reduce) {
.quiz-cards img,
.quiz-feedback {
animation: fade-in 0.2s ease both;
}
.range-cell.selected {
transform: none;
}
}
.quiz-answers .btn:disabled { opacity: 1; }
.quiz-answers .btn.correct { border-color: var(--green); background: rgba(46, 204, 113, 0.18); }
.quiz-answers .btn.wrong { border-color: var(--red); background: rgba(255, 90, 90, 0.15); }
.quiz-feedback { margin-top: 12px; padding: 12px 14px; border-radius: 10px; background: var(--panel); font-size: 14px; }
.quiz-feedback.good { box-shadow: inset 0 0 0 2px var(--green); }
.quiz-feedback.bad { box-shadow: inset 0 0 0 2px var(--red); }
+22 -13
View File
@@ -2,43 +2,50 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<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">
<link rel="stylesheet" href="/range/files/range.css">
</head>
<body>
<main class="wrap range-wrap">
<div class="topbar">
<a class="icon-btn" href="/" aria-label="ロビーへ戻る">←</a>
<h1>ハンドレンジ</h1>
<body class="page-range">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/" aria-label="ロビーへ戻る"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<div class="seg" id="tabs">
<main class="wrap range-wrap">
<h1 class="large-title" id="large-title">ハンドレンジ</h1>
<div class="segmented range-tabs" id="tabs">
<button type="button" class="seg-btn active" data-view="range">レンジ表</button>
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
</div>
<!-- レンジ表 -->
<section id="view-range">
<div class="matrix-card">
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
</div>
<div class="range-legend" id="legend"></div>
<div class="card mt-12" id="selected">
<div class="card selected-card" id="selected">
<div class="sel-hand">—</div>
<div class="hint">セルをタップすると分類を確認できます</div>
</div>
<button type="button" class="btn btn-orange btn-block mt-12" id="start-from-range">クイズを始める</button>
<button type="button" class="btn btn-tint btn-lg btn-block mt-12" id="start-from-range">クイズを始める</button>
</section>
<!-- クイズ -->
<section id="view-quiz" hidden>
<div class="label">問題数(全169ハンドからランダム)</div>
<div class="seg" id="count">
<div class="segmented" id="count">
<button type="button" class="seg-btn active" data-count="10">10問</button>
<button type="button" class="seg-btn" data-count="20">20問</button>
<button type="button" class="seg-btn" data-count="0">全問</button>
@@ -64,12 +71,14 @@
<div class="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
<div class="row mt-12">
<button type="button" class="btn btn-dark" id="start-quiz">開始</button>
<button type="button" class="btn btn-blue" id="next-question" disabled>次の問題 →</button>
<button type="button" class="btn btn-lg" id="start-quiz">開始</button>
<button type="button" class="btn btn-tint btn-lg" id="next-question" disabled>次の問題</button>
</div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/range/files/range-data.js"></script>
<script src="/range/files/range.js"></script>
</body>
</html>
+34 -38
View File
@@ -3,39 +3,11 @@
'use strict';
const $ = (id) => document.getElementById(id);
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const { RANKS, CATEGORIES, FACING_ACTIONS, handAt, categoryOf, labelOf, facingOf, facingAction } = window.HandRange;
const CATEGORIES = [
{ id: 'navy', label: '8人(強)' },
{ id: 'red', label: '8人(弱)' },
{ id: 'yellow', label: '6〜7人' },
{ id: 'green', label: '4〜5人' },
{ id: 'blue', label: '3人' },
{ id: 'white', label: '2人' },
{ id: 'purple', label: '紫枠' },
{ id: 'gray', label: 'フォールド' },
];
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
55: 'green', '54s': 'purple',
44: 'blue',
33: 'blue',
22: 'blue',
};
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const labelOf = (id) => (CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]).label;
Fluid.navBar($('nav'), $('large-title'));
const tabsSeg = Fluid.Segmented($('tabs'));
Fluid.Segmented($('count'));
function swatch(id) {
const s = document.createElement('span');
@@ -43,11 +15,19 @@
return s;
}
// レイズを受けたときの対応の枠(3bet は水色、コールは黄)
function frameSwatch(id) {
const s = document.createElement('span');
s.className = `swatch-dot swatch-frame facing-${id}`;
return s;
}
// ---------- タブ ----------
function showView(name) {
$('view-range').hidden = name !== 'range';
$('view-quiz').hidden = name !== 'quiz';
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.view === name);
for (const b of $('tabs').querySelectorAll('[data-view]')) b.classList.toggle('active', b.dataset.view === name);
tabsSeg.update();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-view]');
@@ -60,6 +40,11 @@
item.append(swatch(c.id), c.label);
$('legend').append(item);
}
for (const a of FACING_ACTIONS) {
const item = document.createElement('span');
item.append(frameSwatch(a.id), `レイズを受けたら${a.label}`);
$('legend').append(item);
}
for (let r = 0; r < 13; r++) {
for (let c = 0; c < 13; c++) {
@@ -67,9 +52,10 @@
const cat = categoryOf(hand);
const b = document.createElement('button');
b.type = 'button';
b.className = `range-cell cat-${cat}`;
const facing = facingOf(hand);
b.className = `range-cell cat-${cat}${facing ? ` facing-${facing}` : ''}`;
b.textContent = hand;
b.title = `${hand}:${labelOf(cat)}`;
b.title = `${hand}:${labelOf(cat)}${facing ? `/レイズを受けたら${facingAction(facing).label}` : ''}`;
b.addEventListener('click', () => select(b, hand, cat));
$('matrix').append(b);
}
@@ -86,10 +72,17 @@
const label = document.createElement('div');
label.className = 'sel-label';
label.append(swatch(cat), labelOf(cat));
const facing = facingOf(hand);
const vs = document.createElement('div');
vs.className = 'sel-label';
if (facing) vs.append(frameSwatch(facing), `レイズを受けたら${facingAction(facing).label}`);
else vs.append('レイズを受けたらフォールド');
const note = document.createElement('div');
note.className = 'hint';
note.textContent = cat === 'purple' ? '灰色+紫枠。クイズの答えは「紫枠」です。' : 'このハンドの分類です。';
box.append(name, label, note);
note.textContent =
(cat === 'gray' ? 'どのポジションからもオープンしない手です。' : `${labelOf(cat)} からオープンできる一番狭い分類です。`) +
'枠は CO のオープンを BTN で受けたときの対応です。';
box.append(name, label, vs, note);
}
$('start-from-range').addEventListener('click', () => {
@@ -111,7 +104,7 @@
const b = e.target.closest('[data-count]');
if (!b) return;
quizCount = Number(b.dataset.count);
for (const x of $('count').children) x.classList.toggle('active', x === b);
for (const x of $('count').querySelectorAll('[data-count]')) x.classList.toggle('active', x === b);
});
function shuffle(a) {
@@ -198,6 +191,9 @@
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
fb.hidden = false;
// 間違えたら選んだボタンが揺れる(振動と同じ瞬間に)
if (!ok) Fluid.shake(button);
Fluid.haptic(ok ? 10 : [18, 50, 18]);
updateStats();
$('next-question').disabled = false;
}
+465
View File
@@ -0,0 +1,465 @@
/* CPU戦のレビュー(管理者専用)。基本のデザイン・セグメント・リストは /assets/style.css、
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
.score-great { color: var(--green); }
.score-good { color: #64b96a; }
.score-warn { color: var(--orange); }
.score-bad { color: var(--red); }
.score-none { color: var(--label-2); }
/* 対局の一覧 */
.rv-sessions {
margin-bottom: 28px;
}
.rv-session {
-webkit-appearance: none;
appearance: none;
width: 100%;
border: 0;
text-align: left;
}
.rv-session-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
/* 概要とタイル(ウィジェットのような小さな面) */
.rv-overview {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.rv-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.rv-tile {
display: grid;
gap: 2px;
min-width: 0;
padding: 12px 14px;
border-radius: 16px;
background: var(--bg-2);
}
.rv-tile span {
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.rv-tile b {
font-family: var(--font-num);
font-size: 1.5rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.rv-tile small {
color: var(--label-2);
font-size: 0.6875rem;
line-height: 1.45;
}
.rv-tabs {
margin-bottom: 14px;
}
.rv-tabs .seg-btn {
padding: 4px 2px;
font-size: 0.8125rem;
}
#tab-body > .hint,
.review-wrap p.hint {
margin: 10px 4px;
}
/* 表 */
.rv-table-wrap {
margin-top: 8px;
overflow-x: auto;
border-radius: 14px;
background: var(--bg-2);
}
.rv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.rv-table th,
.rv-table td {
padding: 9px 8px;
border-bottom: 0.5px solid var(--separator);
text-align: left;
white-space: nowrap;
}
.rv-table th {
color: var(--label-2);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.rv-table .num {
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.rv-table tbody tr[data-hand] {
cursor: pointer;
transition: background-color 0.35s ease;
}
.rv-table tbody tr[data-hand]:active {
background: var(--fill-3);
transition: none;
}
.rv-table tbody tr[data-hand]:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
@media (hover: hover) {
.rv-table tbody tr[data-hand]:hover {
background: var(--fill-3);
}
}
.rv-table tfoot td {
border-bottom: 0;
font-weight: 700;
}
.rv-hand-cell b {
margin-right: 2px;
}
.rv-table .rv-color {
padding: 1px 6px;
font-size: 0.6875rem;
}
.rv-leaks {
display: grid;
gap: 6px;
margin: 0;
padding: 12px 14px 12px 32px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
}
.rv-leaks b {
color: var(--orange);
}
/* 見直したいハンド */
.rv-worst {
overflow: hidden;
border-radius: 14px;
background: var(--bg-2);
}
.rv-worst-item {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
display: grid;
grid-template-columns: 32px 1fr;
gap: 2px 10px;
align-items: center;
padding: 11px 14px;
border: 0;
background: transparent;
color: var(--label);
text-align: left;
cursor: pointer;
transition: background-color 0.35s ease;
}
.rv-worst-item + .rv-worst-item::before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 56px;
border-top: 0.5px solid var(--separator);
}
.rv-worst-item:active {
background: var(--fill-3);
transition: none;
}
.rv-rank {
grid-row: span 2;
color: var(--orange);
font-family: var(--font-num);
font-size: 1.375rem;
font-weight: 700;
text-align: center;
}
.rv-worst-meta {
color: var(--label-2);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.rv-worst-item .rv-color {
width: 14px;
height: 14px;
padding: 0;
vertical-align: -2px;
}
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
.rv-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin: 0 4px 8px;
font-size: 0.8125rem;
}
.sw {
display: inline-block;
width: 16px;
height: 0;
margin-right: 6px;
border-top: 2px solid;
vertical-align: middle;
}
.sw.actual {
border-color: var(--orange);
}
.sw.ev {
border-color: var(--blue);
border-top-style: dashed;
}
.rv-chart {
position: relative;
padding: 10px;
border-radius: 16px;
background: var(--bg-2);
}
.rv-chart svg {
display: block;
width: 100%;
height: auto;
touch-action: pan-y;
}
.rv-chart .gl {
stroke: var(--separator);
stroke-width: 0.5;
}
.rv-chart .zero {
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 2 3;
}
.rv-chart .tick {
fill: var(--label-2);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rv-chart .line {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.rv-chart .line.actual {
stroke: var(--orange);
}
.rv-chart .line.ev {
stroke: var(--blue);
stroke-dasharray: 6 4;
}
.rv-chart .cross {
stroke: var(--label-3);
stroke-width: 1;
}
.rv-chart .dot {
stroke: var(--bg-2);
stroke-width: 2;
}
.rv-chart .dot.actual {
fill: var(--orange);
}
.rv-chart .dot.ev {
fill: var(--blue);
}
.rv-chart .hit {
fill: transparent;
cursor: crosshair;
}
.rv-tip {
position: absolute;
top: 10px;
padding: 7px 11px;
border-radius: 12px;
background: var(--material-thick);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow: var(--shadow-m), inset 0 0 0 0.5px var(--material-edge);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
/* リプレイ */
.rv-note {
margin-top: 10px;
padding: 10px 12px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.8125rem;
}
.rv-copy {
margin-top: 10px;
}
.rv-board-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 14px;
padding: 12px 14px;
border-radius: 18px;
background: radial-gradient(120% 140% at 30% 20%, var(--felt-hi), var(--felt) 50%, var(--felt-lo));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25), inset 0 8px 20px rgba(0, 0, 0, 0.25);
}
.rv-board {
display: flex;
gap: 4px;
}
.rv-pot {
color: #fff;
font-family: var(--font-num);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.rv-step {
min-height: 68px;
margin-top: 10px;
padding: 10px 14px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
}
.rv-step-title {
margin-bottom: 2px;
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
}
.rv-controls {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 8px;
}
.rv-controls .btn {
padding: 10px 0;
}
.rv-controls .btn .i {
width: 20px;
height: 20px;
}
.rv-analysis {
margin-top: 10px;
padding: 4px 14px 12px;
border-radius: 14px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1.5px var(--orange);
}
.rv-ev {
margin-top: 6px;
}
.rv-ev tr.actual td {
background: rgba(255, 149, 0, 0.1);
}
.rv-ev .best {
color: var(--green);
}
.rv-players {
display: grid;
gap: 6px;
margin-top: 12px;
}
.rv-player {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 12px;
border-radius: 14px;
background: var(--bg-2);
box-shadow: inset 0 0 0 0 transparent;
transition: box-shadow 0.3s ease, opacity 0.3s ease;
}
.rv-player.hero {
box-shadow: inset 0 0 0 1.5px var(--tint-soft);
}
.rv-player.acting {
box-shadow: inset 0 0 0 2px var(--orange);
}
.rv-player.folded {
opacity: 0.5;
}
.rv-player-info {
min-width: 0;
font-size: 0.875rem;
}
.rv-player-act {
color: var(--label-2);
font-size: 0.75rem;
font-weight: 700;
text-align: right;
}
.rv-pos {
margin-left: 4px;
color: var(--tint);
font-size: 0.6875rem;
font-weight: 700;
}
.rv-log {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rv-log-street {
width: 100%;
margin-top: 6px;
color: var(--label-2);
font-size: 0.6875rem;
font-weight: 600;
}
.rv-log-item {
-webkit-appearance: none;
appearance: none;
padding: 5px 10px;
border: 0;
border-radius: 999px;
background: var(--bg-2);
color: var(--label);
font-size: 0.75rem;
cursor: pointer;
box-shadow: inset 0 0 0 0.5px var(--separator);
transition: background-color 0.25s ease, color 0.25s ease, transform 0.45s var(--ease-spring);
}
.rv-log-item:active {
transform: scale(0.94);
transition: transform 0.1s ease-out;
}
.rv-log-item .hint {
margin: 0 4px;
font-size: 0.6875rem;
}
.rv-log-item.mine {
box-shadow: inset 0 0 0 1.5px var(--tint);
}
.rv-log-item.current {
background: var(--orange);
color: var(--on-orange);
}
.rv-log-item.current .hint {
color: rgba(0, 0, 0, 0.6);
}
.plus {
color: var(--green);
}
.minus {
color: var(--red);
}
+69
View File
@@ -0,0 +1,69 @@
<!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>CPU戦のレビュー - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/range/files/review.css">
</head>
<body class="page-review">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" id="back" href="/" aria-label="戻る"><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>
</div>
</header>
<main class="wrap review-wrap">
<h1 class="large-title" id="title">CPU戦のレビュー</h1>
<!-- 対局の一覧 -->
<section id="view-list">
<div class="group-header">レビュー付きCPU戦の対局(新しい順・最大50件)</div>
<div id="sessions" class="list rv-sessions"></div>
</section>
<!-- 1回の対局 -->
<section id="view-session" hidden>
<div class="rv-overview" id="overview"></div>
<div class="segmented rv-tabs" id="tabs">
<button type="button" class="seg-btn active" data-tab="scores">点数表</button>
<button type="button" class="seg-btn" data-tab="stats">成績</button>
<button type="button" class="seg-btn" data-tab="range">色・位置</button>
<button type="button" class="seg-btn" data-tab="worst">見直し</button>
<button type="button" class="seg-btn" data-tab="graph">収支</button>
</div>
<div id="tab-body"></div>
</section>
<!-- 1ハンドのリプレイ -->
<section id="view-replay" hidden>
<div class="rv-replay-head" id="replay-head"></div>
<div class="rv-board-box">
<div class="rv-board" id="replay-board"></div>
<div class="rv-pot" id="replay-pot"></div>
</div>
<div class="rv-step" id="replay-step" aria-live="polite"></div>
<div class="rv-controls">
<button type="button" class="btn" data-step="first" aria-label="最初へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5.5" width="2.4" height="13" rx="1"/><path d="M19 6.6v10.8a.9.9 0 0 1-1.4.75L9.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
<button type="button" class="btn" data-step="prev" aria-label="前へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 6.6v10.8a.9.9 0 0 1-1.4.75L7.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
<button type="button" class="btn btn-tint" data-step="next" aria-label="次へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 7 6.6z"/></svg></button>
<button type="button" class="btn" data-step="last" aria-label="最後へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 5 6.6z"/><rect x="16.6" y="5.5" width="2.4" height="13" rx="1"/></svg></button>
</div>
<div id="replay-analysis"></div>
<div class="rv-players" id="replay-players"></div>
<div class="rv-section">アクション一覧</div>
<div class="rv-log" id="replay-log"></div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/range/files/review.js"></script>
</body>
</html>
+596
View File
@@ -0,0 +1,596 @@
// CPU戦のレビュー(管理者専用): 対局ごとの点数表・成績・色とポジションの傾向・見直したいハンド・収支の推移と、
// 1ハンドずつのリプレイ
(function () {
'use strict';
const { esc, chips, api, toast } = App;
const $ = (id) => document.getElementById(id);
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const TYPE_LABEL = { FOLD: 'フォールド', CHECK: 'チェック', CALL: 'コール', BET: 'ベット', RAISE: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
let session = null;
let tab = 'scores';
let replay = null; // { hand, step }
const CHEVRON = '<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>';
const SPARKLES =
'<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>';
Fluid.navBar($('nav'), $('title'));
const tabsSeg = Fluid.Segmented($('tabs'));
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : '');
const pctText = (v) => (v == null ? '—' : `${v}%`);
const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const colorChip = (h) => `<span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span>`;
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard slot ${extra}"></span>`;
const rank = c[0] === 'T' ? '10' : c[0];
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ---------- 画面の切り替え(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 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);
route();
}
async function route() {
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戦のレビュー';
if (table) setBack(`/table/${table}`, 'テーブル');
else setBack('/', 'ロビー');
show('list');
return loadList();
}
if (!session || session.id !== id || !hand) {
try {
session = await api('GET', `/api/reviews/${encodeURIComponent(id)}`);
} catch (err) {
toast(err.message);
return go({}, false);
}
}
$('title').textContent = session.tableName;
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
setBack(`?s=${encodeURIComponent(id)}`, '対局');
return openReplay(h);
}
}
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 === '/' || href.startsWith('/table/')) return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
});
// ---------- 対局の一覧 ----------
async function loadList() {
let list;
try {
list = await api('GET', '/api/reviews');
} catch (err) {
$('sessions').innerHTML = `<div class="empty">${esc(err.message)}</div>`;
return;
}
if (!list.length) {
$('sessions').innerHTML = '<div class="empty">まだ対局がありません。<br>ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。</div>';
return;
}
$('sessions').innerHTML = list
.map(
(s) => `
<button type="button" class="cell cell-link rv-session" data-id="${esc(s.id)}">
<span class="cell-title">
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
<span class="cell-sub">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
</span>
${CHEVRON}
</button>`
)
.join('');
}
$('sessions').addEventListener('click', (e) => {
const b = e.target.closest('[data-id]');
if (b) go({ s: b.dataset.id });
});
// ---------- 1回の対局 ----------
function renderSession() {
const s = session.summary;
const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0;
$('overview').innerHTML = `
<div class="rv-tile"><span>ハンド数</span><b>${s.stats.hands}</b></div>
<div class="rv-tile"><span>平均点</span><b class="score-${scoreClass(s.total)}">${s.total == null ? '—' : s.total}</b></div>
<div class="rv-tile"><span>収支</span><b class="${signClass(net)}">${signed(net)}</b></div>`;
for (const b of $('tabs').querySelectorAll('[data-tab]')) b.classList.toggle('active', b.dataset.tab === tab);
tabsSeg.update();
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
$('tab-body').innerHTML = '';
render();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-tab]');
if (!b) return;
tab = b.dataset.tab;
renderSession();
});
function scoreCell(v) {
return `<td class="num score-${scoreClass(v)}">${v == null ? '—' : v}</td>`;
}
// 点数表: ハンドごと・ストリートごとの点数
function renderScores() {
const hands = session.hands;
if (!hands.length) {
$('tab-body').innerHTML = '<div class="empty">まだハンドがありません。</div>';
return;
}
const rows = hands
.slice()
.reverse()
.map(
(h) => `
<tr data-hand="${h.number}" tabindex="0">
<td class="num">${h.number}</td>
<td class="rv-hand-cell"><b>${esc(h.hero.key)}</b> ${colorChip(h.hero)}</td>
<td>${esc(h.hero.position)}</td>
${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')}
${scoreCell(h.handScore)}
<td class="num ${signClass(h.hero.net)}">${signed(h.hero.net)}</td>
</tr>`
)
.join('');
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<p class="hint">行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。</p>
<div class="rv-table-wrap">
<table class="rv-table">
<thead><tr><th>#</th><th>ハンド</th><th>位置</th>${STREETS.map((st) => `<th>${STREET_SHORT[st]}</th>`).join('')}<th>平均</th><th>収支</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td></td><td colspan="2">平均</td>${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)}<td></td></tr></tfoot>
</table>
</div>`;
}
$('tab-body').addEventListener('click', (e) => {
const r = e.target.closest('[data-hand]');
if (r) go({ s: session.id, h: r.dataset.hand });
});
$('tab-body').addEventListener('keydown', (e) => {
const r = e.target.closest('[data-hand]');
if (r && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
go({ s: session.id, h: r.dataset.hand });
}
});
// 成績
function renderStats() {
const st = session.summary.stats;
const tiles = [
['VPIP', pctText(st.vpip), '自分から参加した割合'],
['PFR', pctText(st.pfr), 'プリフロップでレイズした割合'],
['3bet', pctText(st.threeBet), `レイズに対してレイズし返した割合(機会 ${st.threeBetOpp}回)`],
['Cベット', pctText(st.cbet), `プリフロップのレイザーがフロップでベットした割合(機会 ${st.cbetOpp}回)`],
['WTSD', pctText(st.wtsd), 'フロップを見てショーダウンまで行った割合'],
['W$SD', pctText(st.wsd), 'ショーダウンで勝った割合'],
['AF', st.af == null ? '∞' : st.af, 'フロップ以降の (ベット+レイズ) ÷ コール'],
];
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<div class="rv-tiles">${tiles.map(([k, v, d]) => `<div class="rv-tile"><span>${k}</span><b>${v}</b><small>${d}</small></div>`).join('')}</div>
<div class="rv-section">ストリートごとの平均点</div>
<div class="rv-streets">${STREETS.map((s) => `<div class="rv-street"><span>${STREET_SHORT[s]}</span><b class="score-${scoreClass(avg[s])}">${avg[s] == null ? '—' : avg[s]}</b></div>`).join('')}</div>
<p class="hint">目安: VPIP 20〜28%・PFR 15〜22%・WTSD 25〜32%・AF 2〜3(ノーリミットのリングゲームで一般的な範囲)。</p>`;
}
// 色・ポジション・クセ
function renderRange() {
const s = session.summary;
const colorRows = s.colors
.map(
(c) => `
<tr>
<td><span class="rv-color cat-${esc(c.id)}">${esc(c.name)}</span> <span class="hint">${esc(c.label)}</span></td>
<td class="num">${c.dealt}</td>
<td class="num">${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'}</td>
<td class="num">${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'}</td>
</tr>`
)
.join('');
const VERDICT = { loose: '参加しすぎ', tight: '降りすぎ', passive: '消極的', aggressive: '攻めすぎ', needless: '不要なフォールド' };
const posRows = s.positions
.map((p) => {
const mistakes = Object.entries(p.verdicts)
.sort((a, b) => b[1] - a[1])
.map(([v, n]) => `${VERDICT[v]} ${n}`)
.join('・');
return `
<tr>
<td>${esc(p.position)}</td>
<td class="num">${p.hands}</td>
<td class="num">${pctText(Math.round((p.played / p.hands) * 1000) / 10)}</td>
<td class="num">${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'}</td>
<td>${mistakes || '—'}</td>
</tr>`;
})
.join('');
const leaks = s.leaks.length
? `<ul class="rv-leaks">${s.leaks.map((l) => `<li>${esc(l.text)} <b>${l.count}回</b>${l.of ? `<span class="hint">(${l.of}回中)</span>` : ''}</li>`).join('')}</ul>`
: '<div class="hint">目立つクセは見つかりませんでした。</div>';
$('tab-body').innerHTML = `
<div class="rv-section">気をつけたいクセ</div>${leaks}
<div class="rv-section">色ごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>色</th><th>配られた</th><th>参加率</th><th>レンジどおり</th></tr></thead>
<tbody>${colorRows}</tbody>
</table></div>
<div class="rv-section">ポジションごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>位置</th><th>ハンド</th><th>参加率</th><th>レンジどおり</th><th>主な間違い</th></tr></thead>
<tbody>${posRows}</tbody>
</table></div>`;
}
// 見直したいハンド
function renderWorst() {
const w = session.summary.worst;
if (!w.length) {
$('tab-body').innerHTML = '<div class="empty">見直したいハンドはありません。すべて満点です。</div>';
return;
}
$('tab-body').innerHTML = `
<p class="hint">フロップ以降で失った期待値(BB)が大きい順。同じならプリフロップの判定が悪い順。</p>
<div class="rv-worst">${w
.map(
(h, i) => `
<button type="button" class="rv-worst-item" data-hand="${h.number}">
<span class="rv-rank">${i + 1}</span>
<span><b>#${h.number} ${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}"></span> ${esc(h.position)}</span>
<span class="rv-worst-meta">−${h.lossBB}BB ・ ${h.handScore == null ? '—' : h.handScore}点 ・ <span class="${signClass(h.net)}">${signed(h.net)}</span></span>
</button>`
)
.join('')}</div>`;
}
// 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支
function renderGraph() {
const g = session.summary.graph;
if (g.length < 2) {
$('tab-body').innerHTML = '<div class="empty">2ハンド以上でグラフを表示します。</div>';
return;
}
const W = 360;
const H = 220;
const pad = { l: 48, r: 10, t: 12, b: 22 };
const values = g.flatMap((p) => [p.net, p.evNet]).concat([0]);
// 目盛りは 1・2・5 × 10^n 刻み
const raw = (Math.max(...values) - Math.min(...values)) / 4 || 1;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((x) => x >= raw);
const min = Math.floor(Math.min(...values) / step) * step;
const max = Math.ceil(Math.max(...values) / step) * step || step;
const money = (v) => (v < 0 ? '−' : '') + chips(Math.abs(v));
const x = (i) => pad.l + (i / (g.length - 1)) * (W - pad.l - pad.r);
const y = (v) => pad.t + ((max - v) / (max - min)) * (H - pad.t - pad.b);
const path = (key) => g.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join('');
let grid = '';
for (let v = min; v <= max + step / 2; v += step) {
const yy = y(v).toFixed(1);
grid += `<line class="gl" x1="${pad.l}" x2="${W - pad.r}" y1="${yy}" y2="${yy}"/><text class="tick" x="${pad.l - 6}" y="${yy}" dy="4" text-anchor="end">${esc(money(Math.round(v)))}</text>`;
}
const last = g[g.length - 1];
$('tab-body').innerHTML = `
<div class="rv-legend">
<span><i class="sw actual"></i>実際の収支 <b class="${signClass(last.net)}">${signed(last.net)}</b></span>
<span><i class="sw ev"></i>運を除いた収支 <b class="${signClass(last.evNet)}">${signed(last.evNet)}</b></span>
</div>
<div class="rv-chart" id="chart">
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="ハンドごとの累計収支">
${grid}
<line class="zero" x1="${pad.l}" x2="${W - pad.r}" y1="${y(0).toFixed(1)}" y2="${y(0).toFixed(1)}"/>
<text class="tick" x="${pad.l}" y="${H - 6}">#${g[0].number}</text>
<text class="tick" x="${W - pad.r}" y="${H - 6}" text-anchor="end">#${last.number}</text>
<path class="line actual" d="${path('net')}"/>
<path class="line ev" d="${path('evNet')}"/>
<line class="cross" id="cross" y1="${pad.t}" y2="${H - pad.b}" visibility="hidden"/>
<circle class="dot actual" id="dot-net" r="4" visibility="hidden"/>
<circle class="dot ev" id="dot-ev" r="4" visibility="hidden"/>
<rect class="hit" x="${pad.l}" y="${pad.t}" width="${W - pad.l - pad.r}" height="${H - pad.t - pad.b}"/>
</svg>
<div class="rv-tip" id="tip" hidden></div>
</div>
<p class="hint">運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。</p>`;
const svg = $('chart').querySelector('svg');
const tip = $('tip');
const move = (clientX) => {
const rect = svg.getBoundingClientRect();
const sx = ((clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1))));
const p = g[i];
const cx = x(i).toFixed(1);
$('cross').setAttribute('x1', cx);
$('cross').setAttribute('x2', cx);
$('cross').setAttribute('visibility', 'visible');
for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) {
$(id).setAttribute('cx', cx);
$(id).setAttribute('cy', y(p[key]).toFixed(1));
$(id).setAttribute('visibility', 'visible');
}
tip.innerHTML = `<b>ハンド #${p.number}</b><div><i class="sw actual"></i>実際 ${signed(p.net)}</div><div><i class="sw ev"></i>運を除く ${signed(p.evNet)}</div>`;
tip.hidden = false;
const left = (x(i) / W) * rect.width;
tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`;
};
const hide = () => {
tip.hidden = true;
for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden');
};
svg.addEventListener('pointermove', (e) => move(e.clientX));
svg.addEventListener('pointerdown', (e) => move(e.clientX));
svg.addEventListener('pointerleave', hide);
}
// ---------- リプレイ ----------
function openReplay(h) {
replay = { hand: h, step: firstHeroStep(h) };
show('replay');
$('replay-head').innerHTML = `
<div class="rv-head">
<div class="rv-cards">${h.hero.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>#${h.number} ${esc(h.hero.key)}</b> ${colorChip(h.hero)}</div>
<div class="rv-sub">${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 <span class="${signClass(h.hero.net)}">${signed(h.hero.net)}</span></div>
</div>
</div>
<div class="rv-streets">${STREETS.map((st) => `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b class="score-${scoreClass(h.streetScores[st])}">${h.streetScores[st] == null ? '—' : h.streetScores[st]}</b></div>`).join('')}</div>
${allInHtml(h)}
<button type="button" class="btn btn-block rv-copy" id="replay-copy">${SPARKLES}AIに質問する用にコピー</button>`;
$('replay-copy').addEventListener('click', () => copyHandPrompt(h));
$('replay-log').innerHTML = logHtml(h);
renderStep();
}
// ハンドの状況を、AI に貼り付けて「自分の判断は良かったか」を質問できる文章にしてコピーする
async function copyHandPrompt(h) {
const url = `/api/reviews/${encodeURIComponent(session.id)}/hands/${h.number}/prompt`;
try {
if (await App.copyText(() => api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます');
} catch (err) {
toast(err.message);
}
}
function allInHtml(h) {
const a = h.hero.allIn;
if (!a) return '';
return `<div class="rv-note">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 <span class="${signClass(a.won - a.ev)}">${signed(a.won - a.ev)}</span>)</div>`;
}
// 最初に見せる場面: hero の最初の判断(無ければ最初)
function firstHeroStep(h) {
const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto);
return i >= 0 ? i : -1;
}
const nameOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.name : `席${seat + 1}`;
};
const posOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.position : '';
};
function actionText(h, a) {
const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : '';
return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`;
}
function logHtml(h) {
let html = '';
let street = null;
h.actions.forEach((a, i) => {
if (a.street !== street) {
street = a.street;
html += `<div class="rv-log-street">${STREET_LABEL[street]}</div>`;
}
const mine = a.seat === h.heroSeat;
html += `<button type="button" class="rv-log-item${mine ? ' mine' : ''}" data-step="${i}">${esc(nameOf(h, a.seat))}<span class="hint">${esc(posOf(h, a.seat))}</span> ${esc(actionText(h, a))}</button>`;
});
html += `<button type="button" class="rv-log-item end" data-step="${h.actions.length}">結果</button>`;
return html;
}
// step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果)
function stateAt(h, step) {
const invested = new Map(h.players.map((p) => [p.seat, 0]));
const post = (seat, amount) => {
const p = h.players.find((x) => x.seat === seat);
if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start));
};
post(h.sbSeat, h.sb);
post(h.bbSeat, h.bb);
const folded = new Set();
const last = new Map();
let pot = h.sb + h.bb;
let street = 'PREFLOP';
const upto = Math.min(step, h.actions.length - 1);
for (let i = 0; i <= upto; i++) {
const a = h.actions[i];
if (a.street !== street) {
street = a.street;
last.clear();
}
invested.set(a.seat, invested.get(a.seat) + a.added);
if (a.type === 'FOLD') folded.add(a.seat);
last.set(a.seat, a);
pot = a.pot + a.added;
}
return { invested, folded, last, pot, street };
}
function renderStep() {
const h = replay.hand;
const step = replay.step;
const end = step >= h.actions.length;
const st = stateAt(h, step);
const a = step >= 0 && !end ? h.actions[step] : null;
const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP'];
let board = '';
for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : '<span class="pcard slot"></span>';
$('replay-board').innerHTML = board;
$('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`;
if (end) {
const lines = (h.results || []).map((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : '';
return `<div>${tag}<b>${r.winners.map((w) => esc(nameOf(h, w))).join('・')}</b> +${chips(r.amount)}${r.handName ? ` <span class="hint">${esc(r.handName)}</span>` : ''}</div>`;
});
$('replay-step').innerHTML = `<div class="rv-step-title">結果</div>${lines.join('')}`;
} else if (a) {
$('replay-step').innerHTML = `<div class="rv-step-title">${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}</div>
<div><b>${esc(nameOf(h, a.seat))}</b><span class="hint">(${esc(posOf(h, a.seat))})</span> ${esc(actionText(h, a))}</div>
<div class="hint">ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}</div>`;
} else {
$('replay-step').innerHTML = `<div class="rv-step-title">ハンド開始</div><div class="hint">SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}</div>`;
}
$('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : '';
const order = h.players.slice();
$('replay-players').innerHTML = order
.map((p) => {
const folded = st.folded.has(p.seat);
const acting = a && a.seat === p.seat;
const l = st.last.get(p.seat);
const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' ');
const won = end && p.won > 0 ? `<span class="plus">+${chips(p.won)}</span>` : '';
return `
<div class="${cls}">
<div class="rv-cards">${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}</div>
<div class="rv-player-info">
<div><b>${esc(p.name)}</b>${p.bot ? ' <span class="hint">CPU</span>' : ''} <span class="rv-pos">${esc(p.position)}</span></div>
<div class="hint">残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}</div>
</div>
<div class="rv-player-act">${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}</div>
</div>`;
})
.join('');
for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step);
const atStart = step <= -1;
const atEnd = end;
$('view-replay').querySelector('[data-step="first"]').disabled = atStart;
$('view-replay').querySelector('[data-step="prev"]').disabled = atStart;
$('view-replay').querySelector('[data-step="next"]').disabled = atEnd;
$('view-replay').querySelector('[data-step="last"]').disabled = atEnd;
}
// hero の判断の詳しい判定
function analysisHtml(h, index) {
const j = h.preflop.find((x) => x.index === index);
if (j) {
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[j.mark]}">${j.mark}</span><div>
<div><b>ハンドレンジ</b> ${colorChip(h.hero)} <span class="rv-score">${j.score}点</span></div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div></div>`;
}
const d = h.decisions.find((x) => x.index === index);
if (!d) return '';
const best = Math.max(...d.options.map((o) => o.evBB));
const rows = d.options
.map((o) => {
const isActual = o.kind === d.actual.kind && o.to === d.actual.to;
const isBest = o.evBB === best;
return `<tr class="${isActual ? 'actual' : ''}"><td>${optionLabel(o)}${isActual ? ' <span class="hint">← あなた</span>' : ''}</td><td class="num ${isBest ? 'best' : ''}">${o.evBB > 0 ? '+' : ''}${o.evBB}BB</td></tr>`;
})
.join('');
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[d.mark]}">${d.mark}</span><div>
<div><b>${STREET_LABEL[d.street]}の判断</b> <span class="rv-score">${d.score}点</span>${d.lossBB > 0 ? ` <span class="hint">(${d.lossBB}BB 損)</span>` : ''}</div>
<div class="rv-sub">相手の推定レンジに対する勝率 <b>${d.equity}%</b>${d.need != null ? ` / コールに必要な勝率 <b>${d.need}%</b> → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}</div>
</div></div>
<table class="rv-table rv-ev"><thead><tr><th>選択肢</th><th>期待値の目安</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
function stepTo(target) {
if (!replay) return;
replay.step = Math.max(-1, Math.min(replay.hand.actions.length, target));
renderStep();
}
$('view-replay').addEventListener('click', (e) => {
const b = e.target.closest('[data-step]');
if (!b || !replay) return;
const v = b.dataset.step;
const n = replay.hand.actions.length;
if (v === 'first') stepTo(-1);
else if (v === 'prev') stepTo(replay.step - 1);
else if (v === 'next') stepTo(replay.step + 1);
else if (v === 'last') stepTo(n);
else stepTo(Number(v));
});
document.addEventListener('keydown', (e) => {
if ($('view-replay').hidden || e.target.closest('input, textarea')) return;
if (e.key === 'ArrowLeft') stepTo(replay.step - 1);
if (e.key === 'ArrowRight') stepTo(replay.step + 1);
});
route();
})();
+178 -3
View File
@@ -28,8 +28,46 @@
}
return t;
},
// テーブルで自分の収支を表示するか(設定画面で切り替える。既定は表示)
get showNet() { return lsGet('poker.showNet') !== '0'; },
set showNet(v) { lsSet('poker.showNet', v ? '1' : '0'); },
// テーブル作成画面の開始スタックの初期値(設定画面で変えられ、テーブルを作るたびに前回値で上書きする)
get defaultStack() {
const n = Number(lsGet('poker.defaultStack'));
return Number.isInteger(n) && n >= 10 && n <= 100000000 ? n : 1000;
},
set defaultStack(v) { lsSet('poker.defaultStack', String(v)); },
// 前回テーブルを作ったときの SB/BB と、そのときの開始スタック { stack, sb, bb }
get lastBlinds() {
try {
const b = JSON.parse(lsGet('poker.lastBlinds'));
return b && [b.stack, b.sb, b.bb].every(Number.isInteger) && b.sb >= 1 && b.bb > b.sb && b.bb <= b.stack ? b : null;
} catch {
return null;
}
},
set lastBlinds(v) { lsSet('poker.lastBlinds', JSON.stringify(v)); },
};
// 数値の入力欄: フォーカスしたら全体を選択して、そのまま打ち替えられるようにする
function selectOnFocus(el) {
let skipMouseUp = false;
el.addEventListener('focus', () => {
el.select();
skipMouseUp = true;
// iOS Safari はタップの処理の後で選択が外れるので、もう一度選ぶ
setTimeout(() => {
if (document.activeElement === el) el.select();
}, 0);
});
// クリックで入ったときは、離した位置にカーソルが置かれて選択が外れるのを防ぐ
el.addEventListener('mouseup', (e) => {
if (skipMouseUp) e.preventDefault();
skipMouseUp = false;
});
el.addEventListener('keydown', () => { skipMouseUp = false; });
}
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
@@ -38,18 +76,109 @@
return '$' + Number(n).toLocaleString('en-US');
}
// 上から降りてきて、少ししたら同じ道を上へ戻る通知
let toastTimer;
function toast(msg) {
let el = document.querySelector('.toast');
if (!el) {
el = document.createElement('div');
el.className = 'toast';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
document.body.appendChild(el);
void el.offsetWidth; // 最初の1回も画面の外から動かす
}
el.textContent = msg;
el.hidden = false;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
toastTimer = setTimeout(() => el.classList.remove('show'), 2600);
}
// ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。OK なら true で解決する。
// opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) }
// 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる
let closeConfirm = null;
function confirmDialog(opts) {
const o = typeof opts === 'string' ? { message: opts } : opts;
if (closeConfirm) closeConfirm(false);
const backdrop = document.createElement('div');
backdrop.className = 'alert-backdrop';
backdrop.innerHTML =
'<div class="alert" role="alertdialog" aria-modal="true">' +
'<div class="alert-body">' +
(o.title ? '<h2 class="alert-title"></h2>' : '') +
'<p class="alert-message"></p>' +
'</div>' +
'<div class="alert-buttons">' +
'<button type="button" class="alert-btn" data-confirm="cancel"></button>' +
`<button type="button" class="alert-btn ${o.danger ? 'destructive' : 'default'}" data-confirm="ok"></button>` +
'</div></div>';
const dialog = backdrop.firstChild;
const msgEl = dialog.querySelector('.alert-message');
msgEl.textContent = o.message || '';
msgEl.id = 'confirm-message-' + randomHex(4);
dialog.setAttribute('aria-describedby', msgEl.id);
if (o.title) {
const titleEl = dialog.querySelector('.alert-title');
titleEl.textContent = o.title;
titleEl.id = 'confirm-title-' + randomHex(4);
dialog.setAttribute('aria-labelledby', titleEl.id);
}
const okBtn = dialog.querySelector('[data-confirm="ok"]');
const cancelBtn = dialog.querySelector('[data-confirm="cancel"]');
okBtn.textContent = o.ok || 'OK';
cancelBtn.textContent = o.cancel || 'キャンセル';
// 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる
let closing = false;
const appear = new Fluid.Spring(0, { precision: 0.002 });
appear.onUpdate = (v) => {
backdrop.style.setProperty('--appear', v.toFixed(3));
dialog.style.opacity = v >= 1 ? '' : v.toFixed(3);
dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`;
};
const prevFocus = document.activeElement;
return new Promise((resolve) => {
function close(result) {
if (closeConfirm !== close) return;
closeConfirm = null;
closing = true;
document.removeEventListener('keydown', onKey, true);
backdrop.style.pointerEvents = 'none';
appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => backdrop.remove() });
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus();
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
close(false);
} else if (e.key === 'Tab') {
// フォーカスをダイアログの2つのボタンの中に閉じ込める
e.preventDefault();
(document.activeElement === okBtn ? cancelBtn : okBtn).focus();
}
}
okBtn.addEventListener('click', () => close(true));
cancelBtn.addEventListener('click', () => close(false));
// 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く)
let downOnBackdrop = false;
backdrop.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === backdrop;
});
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop && downOnBackdrop) close(false);
});
document.addEventListener('keydown', onKey, true);
closeConfirm = close;
document.body.appendChild(backdrop);
appear.set(0);
appear.to(1, { damping: 1, response: 0.3, allowReduced: true });
// 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする
(o.danger ? cancelBtn : okBtn).focus();
});
}
async function api(method, url, body) {
@@ -68,6 +197,52 @@
return data;
}
// 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。
// サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に
// Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう
async function copyText(load) {
const pending = load();
try {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
const blob = pending.then((t) => new Blob([t], { type: 'text/plain' }));
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
return true;
}
} catch { /* 次の方法へ */ }
const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す
try {
await navigator.clipboard.writeText(text);
return true;
} catch { /* 手でコピーしてもらう */ }
showCopyDialog(text);
return false;
}
function showCopyDialog(text) {
const root = document.createElement('div');
root.className = 'sheet-backdrop';
root.hidden = true;
root.innerHTML =
'<div class="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>' +
'<h2 class="sheet-title">コピーしてください</h2></div>' +
'<div class="sheet-scroll">' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'</div>' +
'<div class="sheet-footer"><button type="button" class="btn btn-lg btn-block copy-close">閉じる</button></div>' +
'</div>';
const area = root.querySelector('textarea');
area.value = text;
document.body.appendChild(root);
const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() });
root.querySelector('.copy-close').addEventListener('click', () => sheet.close());
sheet.open();
area.focus({ preventScroll: true });
area.select();
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
}
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
function connectSocket() {
const socket = io({ transports: ['websocket'] });
@@ -96,5 +271,5 @@
FINISHING: 'FINISHING',
};
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
window.App = { Store, esc, chips, toast, selectOnFocus, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
})();
+30 -10
View File
@@ -1,7 +1,7 @@
(function () {
'use strict';
const { Store, api, chips } = App;
const { Store, api, chips, selectOnFocus } = App;
if (!Store.name) {
location.replace('/');
return;
@@ -16,8 +16,8 @@
const timerInput = $('timer');
const intervalInput = $('interval');
const scheduleEl = $('schedule');
const modeHint = $('mode-hint');
const cpuOptions = $('cpu-options');
const intervalRow = $('interval-row');
const blindHint = $('blind-hint');
const errorEl = $('error');
const submitBtn = $('submit');
@@ -49,7 +49,7 @@
const stack = int(stackInput);
const bb = int(bbInput);
if (blindsTouched && stack > 0 && bb > 0) {
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link">おすすめに戻す</a>`;
} else {
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
}
@@ -60,6 +60,7 @@
const bb = int(bbInput);
const on = timerInput.checked;
intervalInput.disabled = !on;
setOpen(intervalRow, on);
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
if (scheduleEl.hidden) return;
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
@@ -68,17 +69,17 @@
.join('');
}
const MODE_HINTS = {
chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ',
friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動',
cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動',
};
const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
// 行の開け閉め(高さはばねで変わる)。閉じている間はフォーカスも届かないようにする
function setOpen(el, open) {
el.classList.toggle('collapsed', !open);
el.inert = !open;
}
function updateMode() {
const mode = radioValue('mode');
modeHint.textContent = MODE_HINTS[mode] || '';
cpuOptions.hidden = mode !== 'cpu';
setOpen(cpuOptions, mode === 'cpu' || mode === 'review');
}
function refresh() {
@@ -108,7 +109,10 @@
timerInput.addEventListener('change', refresh);
intervalInput.addEventListener('input', refresh);
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
[stackInput, sbInput, bbInput, intervalInput].forEach(selectOnFocus);
$('reroll').addEventListener('click', suggestName);
Fluid.Segmented(form.querySelector('.segmented.numbers'));
Fluid.navBar($('nav'), $('large-title'));
function validate() {
const name = nameInput.value.trim();
@@ -149,6 +153,9 @@
cpuCount: Number(radioValue('cpu-count')),
token: Store.token,
});
// 次にテーブルを作るときの初期値にする
Store.defaultStack = int(stackInput);
Store.lastBlinds = { stack: int(stackInput), sb: int(sbInput), bb: int(bbInput) };
location.href = '/table/' + encodeURIComponent(id);
} catch (err) {
errorEl.textContent = err.message;
@@ -156,7 +163,20 @@
}
});
// レビュー付きCPU戦は管理者だけが作れる(サーバでも確認する)
api('GET', '/api/me')
.then((me) => { $('review-mode').hidden = !me.admin; })
.catch(() => {});
// 開始スタックは設定(=前回値)。SB/BB は前回と同じ開始スタックなら前回値を引き継ぐ
stackInput.value = Store.defaultStack;
applyDefaultBlinds();
const last = Store.lastBlinds;
if (last && last.stack === Store.defaultStack && (last.sb !== int(sbInput) || last.bb !== int(bbInput))) {
sbInput.value = last.sb;
bbInput.value = last.bb;
blindsTouched = true;
}
refresh();
suggestName();
})();
+640
View File
@@ -0,0 +1,640 @@
// 動きと手ざわりの共通部品。
// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
// - Popup: 画面の中央に出るポップアップ(背景を押す・Esc で閉じる)
// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
(function () {
'use strict';
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const reducedMotion = () => motionQuery.matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
// ---------- ばね ----------
const running = new Set();
let frame = 0;
let lastTime = 0;
function tick(now) {
// タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
const dt = clamp((now - lastTime) / 1000, 0, 0.05);
lastTime = now;
for (const s of [...running]) s.step(dt);
frame = running.size ? requestAnimationFrame(tick) : 0;
}
class Spring {
// opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
constructor(value = 0, opts = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.precision = opts.precision || 0.01;
this.onUpdate = opts.onUpdate || null;
this.onRest = null;
this.configure(opts.damping ?? 1, opts.response ?? 0.4);
}
// 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
// damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
configure(damping, response) {
this.damping = damping;
this.response = response;
this.stiffness = ((2 * Math.PI) / response) ** 2;
this.friction = (4 * Math.PI * damping) / response;
}
get animating() {
return running.has(this);
}
// 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
// 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
to(target, opts = {}) {
if (opts.damping != null || opts.response != null) {
this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
}
if (opts.velocity != null) this.velocity = opts.velocity;
this.target = target;
this.onRest = opts.onRest || null;
if (reducedMotion() && !opts.allowReduced) {
this.set(target);
this.rest();
return this;
}
running.add(this);
if (!frame) {
lastTime = performance.now();
frame = requestAnimationFrame(tick);
}
return this;
}
// すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
set(value) {
running.delete(this);
this.value = this.target = value;
this.velocity = 0;
if (this.onUpdate) this.onUpdate(value);
return this;
}
// 画面上のいまの位置で止める(動いている最中につかんだとき)
stop() {
running.delete(this);
this.target = this.value;
this.velocity = 0;
this.onRest = null;
return this;
}
step(dt) {
// 硬いばねでも発散しないよう 1/240 秒刻みで積分する
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i++) {
const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
this.velocity += force * h;
this.value += this.velocity * h;
}
const settled =
Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
if (settled) {
this.value = this.target;
this.velocity = 0;
running.delete(this);
}
if (this.onUpdate) this.onUpdate(this.value);
if (settled) this.rest();
}
rest() {
const fn = this.onRest;
this.onRest = null;
if (fn) fn();
}
}
// ---------- 指の動き ----------
// 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
class VelocityTracker {
constructor() {
this.samples = [];
}
reset() {
this.samples.length = 0;
}
add(x, y, t = performance.now()) {
this.samples.push({ x, y, t });
while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
}
velocity() {
const s = this.samples;
if (s.length < 2) return { x: 0, y: 0 };
const a = s[0];
const b = s[s.length - 1];
const dt = (b.t - a.t) / 1000;
return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
}
}
// 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
function project(velocity, decelerationRate = 0.998) {
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
}
// 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
function trapFocus(box, e) {
const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (!box.contains(document.activeElement)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ---------- シート ----------
// root は背景(.sheet-backdrop)、中に .sheet。
// [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
// 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
// opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Sheet(root, opts = {}) {
const sheet = root.querySelector('.sheet');
const dismissible = opts.dismissible !== false;
const y = new Spring(0, { precision: 0.5 });
const tracker = new VelocityTracker();
let height = 0;
let isOpen = false;
let prevFocus = null;
let drag = null;
let downOnBackdrop = false;
const measure = () => {
height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
};
y.onUpdate = (v) => {
sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
};
function onKey(e) {
if (e.key === 'Escape' && dismissible) {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(sheet, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
if (root.hidden) {
root.hidden = false;
measure();
y.set(height);
}
y.to(0, { damping: 1, response: 0.38 });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? sheet.querySelector(opts.focus) : null;
(target || sheet).focus({ preventScroll: true });
}
function close(velocity) {
if (!isOpen) return;
isOpen = false;
document.removeEventListener('keydown', onKey);
measure();
y.to(height, {
damping: 1,
response: 0.3,
velocity: typeof velocity === 'number' ? velocity : undefined,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (dismissible && e.target === root && downOnBackdrop) close();
});
sheet.addEventListener('pointerdown', (e) => {
if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
if (!e.target.closest('[data-sheet-drag]')) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, e.clientY, e.timeStamp);
});
sheet.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
if (!drag.active) {
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (Math.hypot(dx, dy) < 8) return;
if (Math.abs(dx) > Math.abs(dy)) {
drag = null;
return;
}
// ここで初めてつかむ。動いている最中なら画面上のいまの位置から
drag.active = true;
drag.y = e.clientY;
y.stop();
drag.origin = y.value;
measure();
sheet.setPointerCapture(e.pointerId);
sheet.classList.add('dragging');
if (!isOpen) {
isOpen = true;
document.addEventListener('keydown', onKey);
}
}
const raw = drag.origin + (e.clientY - drag.y);
y.set(raw < 0 ? -rubberband(-raw, height) : raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
sheet.classList.remove('dragging');
if (!wasActive) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
const v = tracker.velocity().y;
if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
// 払った勢いがあるので少しだけ弾ませて戻す
else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
};
sheet.addEventListener('pointerup', end);
sheet.addEventListener('pointercancel', end);
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- ポップアップ ----------
// root は背景(.popup-backdrop)、中に .popup。画面の中央に少し大きい所から縮んで現れ、閉じるときはその場で薄くなる
// (App.confirm のアラートと同じ動き)。背景を押す・Esc で閉じる。
// opts: { focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Popup(root, opts = {}) {
const box = root.querySelector('.popup');
const appear = new Spring(0, { precision: 0.002 });
let isOpen = false;
let closing = false;
let prevFocus = null;
let downOnBackdrop = false;
appear.onUpdate = (v) => {
root.style.setProperty('--appear', v.toFixed(3));
box.style.opacity = v >= 1 ? '' : v.toFixed(3);
box.style.transform = closing || v >= 1 || reducedMotion() ? '' : `scale(${(1.08 - 0.08 * v).toFixed(4)})`;
};
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(box, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
closing = false;
if (root.hidden) {
root.hidden = false;
appear.set(0);
}
root.style.pointerEvents = '';
appear.to(1, { damping: 1, response: 0.32, allowReduced: true });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? box.querySelector(opts.focus) : null;
(target || box).focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
closing = true;
document.removeEventListener('keydown', onKey);
root.style.pointerEvents = 'none'; // 消えている途中の背景で次の操作を止めない
appear.to(0, {
damping: 1,
response: 0.2,
allowReduced: true,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
// 背景を押したら閉じる(ポップアップの中から背景へのドラッグは除く)
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (e.target === root && downOnBackdrop) close();
});
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- セグメント ----------
// container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
// 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
// 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
function Segmented(container) {
const thumb = document.createElement('span');
thumb.className = 'segmented-thumb';
thumb.setAttribute('aria-hidden', 'true');
container.prepend(thumb);
container.classList.add('has-thumb');
const x = new Spring(0, { precision: 0.1 });
const w = new Spring(0, { precision: 0.1 });
const press = new Spring(1, { precision: 0.001 });
const tracker = new VelocityTracker();
let placed = false;
let drag = null;
let suppressClick = false;
const paint = () => {
thumb.style.width = `${w.value.toFixed(2)}px`;
thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
};
x.onUpdate = w.onUpdate = press.onUpdate = paint;
const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
const isSelected = (el) =>
el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
const center = (el) => el.offsetLeft + el.offsetWidth / 2;
function sync(animate = true) {
if (drag && drag.active) return;
const el = items().find(isSelected);
thumb.hidden = !el;
if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
if (animate && placed) {
if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
} else {
x.set(el.offsetLeft);
w.set(el.offsetWidth);
}
placed = true;
}
function select(el) {
if (isSelected(el)) return;
if (el.matches('label')) {
const input = el.querySelector('input');
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.click();
}
}
container.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const el = items().find((i) => i.contains(e.target));
if (!el || !isSelected(el)) return;
drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, 0, e.timeStamp);
press.to(0.95, { damping: 1, response: 0.2 });
});
container.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, 0, e.timeStamp);
if (!drag.active) {
if (Math.abs(e.clientX - drag.x) < 6) return;
drag.active = true;
drag.x = e.clientX;
x.stop();
drag.origin = x.value;
container.setPointerCapture(e.pointerId);
}
const list = items();
const last = list[list.length - 1];
const min = list[0].offsetLeft;
const max = last.offsetLeft + last.offsetWidth - w.value;
let raw = drag.origin + (e.clientX - drag.x);
if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
x.set(raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
press.to(1, { damping: 1, response: 0.25 });
if (!wasActive) return;
suppressClick = true;
setTimeout(() => {
suppressClick = false;
}, 0);
tracker.add(e.clientX, 0, e.timeStamp);
const v = tracker.velocity().x;
const aim = x.value + w.value / 2 + project(v, 0.99);
const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
w.to(best.offsetWidth, { damping: 1, response: 0.3 });
select(best);
};
container.addEventListener('pointerup', end);
container.addEventListener('pointercancel', end);
// ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
container.addEventListener(
'click',
(e) => {
if (!suppressClick) return;
e.preventDefault();
e.stopPropagation();
},
true
);
container.addEventListener('change', () => sync());
// ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
container.addEventListener('click', () => requestAnimationFrame(() => sync()));
if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
sync(false);
return { update: sync };
}
// ---------- 出し入れ ----------
// 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
// from: { scale, y }。視差効果を減らす設定では透明度だけ変える
function presence(el, show, from = {}) {
let p = el._presence;
if (!p) {
p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
el.hidden = !show;
}
const scale = from.scale ?? 0.96;
const dy = from.y ?? 8;
p.onUpdate = (v) => {
el.style.opacity = v >= 1 ? '' : v.toFixed(3);
el.style.transform =
v >= 1 || reducedMotion()
? ''
: `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
};
if (show) {
if (el.hidden) {
el.hidden = false;
p.set(0);
}
if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
} else if (!el.hidden && p.target !== 0) {
p.to(0, {
damping: 1,
response: 0.22,
allowReduced: true,
onRest: () => {
el.hidden = true;
},
});
}
}
// 数字をばねで変える(ポット・スタックなど)
function countTo(el, value, format) {
const show = (v) => {
el.textContent = format(Math.round(v));
};
let s = el._count;
if (!s) {
s = el._count = new Spring(value, { precision: 0.5 });
s.onUpdate = show;
show(value);
return;
}
s.onUpdate = show;
if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
}
// 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
function shake(el) {
if (reducedMotion()) return;
let s = el._shake;
if (!s) {
s = el._shake = new Spring(0, { precision: 0.05 });
s.onUpdate = (v) => {
el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
};
}
s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
}
// 振動(対応している端末だけ。画面に触れる前は振動できない)
function haptic(pattern = 10) {
try {
const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
if (navigator.vibrate && touched) navigator.vibrate(pattern);
} catch {
/* 振動できない環境 */
}
}
// ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
// 大きな見出しが隠れたらバーの中に小さな見出しを出す
function navBar(nav, largeTitle) {
if (!nav) return;
const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
update();
window.addEventListener('scroll', update, { passive: true });
const inline = nav.querySelector('.nav-title');
if (!largeTitle || !inline) return;
const mirror = () => {
inline.textContent = largeTitle.textContent;
};
mirror();
new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
if ('IntersectionObserver' in window) {
new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
}).observe(largeTitle);
}
}
// ---------- 拡大させない ----------
// iOS Safari は viewport の user-scalable=no を無視するので、ピンチは gesture* イベントと
// 2本指の touchmove を止めて防ぐ。ダブルタップの拡大は CSS の touch-action で止めている
// (JS で 2回目のタップを止めると、素早い連打のクリックまで消えてしまうため)
const stopZoom = (e) => e.preventDefault();
for (const type of ['gesturestart', 'gesturechange', 'gestureend']) {
document.addEventListener(type, stopZoom, { passive: false });
}
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length > 1) e.preventDefault();
},
{ passive: false },
);
window.Fluid = {
Spring,
VelocityTracker,
project,
rubberband,
clamp,
reducedMotion,
Sheet,
Popup,
Segmented,
presence,
countTo,
shake,
haptic,
navBar,
};
})();
+12 -3
View File
@@ -16,12 +16,18 @@
const value = () => inputs.map((i) => i.value).join('');
// 数字が入った枠は小さく弾む(空に戻ったら印を外す)
function markFilled() {
for (const i of inputs) i.classList.toggle('filled', Boolean(i.value));
}
function fill(digits, from) {
let i = from;
for (const d of digits) {
if (i >= inputs.length) break;
inputs[i++].value = d;
}
markFilled();
inputs[Math.min(i, inputs.length - 1)].focus();
if (value().length === 6) submit();
}
@@ -31,11 +37,13 @@
const digits = input.value.replace(/\D/g, '');
input.value = '';
if (digits) fill(digits, idx);
else markFilled();
errorEl.textContent = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && idx > 0) {
inputs[idx - 1].value = '';
markFilled();
inputs[idx - 1].focus();
e.preventDefault();
} else if (e.key === 'ArrowLeft' && idx > 0) {
@@ -68,10 +76,11 @@
location.replace(App.safeNext(next));
} catch (err) {
errorEl.textContent = err.message;
box.classList.remove('shake');
void box.offsetWidth;
box.classList.add('shake');
// 揺れと振動は同じ瞬間に(どちらも「違う」の合図)
Fluid.shake(box);
Fluid.haptic([18, 50, 18]);
inputs.forEach((i) => { i.value = ''; });
markFilled();
inputs[0].focus();
} finally {
busy = false;
+218 -31
View File
@@ -6,6 +6,9 @@
const nameError = document.getElementById('name-error');
const listEl = document.getElementById('list');
const countEl = document.getElementById('count');
const footerEl = document.getElementById('list-footer');
Fluid.navBar(document.getElementById('nav'), document.getElementById('large-title'));
nameInput.value = Store.name;
nameInput.addEventListener('input', () => {
@@ -22,9 +25,17 @@
return false;
}
// 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404)
let admin = false;
let tables = null;
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
// (ページ・API 自体もサーバ側で管理者以外は 404)
App.api('GET', '/api/me')
.then((me) => { document.getElementById('range-link').hidden = !me.admin; })
.then((me) => {
admin = Boolean(me.admin);
document.getElementById('admin-group').hidden = !admin;
if (tables) render(tables);
})
.catch(() => {});
document.getElementById('create').addEventListener('click', () => {
@@ -32,46 +43,222 @@
});
listEl.addEventListener('click', (e) => {
const del = e.target.closest('[data-delete]');
if (del) return deleteTable(del.dataset.delete, del);
const btn = e.target.closest('[data-join]');
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦' };
function phaseBadge(t) {
if (t.paused) return '<span class="badge paused">PAUSED</span>';
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
return '<span class="badge live">PLAYING</span>';
async function deleteTable(id, btn) {
const t = tables && tables.find((x) => x.id === id);
if (!t) return;
const players = t.playerCount ? `
着席中の ${t.playerCount} 人はテーブルから退出させられます。` : '';
const ok = await App.confirm({
title: 'テーブルを削除',
message: `「${t.name}」を削除しますか?${players}`,
ok: '削除する',
danger: true,
});
const row = btn.closest('.swipe-row');
if (!ok || !document.contains(btn)) {
if (row && row.swipe) row.swipe.close();
return;
}
btn.disabled = true;
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
.then(() => App.toast('テーブルを削除しました'))
.catch((err) => {
btn.disabled = false;
if (row && row.swipe) row.swipe.close();
App.toast(err.message);
});
}
function render(tables) {
const ICON = {
chips:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
cards:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg>',
cpu:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg>',
review:
'<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>',
person: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0"/></svg>',
clock: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
trash:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 7h15M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12a2 2 0 0 0 2 1.8h5a2 2 0 0 0 2-1.8l1-12M10.5 11v6M13.5 11v6"/></svg>',
};
const MODE = {
chips: { label: 'キャッシュオンリー', icon: 'chips', tone: 'green' },
friends: { label: '友人戦', icon: 'cards', tone: 'blue' },
cpu: { label: 'CPU戦', icon: 'cpu', tone: 'indigo' },
review: { label: 'レビュー付きCPU戦', icon: 'review', tone: 'orange' },
};
function statusHtml(t) {
if (t.paused) return '<span class="status paused">一時停止中</span>';
if (t.phase === 'WAITING') return '<span class="status waiting">待機中</span>';
return '<span class="status live">プレイ中</span>';
}
function rowHtml(t) {
const m = MODE[t.mode] || MODE.chips;
const full = t.playerCount >= t.maxSeats;
const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし';
return `
<span class="cell-icon tone-${m.tone}">${ICON[m.icon]}</span>
<span class="table-row-main">
<span class="name">${esc(t.name)}</span>
<span class="table-row-meta">${statusHtml(t)}<span>${m.label}</span></span>
<span class="table-row-meta">
<span class="meta-item">${ICON.person}${t.playerCount}/${t.maxSeats}</span>
<span>Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}</span>
<span>開始 ${chips(t.startingStack)}</span>
<span class="meta-item">${ICON.clock}${timer}</span>
</span>
</span>
<button type="button" class="btn-get" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>${full ? '観戦' : '参加'}</button>`;
}
// ---------- 左へスワイプして削除(管理者) ----------
// 行は指に 1:1 で付いてきて、削除ボタンの幅を越えるとゴムのように抵抗する。
// 離したときは速度から止まる位置を先読みし、半分を越えていれば開いたまま止める
const ACTION_WIDTH = 88;
let openRow = null;
function attachSwipe(row) {
const content = row.querySelector('.table-row');
const action = row.querySelector('.swipe-action');
const x = new Fluid.Spring(0, { precision: 0.2 });
const tracker = new Fluid.VelocityTracker();
let drag = null;
let suppressClick = false;
x.onUpdate = (v) => {
content.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
row.style.setProperty('--reveal', Fluid.clamp(-v / ACTION_WIDTH, 0, 1).toFixed(3));
};
const settle = (open, velocity) => {
x.to(open ? -ACTION_WIDTH : 0, { damping: velocity ? 0.8 : 1, response: 0.3, velocity });
if (open) openRow = row;
else if (openRow === row) openRow = null;
};
row.swipe = { close: () => settle(false) };
content.addEventListener('pointerdown', (e) => {
if (!admin || (e.pointerType === 'mouse' && e.button !== 0)) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, e.clientY, e.timeStamp);
});
content.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
if (!drag.active) {
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (Math.hypot(dx, dy) < 10) return;
// 縦の動きはページのスクロールに任せる
if (Math.abs(dy) > Math.abs(dx)) {
drag = null;
return;
}
drag.active = true;
drag.x = e.clientX;
x.stop();
drag.origin = x.value;
content.setPointerCapture(e.pointerId);
if (openRow && openRow !== row) openRow.swipe.close();
}
let raw = drag.origin + (e.clientX - drag.x);
if (raw > 0) raw = Fluid.rubberband(raw, row.offsetWidth);
else if (raw < -ACTION_WIDTH) raw = -ACTION_WIDTH - Fluid.rubberband(-ACTION_WIDTH - raw, row.offsetWidth);
x.set(raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
if (!wasActive) return;
suppressClick = true;
setTimeout(() => {
suppressClick = false;
}, 0);
tracker.add(e.clientX, e.clientY, e.timeStamp);
const v = tracker.velocity().x;
settle(x.value + Fluid.project(v) < -ACTION_WIDTH / 2, v);
};
content.addEventListener('pointerup', end);
content.addEventListener('pointercancel', end);
content.addEventListener(
'click',
(e) => {
// スワイプの後のクリックや、開いている行のタップ(参加ボタン以外)は「閉じる」だけにする
const tapOnOpenRow = openRow === row && !e.target.closest('[data-join]');
if (!suppressClick && !tapOnOpenRow) return;
e.preventDefault();
e.stopPropagation();
if (!suppressClick) row.swipe.close();
},
true
);
// キーボードでは削除ボタンにフォーカスが来たら開く
action.addEventListener('focus', () => settle(true));
action.addEventListener('blur', () => settle(false));
}
document.addEventListener('pointerdown', (e) => {
if (openRow && !openRow.contains(e.target)) openRow.swipe.close();
});
// ---------- 一覧(行は使い回す。スワイプの途中で作り直さない) ----------
const rows = new Map();
function rowEl(id) {
let row = rows.get(id);
if (row) return row;
row = document.createElement('div');
row.className = 'swipe-row';
row.innerHTML = `<button type="button" class="swipe-action" data-delete="${esc(id)}">${ICON.trash}<span>削除</span></button><div class="table-row"></div>`;
attachSwipe(row);
rows.set(id, row);
return row;
}
function render(list) {
tables = list;
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
footerEl.hidden = !admin || !tables.length;
if (!tables.length) {
rows.clear();
openRow = null;
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
return;
}
listEl.innerHTML = tables
.map((t) => {
const full = t.playerCount >= t.maxSeats;
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
return `
<div class="table-item">
<div class="info">
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
<div class="meta">
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
<span>開始 <b>${chips(t.startingStack)}</b></span>
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
<span>${timer}</span>
<span>${MODE_LABEL[t.mode] || ''}</span>
</div>
</div>
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
${full ? '観戦' : '参加'}
</button>
</div>`;
})
.join('');
const empty = listEl.querySelector('.empty');
if (empty) empty.remove();
const ids = new Set(tables.map((t) => t.id));
for (const [id, row] of rows) {
if (ids.has(id)) continue;
if (openRow === row) openRow = null;
row.remove();
rows.delete(id);
}
tables.forEach((t, i) => {
const row = rowEl(t.id);
const html = rowHtml(t);
const content = row.querySelector('.table-row');
if (content.html !== html) {
content.html = html;
content.innerHTML = html;
}
const action = row.querySelector('.swipe-action');
action.hidden = !admin;
action.tabIndex = admin ? 0 : -1;
if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
});
}
const socket = App.connectSocket();
+34
View File
@@ -0,0 +1,34 @@
// 設定: この端末だけに保存する(localStorage)
(function () {
'use strict';
const { Store, selectOnFocus } = App;
const $ = (id) => document.getElementById(id);
Fluid.navBar($('nav'), $('large-title'));
const showNet = $('show-net');
showNet.checked = Store.showNet;
showNet.addEventListener('change', () => {
Store.showNet = showNet.checked;
Fluid.haptic(6);
});
const stack = $('default-stack');
stack.value = Store.defaultStack;
selectOnFocus(stack);
// 正しい値のときだけ保存する。外したときに不正なら保存済みの値に戻す
stack.addEventListener('input', () => {
const n = Number(stack.value);
const ok = Number.isInteger(n) && n >= 10 && n <= 100000000;
stack.classList.toggle('invalid', stack.value !== '' && !ok);
if (ok) Store.defaultStack = n;
});
stack.addEventListener('blur', () => {
stack.classList.remove('invalid');
stack.value = Store.defaultStack;
});
stack.addEventListener('keydown', (e) => {
if (e.key === 'Enter') stack.blur();
});
})();
+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();
})();
+3836 -648
View File
File diff suppressed because it is too large. Load diff
+1025 -151
View File
File diff suppressed because it is too large. Load diff
+94 -37
View File
@@ -2,37 +2,73 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<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>
<main class="wrap">
<div class="topbar">
<a class="icon-btn" href="/" aria-label="戻る">←</a>
<h1>テーブルを作成</h1>
<body class="page-create">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap">
<h1 class="large-title" id="large-title">テーブルを作成</h1>
<form id="form" novalidate>
<label for="table-name">テーブル名</label>
<div class="row">
<section class="group">
<div class="group-header"><label for="table-name">テーブル名</label></div>
<div class="list">
<div class="cell field-cell">
<input id="table-name" type="text" maxlength="24" required>
<button type="button" class="icon-btn" id="reroll" aria-label="別の名前にする">🎲</button>
<button type="button" class="icon-plain" id="reroll" aria-label="別の名前にする" title="別の名前にする">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4"/><circle cx="9" cy="9" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="15" cy="15" r="1.4" fill="currentColor" stroke="none"/></svg>
</button>
</div>
<div class="label" id="mode-label">テーブルの種類</div>
<div class="segmented" role="radiogroup" aria-labelledby="mode-label">
<label><input type="radio" name="mode" value="chips" checked><span>キャッシュ<br>オンリー</span></label>
<label><input type="radio" name="mode" value="friends"><span>友人戦</span></label>
<label><input type="radio" name="mode" value="cpu"><span>CPU戦</span></label>
</div>
<div class="hint" id="mode-hint"></div>
</section>
<div id="cpu-options" hidden>
<div class="label" id="cpu-label">CPU の人数</div>
<section class="group">
<div class="group-header" id="mode-label">テーブルの種類</div>
<div class="list" role="radiogroup" aria-labelledby="mode-label">
<label class="cell choice">
<input type="radio" name="mode" value="chips" checked>
<span class="cell-icon tone-green"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg></span>
<span class="choice-text"><span class="cell-title">キャッシュオンリー</span><span class="cell-sub">実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<label class="cell choice">
<input type="radio" name="mode" value="friends">
<span class="cell-icon tone-blue"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg></span>
<span class="choice-text"><span class="cell-title">友人戦</span><span class="cell-sub">各自のスマホに手札が配られ、勝敗とポットの分配は自動</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<label class="cell choice">
<input type="radio" name="mode" value="cpu">
<span class="cell-icon tone-indigo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg></span>
<span class="choice-text"><span class="cell-title">CPU戦</span><span class="cell-sub">CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<!-- 管理者だけに表示 -->
<label class="cell choice" id="review-mode" hidden>
<input type="radio" name="mode" value="review">
<span class="cell-icon tone-orange"><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></span>
<span class="choice-text"><span class="cell-title">レビュー付きCPU戦</span><span class="cell-sub">あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
</div>
</section>
<div class="collapsible collapsed" id="cpu-options" inert>
<div class="collapse-inner">
<section class="group">
<div class="group-header" id="cpu-label">CPU の人数</div>
<div class="segmented numbers" role="radiogroup" aria-labelledby="cpu-label">
<label><input type="radio" name="cpu-count" value="1"><span>1</span></label>
<label><input type="radio" name="cpu-count" value="2"><span>2</span></label>
@@ -43,39 +79,60 @@
<label><input type="radio" name="cpu-count" value="7"><span>7</span></label>
<label><input type="radio" name="cpu-count" value="8"><span>8</span></label>
</div>
</section>
</div>
</div>
<section class="group">
<div class="group-header">チップ</div>
<div class="list">
<div class="cell field-cell value-right">
<label for="stack">開始スタック</label>
<input id="stack" type="number" inputmode="numeric" min="10" step="1" value="1000">
<div class="label">開始ブラインド(SB / BB)</div>
<div class="row">
<input id="sb" type="number" inputmode="numeric" min="1" step="1" aria-label="スモールブラインド">
<span class="slash">/</span>
<input id="bb" type="number" inputmode="numeric" min="1" step="1" aria-label="ビッグブラインド">
<input id="stack" type="number" inputmode="numeric" min="10" step="1">
</div>
<div class="hint" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
<div class="cell field-cell value-right">
<label for="sb">スモールブラインド</label>
<input id="sb" type="number" inputmode="numeric" min="1" step="1">
</div>
<div class="cell field-cell value-right">
<label for="bb">ビッグブラインド</label>
<input id="bb" type="number" inputmode="numeric" min="1" step="1">
</div>
</div>
<div class="group-footer" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
</section>
<div class="switch-row">
<span>ブラインドタイマー</span>
<label class="switch">
<input id="timer" type="checkbox" aria-label="ブラインドタイマー">
<section class="group">
<div class="group-header">ブラインドタイマー</div>
<div class="list">
<div class="cell">
<span class="cell-title" id="timer-label">ブラインドを自動で上げる</span>
<span class="switch">
<input id="timer" type="checkbox" aria-labelledby="timer-label">
<span></span>
</label>
</span>
</div>
<div class="collapsible collapsed" id="interval-row" inert>
<div class="collapse-inner">
<div class="cell field-cell value-right">
<label for="interval">インターバル</label>
<div class="unit-input">
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
<span class="unit">min</span>
<span class="unit">分</span>
</div>
</div>
</div>
</div>
<div class="schedule" id="schedule" hidden></div>
</section>
<div class="error-text" id="error"></div>
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">テーブルを作成</button>
<div class="submit-area">
<div class="error-text" id="error" role="alert"></div>
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">テーブルを作成</button>
</div>
</form>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/blinds.js"></script>
<script src="/assets/create.js"></script>
+15 -9
View File
@@ -2,20 +2,25 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<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>
<main class="wrap">
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
<body class="page-key">
<main class="key-screen">
<div class="app-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
</div>
<h1 class="brand">Hold'em Night</h1>
<p class="brand-sub">PRIVATE CLUB</p>
<form id="form" class="card" autocomplete="off">
<form id="form" class="key-form" autocomplete="off">
<h2 class="key-title" id="key-title">参加キーを入力</h2>
<p class="hint key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
<p class="key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
<div class="key-inputs" id="digits">
<input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code">
<input type="tel" inputmode="numeric" aria-label="2桁目">
@@ -24,10 +29,11 @@
<input type="tel" inputmode="numeric" aria-label="5桁目">
<input type="tel" inputmode="numeric" aria-label="6桁目">
</div>
<div class="error-text text-center" id="error"></div>
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">入室する</button>
<p class="error-text" id="error" role="alert"></p>
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">入室する</button>
</form>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/key.js"></script>
</body>
+70 -16
View File
@@ -2,34 +2,88 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<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>
<body class="page-lobby">
<header class="nav-bar" id="nav">
<div class="nav-inner"><span class="nav-title"></span></div>
</header>
<main class="wrap">
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
<label for="name">あなたの名前</label>
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
<div class="error-text" id="name-error"></div>
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
<a id="range-link" class="btn btn-dark btn-block mt-8" href="/range" hidden>♠ ハンドレンジ学習</a>
<div class="section-title">
<span>開催中のテーブル</span>
<span class="sub" id="count"></span>
<div class="hero">
<div class="app-mark small" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
</div>
<div class="table-list" id="list">
<div>
<h1 class="large-title" id="large-title">Hold'em Night</h1>
<p class="brand-sub">PRIVATE CLUB</p>
</div>
</div>
<section class="group">
<div class="group-header">プロフィール</div>
<div class="list">
<div class="cell field-cell">
<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>
<a class="cell cell-link" href="/settings">
<span class="cell-icon tone-gray"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg></span>
<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>
</div>
<div class="error-text" id="name-error" role="alert"></div>
</section>
<div class="lobby-actions">
<button id="create" class="btn btn-tint btn-lg btn-block" type="button">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>テーブルを作成
</button>
</div>
<!-- 管理者だけに表示 -->
<section class="group" id="admin-group" hidden>
<div class="group-header">管理者</div>
<div class="list">
<a id="range-link" class="cell cell-link" href="/range">
<span class="cell-icon tone-purple"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/></svg></span>
<span class="cell-title">ハンドレンジ学習</span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
<a id="review-link" class="cell cell-link" href="/review">
<span class="cell-icon tone-orange"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20v-8M12 20V5M19 20v-9"/></svg></span>
<span class="cell-title">CPU戦のレビュー</span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
</section>
<section class="group">
<div class="group-header">
<span>開催中のテーブル</span>
<span id="count"></span>
</div>
<div class="list" id="list">
<div class="empty">読み込み中…</div>
</div>
<div class="group-footer" id="list-footer" hidden>行を左へスワイプすると削除できます(管理者)。</div>
</section>
</main>
<script src="/socket.io/socket.io.js"></script>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/lobby.js"></script>
</body>
+54
View File
@@ -0,0 +1,54 @@
<!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-settings">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap">
<h1 class="large-title" id="large-title">設定</h1>
<section class="group">
<div class="group-header">テーブル</div>
<div class="list">
<div class="cell">
<span class="cell-title" id="show-net-label">収支を表示</span>
<span class="switch">
<input id="show-net" type="checkbox" aria-labelledby="show-net-label">
<span></span>
</span>
</div>
</div>
<div class="group-footer">オフにすると、テーブルで自分の収支と、ランキングの「収支」が表示されなくなります。この端末だけの設定で、他の人の画面は変わりません。</div>
</section>
<section class="group">
<div class="group-header">テーブルの作成</div>
<div class="list">
<div class="cell field-cell value-right">
<label for="default-stack">開始スタック</label>
<input id="default-stack" type="number" inputmode="numeric" min="10" step="1">
</div>
</div>
<div class="group-footer" id="stack-hint">テーブルを作るときの初期値です。テーブルを作ると、そのときの値(SB・BB も)が次回の初期値になります。</div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/settings.js"></script>
</body>
</html>
+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>
+173 -44
View File
@@ -2,27 +2,47 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<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="#eceef1">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#070c0a">
<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="table-page">
<div class="conn-lost" id="conn-lost" hidden>接続が切れました。再接続中…</div>
<body class="page-table">
<div class="conn-lost" id="conn-lost" role="status" hidden><span class="spinner" aria-hidden="true"></span>接続が切れました。再接続中…</div>
<main class="wrap" id="main" hidden>
<div class="table-header">
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
<main class="table-main" id="main" hidden>
<header class="table-bar">
<button class="glass-btn" id="leave" aria-label="退席する" title="退席する">
<svg class="i icon-leave" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h3.5A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5H14"/><path d="M10 8l-4 4 4 4M6 12h9"/></svg>
<svg class="i icon-back" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<h1 id="table-name"></h1>
<!-- ホストだけに表示 -->
<span class="host-controls" id="host-controls" hidden>
<button class="btn btn-green btn-sm" id="start">START</button>
<button class="icon-btn" id="pause" aria-label="一時停止" title="一時停止">⏸</button>
<button class="btn btn-tint btn-sm" id="start">START</button>
<button class="glass-btn" id="pause" aria-label="一時停止" title="一時停止">
<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>
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
</div>
<!-- レビュー付き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>
</header>
<div class="level-bar">
<span class="lv" id="level"></span>
@@ -35,6 +55,7 @@
</div>
<div class="felt-area" id="felt-area">
<div class="rail">
<div class="felt">
<div class="pot-label">POT</div>
<div class="pot" id="pot">$0</div>
@@ -42,45 +63,32 @@
<div class="phase"><span id="phase">WAITING</span><span class="hand-no" id="hand-no"></span></div>
<div class="result" id="result" hidden></div>
</div>
</div>
<div id="seats"></div>
</div>
<div class="spectator-note" id="spectator-note" hidden>
<span id="spectator-text">満席のため観戦中です</span>
<button class="btn btn-orange btn-sm" id="take-seat" hidden>空席に座る</button>
<button class="btn btn-tint btn-sm" id="take-seat" hidden>空席に座る</button>
</div>
</main>
<div class="action-bar" id="action-bar" hidden>
<section class="action-bar" id="action-bar" hidden>
<div class="inner">
<div class="me-row">
<div class="my-cards" id="my-cards" hidden></div>
<div class="me-info">
<div class="my-stack"><span class="wallet">👛</span><span id="my-stack">$0</span></div>
<div class="my-stack" id="my-stack">$0</div>
<div class="me-sub"><span class="my-hand" id="my-hand"></span><span id="my-net"></span><span class="my-bet" id="my-bet"></span></div>
</div>
<button class="btn btn-ghost btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
<button class="btn btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
</div>
<div class="status-line" id="status-line"></div>
<div class="status-line" id="status-line" aria-live="polite"></div>
<div class="award-panel" id="award-panel" hidden>
<div id="award-pots"></div>
<button class="btn btn-orange btn-block" id="award-confirm" disabled>確定</button>
</div>
<div class="raise-panel" id="raise-panel" hidden>
<div class="presets" id="presets">
<button class="btn btn-dark btn-sm" data-preset="min">Min</button>
<button class="btn btn-dark btn-sm" data-preset="half">½</button>
<button class="btn btn-dark btn-sm" data-preset="threeq">¾</button>
<button class="btn btn-dark btn-sm" data-preset="pot">Pot</button>
<button class="btn btn-dark btn-sm" data-preset="max">All-in</button>
</div>
<div class="raise-row">
<input type="range" id="raise-range" aria-label="レイズ額">
<input type="number" id="raise-amount" inputmode="numeric" aria-label="レイズ額">
</div>
<div class="award-pots" id="award-pots"></div>
<button class="btn btn-tint btn-lg btn-block" id="award-confirm" disabled>確定</button>
</div>
<div class="actions" id="actions">
@@ -88,33 +96,154 @@
<button class="btn act-call" id="act-call" disabled>Check</button>
<button class="btn act-raise" id="act-raise" disabled>Raise</button>
</div>
<div class="actions raising" id="raise-actions" hidden>
<button class="btn btn-dark" id="raise-cancel">戻る</button>
<button class="btn act-raise" id="raise-confirm">Raise</button>
<div class="actions single" id="next-actions" hidden>
<button class="btn" id="review-show" hidden>レビュー</button>
<button class="btn btn-tint" id="next-hand">次のハンドへ</button>
</div>
<div class="actions single" id="alt-actions" hidden>
<button class="btn btn-orange" id="sit-in">復帰する</button>
<button class="btn btn-tint" id="sit-in">復帰する</button>
<button class="btn btn-blue" id="rebuy">リバイ</button>
</div>
</div>
</div>
</section>
<div class="center-message" id="not-found" hidden>
<div class="badge-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.5v.01"/></svg></div>
<h2 id="not-found-title">テーブルが見つかりません</h2>
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
<a class="btn btn-tint btn-lg" href="/">ロビーへ戻る</a>
</div>
<div class="modal-backdrop" id="name-modal" hidden>
<form class="modal" id="name-form">
<h2>名前を入力</h2>
<p class="hint">テーブルで表示される名前です</p>
<input id="name-input" type="text" maxlength="12" placeholder="例: とらのすけ" class="mt-12" autocomplete="nickname">
<button class="btn btn-orange btn-block mt-12" type="submit">参加する</button>
<!-- ベット/レイズ額(Bet・Raise ボタンで下から出るシート。つまみを下へ払うと閉じる)。
候補はプリフロップのオープン・3bet・4bet、フロップ以降のベット・レイズで入れ替わる -->
<div class="sheet-backdrop bet-backdrop" id="bet-modal" hidden>
<div class="sheet bet-sheet" role="dialog" aria-modal="true" aria-labelledby="bet-title" tabindex="-1">
<div class="sheet-header bet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<div class="bet-heading">
<h2 class="sheet-title" id="bet-title">Raise</h2>
<span class="bet-context" id="bet-context"></span>
</div>
</div>
<div class="bet-body">
<div class="bet-amount-row">
<label class="raise-value"><span class="cur" aria-hidden="true">$</span><input type="number" class="raise-amount" id="raise-amount" inputmode="numeric" aria-label="ベット額"></label>
<div class="raise-meta"><b id="raise-bb"></b><span id="raise-ratio"></span></div>
</div>
<div class="bet-presets" id="presets"></div>
<div class="bet-adjust">
<button type="button" class="step-btn" id="raise-minus" aria-label="1BB 減らす">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12"/></svg>
</button>
<div class="bet-slider" id="raise-range" role="slider" tabindex="0" aria-label="ベット額">
<div class="bet-track"><div class="bet-fill"></div></div>
<div class="bet-thumb"></div>
</div>
<button type="button" class="step-btn" id="raise-plus" aria-label="1BB 増やす">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 6v12M6 12h12"/></svg>
</button>
</div>
<div class="bet-ends">
<button type="button" class="end-btn" id="raise-min">Min <span id="raise-min-amt"></span></button>
<button type="button" class="end-btn allin" id="raise-max">All-in <span id="raise-max-amt"></span></button>
</div>
</div>
<div class="sheet-footer">
<div class="btn-row">
<button type="button" class="btn btn-lg" id="raise-cancel">戻る</button>
<button type="button" class="btn btn-lg act-raise" id="raise-confirm">Raise</button>
</div>
</div>
</div>
</div>
<!-- 席順の変更(ホストのみ・画面の中央に出るポップアップ)。自分から時計回りの並びを、行をつかんで並べ替える -->
<div class="popup-backdrop" id="seating-modal" hidden>
<div class="popup seating-popup" role="dialog" aria-modal="true" aria-labelledby="seating-title" tabindex="-1">
<div class="popup-header">
<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">
<div class="sheet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<h2 class="sheet-title" id="review-title">ハンドのレビュー</h2>
</div>
<div class="sheet-scroll">
<div id="review-body"></div>
<button type="button" class="btn btn-block mt-12" id="review-copy">
<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">
<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>
</div>
</div>
<div class="sheet-footer">
<div class="btn-row">
<button type="button" class="btn btn-lg" id="review-close">閉じる</button>
<button type="button" class="btn btn-tint btn-lg" id="review-modal-next">次のハンドへ</button>
</div>
</div>
</div>
</div>
<!-- 名前の入力(閉じられないシート) -->
<div class="sheet-backdrop" id="name-modal" hidden>
<form class="sheet sheet-form" id="name-form" role="dialog" aria-modal="true" aria-labelledby="name-title" tabindex="-1">
<h2 class="sheet-title" id="name-title">名前を入力</h2>
<p class="hint mt-8">テーブルで表示される名前です</p>
<input id="name-input" class="field mt-12" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname" aria-labelledby="name-title">
<button class="btn btn-tint btn-lg btn-block mt-12" type="submit">参加する</button>
</form>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/table.js"></script>
</body>
+6 -1
View File
@@ -71,8 +71,12 @@ function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function isAdminCookieHeader(cookieHeader) {
return roleOf(cookieHeader) === 'admin';
}
function isAdmin(req) {
return roleOf(req.headers.cookie) === 'admin';
return isAdminCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res, role) {
@@ -232,6 +236,7 @@ module.exports = {
isAuthed,
isAdmin,
isAuthedCookieHeader,
isAdminCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
+2
View File
@@ -73,6 +73,8 @@ module.exports = {
BOT_FAST_MS: 400,
// CPU が勝率を見積もるときのシミュレーション回数
BOT_ITERATIONS: 800,
// レビューで勝率を見積もるときのシミュレーション回数(1回の判断・選択肢ごと)
REVIEW_ITERATIONS: 1200,
// 参加者が2人揃ってから次のハンドを始めるまで
NEXT_HAND_DELAY_MS: 2000,
+41 -4
View File
@@ -52,6 +52,12 @@ class Hand {
this.pots = null; // ショーダウンのポット(同じ参加者のポットはまとめる)
this.engineWon = null; // エンジンが分配した額(席番号 → 額)
this.taken = new Map(); // ハンド中に退席した人が持ち出したチップ(エンジンから外れた分)
// アクションの記録(レビュー用)。1手ごとに、そのときの状況と行動を残す
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) {
@@ -73,6 +79,8 @@ class Hand {
const headsUp = n === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
// プリフロップに行動する順(UTG から BB まで)
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
this.engine.deal();
this.sync();
}
@@ -174,7 +182,10 @@ class Hand {
}
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
this.revealed = true;
if (this.board.length < 5) this.runout = true;
if (this.board.length < 5) {
this.runout = true;
this.allIn = { board: this.board.slice(), pots: this.pots };
}
else this.finishShowdown();
}
@@ -229,7 +240,8 @@ class Hand {
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
@@ -254,6 +266,26 @@ class Hand {
}
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
const entry = {
street: this.street,
seat,
type: last.type,
amount: last.amount || 0, // このストリートでの合計額
added: type === 'call' ? o.toCall : type === 'raise' ? amount - p.bet : 0, // この行動で出した額
allIn,
auto,
pot: this.potTotal, // 行動する前のポット(このストリートのベットを含む)
toCall: o.toCall,
currentBet: this.currentBet,
bet: p.bet,
stack: this.seats[seat].stack,
board: this.board.slice(),
canCheck: o.canCheck,
canRaise: o.canRaise,
minRaiseTo: o.minRaiseTo,
maxRaiseTo: o.maxRaiseTo,
live: this.live().length,
};
try {
this.engine.act(this.engineAction(seat, type, amount));
} catch {
@@ -261,6 +293,7 @@ class Hand {
return type === 'fold' && o.canCheck ? 'いまはフォールドできません。チェックしてください' : 'そのアクションはできません';
}
p.last = allIn ? { type: 'ALLIN', amount: last.amount } : last;
this.log.push(entry);
this.actions++;
if (type === 'raise') {
this.aggressor = seat;
@@ -272,7 +305,7 @@ class Hand {
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
@@ -280,7 +313,7 @@ class Hand {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0)) this.autoAct();
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
if (this.settled) {
@@ -308,6 +341,7 @@ class Hand {
this.engine.stand(String(seat));
this.taken.set(seat, (this.taken.get(seat) || 0) + before - this.state.initialChips);
p.last = { type: 'FOLD' };
this.log.push({ street: this.street, seat, type: 'FOLD', amount: 0, added: 0, auto: true, pot: this.potTotal, board: this.board.slice() });
this.sync();
}
@@ -381,6 +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.endedAt = Date.now();
this.finished = true;
this.runout = false;
this.showdown = showdown;
@@ -411,6 +447,7 @@ class Hand {
pot: this.pot,
potTotal: this.potTotal,
currentBet: this.currentBet,
raises: this.raises, // ベット額の候補を 3bet・4bet などに合わせるため
button: this.button,
sbSeat: this.sbSeat,
bbSeat: this.bbSeat,
+88 -10
View File
@@ -7,6 +7,11 @@ const { Server } = require('socket.io');
const config = require('./config');
const auth = require('./auth');
const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const { buildHandPrompt } = require('./review/prompt');
const stats = require('./stats/store');
const { summarize: summarizeStats } = require('./stats/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -86,6 +91,8 @@ 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('/settings', page('settings.html'));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
@@ -96,12 +103,55 @@ app.use(
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'),
// (スクリプトと CSS は更新がすぐ反映されるよう毎回確認させる)
setHeaders: (res, file) =>
res.setHeader('Cache-Control', file.endsWith('.png') ? 'private, max-age=86400' : 'private, no-cache'),
})
);
// ---------- 管理者専用: レビュー付きCPU戦の振り返り ----------
app.get('/review', auth.requireAdmin, page('review.html', ADMIN));
app.get('/api/reviews', auth.requireAdmin, async (req, res, next) => {
try {
res.json(await reviews.list());
} catch (err) {
next(err);
}
});
app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
if (!session) return res.status(404).json({ error: '見つかりません' });
res.json({ ...session, summary: summarize(session.hands) });
} catch (err) {
next(err);
}
});
// 1ハンドの状況を AI に質問するための文章
app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
const hand = session && session.hands.find((h) => String(h.number) === req.params.number);
if (!hand) return res.status(404).json({ error: '見つかりません' });
res.json({ text: buildHandPrompt(hand) });
} catch (err) {
next(err);
}
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list()));
// 自分の統計。プレイヤー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() }));
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -125,8 +175,12 @@ app.post('/api/tables', (req, res) => {
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
if (!intIn(bigBlind, smallBlind + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' });
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
if (!MODES.includes(mode)) return res.status(400).json({ error: 'テーブルの種類が不正です' });
if (mode === 'cpu' && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
// レビュー付きCPU戦は管理者だけが作れる
if (!MODES.includes(mode) || (mode === 'review' && !auth.isAdmin(req))) {
return res.status(400).json({ error: 'テーブルの種類が不正です' });
}
const withBots = mode === 'cpu' || mode === 'review';
if (withBots && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
return res.status(400).json({ error: `CPU の人数は1〜${config.MAX_SEATS - 1}人にしてください` });
}
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
@@ -139,13 +193,22 @@ app.post('/api/tables', (req, res) => {
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
mode,
cpuCount: mode === 'cpu' ? cpuCount : 0,
cpuCount: withBots ? cpuCount : 0,
hostToken,
});
broadcastLobby();
res.json({ id: table.id });
});
// 管理者はどのテーブルでも削除できる(着席者・観戦者にはテーブルが閉じられたことを知らせる)
app.delete('/api/tables/:id', auth.requireAdmin, (req, res) => {
const table = tables.get(req.params.id);
if (!table) return res.status(404).json({ error: 'テーブルが見つかりません' });
console.log(`[admin] テーブル「${table.name}」を削除`);
closeTable(table, null, 'deleted');
res.json({ ok: true });
});
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
// 内部情報(スタックトレース等)を返さない
@@ -183,8 +246,10 @@ function broadcastTable(table) {
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
}
// レビュー付きCPU戦は管理者のロビーにだけ出す
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list());
io.to('lobby').emit('lobby:tables', tables.list(false));
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
}
function changed(table) {
@@ -193,12 +258,13 @@ function changed(table) {
}
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
function closeTable(table, leaver) {
// reason: 'empty'(人間が全員退席した)/ 'deleted'(管理者が削除した)
function closeTable(table, leaver, reason = 'empty') {
tables.delete(table.id);
for (const s of roomSockets(table.id)) {
s.leave(roomOf(table.id));
if (s.data.tableId === table.id) s.data.tableId = null;
if (s !== leaver) s.emit('table:closed');
if (s !== leaver) s.emit('table:closed', { reason });
}
broadcastLobby();
}
@@ -211,6 +277,7 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
// イベントの連打を制限
let windowStart = Date.now();
@@ -226,8 +293,8 @@ io.on('connection', (socket) => {
});
socket.on('lobby:subscribe', () => {
socket.join('lobby');
socket.emit('lobby:tables', tables.list());
socket.join(socket.data.admin ? 'lobby:admin' : 'lobby');
socket.emit('lobby:tables', tables.list(socket.data.admin));
});
socket.on('table:join', (p, ack) => {
@@ -238,6 +305,10 @@ io.on('connection', (socket) => {
const name = cleanText(p.name, 12);
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
return reply(ack, { ok: false, error: 'NOT_FOUND' });
}
const prev = socket.data.tableId && tables.get(socket.data.tableId);
if (prev && prev !== table) {
@@ -278,6 +349,13 @@ io.on('connection', (socket) => {
'table:sitOut': (t, token) => t.setSittingOut(token, true),
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.nextHand(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) => {
+275
View File
@@ -0,0 +1,275 @@
'use strict';
// 1ハンドのレビュー。ハンドが終わったら hero(レビューを受ける人)について作る。
// プリフロップ: 登録してあるハンドレンジの色と、そのとおりに行動できたか(preflop.js)
// フロップ以降: 相手の推定レンジに対する勝率から、選択肢ごとの期待値(BB)を見積もり、
// 一番良い選択肢との差(失った期待値)を点数にする。GTO ではなく目安
// オールイン: 実際の手札どうしの勝率から、収支の期待値(運の影響を除いた収支)を出す
const HandRange = require('../../admin/range-data');
const { judgePreflop, positionOf, describeSituation, ACTION_KIND } = require('./preflop');
const { codes, rangeOf, narrow, rangeEquity, showdownShare } = require('./equity');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// レビューで色に添える説明(クイズ用の分類名がそのままでは分かりにくいもの)
const COLOR_LABEL = { gray: 'レンジ外' };
// 失った期待値がポット(コール後)のこの割合までは誤差として満点にする
const TOLERANCE = 0.03;
// 失った期待値がポット(コール後)の 1% 増えるごとに減らす点数
const PENALTY = 2.5;
// 相手が降りる割合: ベットが損をしない最低限のフォールド率(risk / (risk + reward))より少し低めに見て、
// 相手が多いほど全員は降りにくくする(ただのブラフが得にならないように)
const FOLD_REALISM = 0.7;
const FOLD_MULTIWAY = 0.85;
const markOf = (score) => (score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const round1 = (x) => Math.round(x * 10) / 10;
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
// index より前に降りた席
function foldedBefore(log, index) {
const s = new Set();
for (let i = 0; i < index; i++) if (log[i].type === 'FOLD') s.add(log[i].seat);
return s;
}
// 相手の推定レンジ: プリフロップの色(参加しなかった BB などは全部の手)から、
// フロップ以降にベット・レイズするたびに強い手(いまの場札での役)に絞る。コールでも少し絞る
function opponentRanges(hand, hero, index, estimated, board, dead) {
const log = hand.log;
const folded = foldedBefore(log, index);
const ranges = [];
for (const s of hand.order) {
if (s === hero || folded.has(s)) continue;
let aggr = 0;
let calls = 0;
for (let i = 0; i < index; i++) {
const e = log[i];
if (e.seat !== s || e.street === 'PREFLOP') continue;
if (e.type === 'BET' || e.type === 'RAISE') aggr++;
else if (e.type === 'CALL') calls++;
}
const fraction = Math.max(0.08, 0.55 ** aggr * 0.8 ** calls);
ranges.push(narrow(rangeOf(estimated.get(s) || 0, dead), board, fraction));
}
return ranges;
}
// フロップ以降の1回の判断。選択肢ごとの期待値(いま降りたときを 0 とするチップ)
function evaluateDecision(hand, hero, index, estimated, opts) {
const e = hand.log[index];
const heroCards = codes(hand.players.get(hero).cards);
const board = codes(e.board);
const dead = heroCards.concat(board);
const ranges = opponentRanges(hand, hero, index, estimated, board, dead);
const eq = rangeEquity(heroCards, board, ranges, opts);
const P = e.pot;
const t = e.toCall;
const options = [];
if (t > 0) {
options.push({ kind: 'fold', ev: 0 });
options.push({ kind: 'call', ev: eq * (P + t) - t });
}
if (e.canCheck) options.push({ kind: 'check', ev: eq * P });
if (e.canRaise) {
const clamp = (v) => Math.min(e.maxRaiseTo, Math.max(e.minRaiseTo, Math.round(v)));
const sizes = new Set([clamp(e.currentBet === 0 ? P * 0.66 : e.currentBet * 3)]);
if (ACTION_KIND[e.type] === 'raise') sizes.add(e.amount);
for (const to of sizes) {
const r = to - e.bet; // 自分が出す額
const c = to - e.currentBet; // コールする人が出す額(直前のベットからの差)
// コールされたら、降りなかった強い側のレンジと勝負する
const alpha = r / (P + r);
const foldAll = FOLD_REALISM * alpha * FOLD_MULTIWAY ** Math.max(0, ranges.length - 1);
const eqCalled = rangeEquity(heroCards, board, ranges.map((rg) => narrow(rg, board, 1 - alpha)), opts);
options.push({ kind: 'raise', to, ev: foldAll * P + (1 - foldAll) * (eqCalled * (P + r + c) - r) });
}
}
const kind = ACTION_KIND[e.type];
const actual = options.find((o) => o.kind === kind && (kind !== 'raise' || o.to === e.amount)) || options[0];
const best = options.reduce((a, b) => (b.ev > a.ev ? b : a));
const loss = Math.max(0, best.ev - actual.ev);
const base = P + t;
const score = Math.max(0, Math.min(100, Math.round(100 - (PENALTY * 100 * Math.max(0, loss - TOLERANCE * base)) / base)));
const bb = hand.bb;
return {
index,
street: e.street,
equity: round1(eq * 100),
need: t > 0 ? round1((t / base) * 100) : null,
options: options.map((o) => ({ kind: o.kind, to: o.to, evBB: round1(o.ev / bb) })),
best: { kind: best.kind, to: best.to },
actual: { kind: actual.kind, to: actual.to },
lossBB: round1(loss / bb),
score,
mark: markOf(score),
};
}
// プリフロップでコール・レイズに直面したときの参考: 参加者の推定レンジに対する勝率と必要勝率
function preflopOdds(hand, hero, j, estimated, opts) {
const e = hand.log[j.index];
if (!(e.toCall > 0) || j.situation.kind !== 'facing') return {};
const heroCards = codes(hand.players.get(hero).cards);
const folded = foldedBefore(hand.log, j.index);
const ranges = [];
for (let i = 0; i < j.index; i++) {
const x = hand.log[i];
const k = ACTION_KIND[x.type];
if (x.street !== 'PREFLOP' || x.seat === hero || folded.has(x.seat) || (k !== 'call' && k !== 'raise')) continue;
if (ranges.some((r) => r.seat === x.seat)) continue;
ranges.push({ seat: x.seat, range: rangeOf(estimated.get(x.seat) || 0, heroCards) });
}
if (!ranges.length) return {};
const eq = rangeEquity(heroCards, [], ranges.map((r) => r.range), opts);
return { equity: round1(eq * 100), need: round1((e.toCall / (e.pot + e.toCall)) * 100) };
}
// hero の統計用の記録(VPIP・PFR・3bet・Cベットなど)
function statsOf(hand, hero) {
const mine = hand.log.filter((e) => e.seat === hero && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const post = mine.filter((e) => e.street !== 'PREFLOP');
const me = hand.players.get(hero);
const foldedPre = hand.log.some((e) => e.seat === hero && e.street === 'PREFLOP' && e.type === 'FOLD');
const sawFlop = !foldedPre && hand.board.length >= 3;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
let threeBetOpp = false;
let threeBet = false;
let raises = 0;
let lastRaiser = null;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
if (e.seat === hero && !e.auto && raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = ACTION_KIND[e.type] === 'raise';
}
if (ACTION_KIND[e.type] === 'raise') {
raises++;
lastRaiser = e.seat;
}
}
// Cベット: プリフロップの最後のレイザーが、フロップで誰もベットしていないときにベットしたか
const firstFlop = post.find((e) => e.street === 'FLOP');
const cbetOpp = sawFlop && lastRaiser === hero && Boolean(firstFlop) && firstFlop.currentBet === 0 && firstFlop.canRaise;
const showdown = hand.showdown && !me.folded;
return {
vpip: pre.some((e) => e.type === 'CALL' || e.type === 'BET' || e.type === 'RAISE'),
pfr: pre.some((e) => e.type === 'BET' || e.type === 'RAISE'),
threeBetOpp,
threeBet,
sawFlop,
cbetOpp,
cbet: cbetOpp && firstFlop.type === 'BET',
aggressive: post.filter((e) => e.type === 'BET' || e.type === 'RAISE').length,
calls: post.filter((e) => e.type === 'CALL').length,
showdown,
wonShowdown: showdown && me.won > 0,
};
}
// names: 席番号 → 名前、bots: CPU の席の Set
function analyzeHand(hand, hero, { names, bots, iterations = 1200, rng = Math.random } = {}) {
const opts = { iterations, rng };
const nameOf = (s) => (names && names.get(s)) || `席${s + 1}`;
const me = hand.players.get(hero);
const key = HandRange.handKey(me.cards);
const cat = HandRange.category(HandRange.categoryOf(key));
const { judgments, estimated } = judgePreflop(hand, hero);
const preflop = judgments.map((j) => ({
index: j.index,
situation: describeSituation(j.situation, nameOf),
recommended: j.recommended,
actual: j.actual,
verdict: j.verdict,
label: j.label,
mark: j.mark,
score: j.score,
...preflopOdds(hand, hero, j, estimated, opts),
}));
const decisions = [];
hand.log.forEach((e, i) => {
if (e.seat === hero && !e.auto && e.street !== 'PREFLOP') decisions.push(evaluateDecision(hand, hero, i, estimated, opts));
});
const streetScores = {};
for (const st of STREETS) {
const scores = (st === 'PREFLOP' ? preflop : decisions.filter((d) => d.street === st)).map((d) => d.score);
streetScores[st] = scores.length ? Math.round(mean(scores)) : null;
}
const scored = STREETS.map((st) => streetScores[st]).filter((x) => x != null);
// 収支と、オールインで決着したときの期待値
const net = hand.endStacks.get(hero) - hand.startStacks.get(hero);
let allIn = null;
if (hand.allIn && !me.folded) {
const hands = new Map(hand.live().map((p) => [p.seat, p.cards]));
const { ev, equity } = showdownShare(hero, hands, hand.allIn.board, hand.allIn.pots, { rng });
allIn = { street: hand.allIn.board.length === 0 ? 'PREFLOP' : hand.allIn.board.length === 3 ? 'FLOP' : 'TURN', equity: round1(equity * 100), ev: Math.round(ev), won: me.won };
}
const evNet = allIn ? Math.round(net - me.won + allIn.ev) : net;
return {
number: hand.number,
at: Date.now(),
sb: hand.sb,
bb: hand.bb,
button: hand.button,
sbSeat: hand.sbSeat,
bbSeat: hand.bbSeat,
heroSeat: hero,
players: hand.order.map((s) => {
const p = hand.players.get(s);
return {
seat: s,
name: nameOf(s),
bot: Boolean(bots && bots.has(s)),
position: positionOf(hand, s),
cards: p.cards.slice(),
start: hand.startStacks.get(s),
end: hand.endStacks.get(s),
won: p.won,
folded: p.folded,
};
}),
actions: hand.log.map((e) => ({
street: e.street,
seat: e.seat,
type: e.type,
amount: e.amount,
added: e.added,
allIn: Boolean(e.allIn),
auto: Boolean(e.auto),
pot: e.pot,
toCall: e.toCall || 0,
})),
board: hand.board.slice(),
results: hand.results,
showdown: hand.showdown,
hero: {
cards: me.cards.slice(),
key,
color: cat.id,
colorName: cat.name,
colorLabel: COLOR_LABEL[cat.id] || cat.label,
tier: cat.tier,
position: positionOf(hand, hero),
net,
evNet,
allIn,
},
preflop,
decisions,
streetScores,
handScore: scored.length ? Math.round(mean(scored)) : null,
lossBB: round1(decisions.reduce((a, d) => a + d.lossBB, 0)),
stats: statsOf(hand, hero),
};
}
module.exports = { analyzeHand, STREETS, COLOR_LABEL, markOf };
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// レビュー用の勝率計算(@pokertools/evaluator の役判定を使う。役の強さのスコアは小さいほど強い)
// rangeEquity : 相手ごとの推定レンジ(手札の組み合わせの一覧)に対する勝率(モンテカルロ法)
// showdownShare: 実際の手札どうしで、残りの場札をすべて(多いときはサンプリングで)めくったときの取り分
const { evaluate, getCardCode } = require('@pokertools/evaluator');
const HandRange = require('../../admin/range-data');
const codes = (cards) => cards.map(getCardCode);
// 1326通りの手札。段階(色)ごとに使う
const COMBOS = [];
{
const ranks = 'AKQJT98765432';
const suits = 'shdc';
const deck = [];
for (const r of ranks) for (const s of suits) deck.push(r + s);
for (let i = 0; i < deck.length; i++) {
for (let j = i + 1; j < deck.length; j++) {
const cards = [deck[i], deck[j]];
COMBOS.push({ c: codes(cards), tier: HandRange.tierOf(HandRange.handKey(cards)) });
}
}
}
// minTier 以上の色の手(0 ならすべて)から、dead のカードを含むものを除く
function rangeOf(minTier, dead) {
const d = new Set(dead);
return COMBOS.filter((x) => x.tier >= minTier && !d.has(x.c[0]) && !d.has(x.c[1])).map((x) => x.c);
}
// いまの場札での役の強さの上位 fraction だけ残す(ベット・レイズされるほど強い手に絞る)
function narrow(range, board, fraction) {
if (fraction >= 1 || board.length < 3 || range.length === 0) return range;
const scored = range.map((c) => ({ c, v: evaluate(c.concat(board)) }));
scored.sort((a, b) => a.v - b.v);
const keep = Math.max(1, Math.ceil(scored.length * fraction));
return scored.slice(0, keep).map((x) => x.c);
}
// hero の手札 vs 相手ごとのレンジ。引き分けは等分。ranges が空なら 1
function rangeEquity(hero, board, ranges, { iterations = 1200, rng = Math.random } = {}) {
const live = ranges.filter((r) => r.length > 0);
if (live.length === 0) return 1;
const fixed = hero.concat(board);
let score = 0;
let n = 0;
const used = new Uint8Array(52);
for (let it = 0; it < iterations; it++) {
used.fill(0);
for (const c of fixed) used[c] = 1;
const opp = [];
let ok = true;
for (const r of live) {
let pick = null;
for (let tries = 0; tries < 30 && !pick; tries++) {
const c = r[Math.floor(rng() * r.length)];
if (!used[c[0]] && !used[c[1]]) pick = c;
}
if (!pick) {
ok = false;
break;
}
used[pick[0]] = used[pick[1]] = 1;
opp.push(pick);
}
if (!ok) continue;
const full = board.slice();
while (full.length < 5) {
const c = Math.floor(rng() * 52);
if (!used[c]) {
used[c] = 1;
full.push(c);
}
}
const me = evaluate(hero.concat(full));
let ties = 0;
let lost = false;
for (const o of opp) {
const v = evaluate(o.concat(full));
if (v < me) {
lost = true;
break;
}
if (v === me) ties++;
}
if (!lost) score += 1 / (ties + 1);
n++;
}
return n ? score / n : 0.5;
}
// 残りの場札の組み合わせ。2枚以下ならすべて、3枚以上はサンプリング
function* runouts(board, dead, samples, rng) {
const need = 5 - board.length;
const used = new Set(board.concat(dead));
const deck = [];
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
if (need === 0) {
yield board;
} else if (need === 1) {
for (const a of deck) yield board.concat([a]);
} else if (need === 2) {
for (let i = 0; i < deck.length; i++) for (let j = i + 1; j < deck.length; j++) yield board.concat([deck[i], deck[j]]);
} else {
for (let k = 0; k < samples; k++) {
for (let i = 0; i < need; i++) {
const j = i + Math.floor(rng() * (deck.length - i));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
yield board.concat(deck.slice(0, need));
}
}
}
// オールインの時点で、hero がポットごとに受け取る額の期待値と勝率
// hands: 席番号 → 手札("As" 形式)。pots: [{ amount, eligible: [席番号] }]
function showdownShare(heroSeat, hands, boardCards, pots, { samples = 4000, rng = Math.random } = {}) {
const board = codes(boardCards);
const seats = [...hands.keys()];
const hole = new Map(seats.map((s) => [s, codes(hands.get(s))]));
const dead = seats.flatMap((s) => hole.get(s));
let ev = 0;
let equity = 0; // メインポット(全員が対象)での取り分の割合
let n = 0;
for (const full of runouts(board, dead, samples, rng)) {
const score = new Map(seats.map((s) => [s, evaluate(hole.get(s).concat(full))]));
pots.forEach((pot, i) => {
if (!pot.eligible.includes(heroSeat)) return;
const best = Math.min(...pot.eligible.map((s) => score.get(s)));
if (score.get(heroSeat) !== best) return;
const share = 1 / pot.eligible.filter((s) => score.get(s) === best).length;
ev += pot.amount * share;
if (i === 0) equity += share;
});
n++;
}
return { ev: ev / n, equity: equity / n };
}
module.exports = { codes, rangeOf, narrow, rangeEquity, showdownShare };
+165
View File
@@ -0,0 +1,165 @@
'use strict';
// プリフロップの判定: 配られた手が登録してあるハンドレンジ(6-max のオープンレンジ)の何色で、そのとおりに行動できたか。
// 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)に対応する色
// 以上ならレイズ(オープン)、それより弱ければフォールド
// 自分より前にコール・レイズした人がいる: その人のレンジを色で推定し(その人の後ろの人数に対応する色)、
// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド
// 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ
// BTN で CO のオープン(ほかに参加者なし)に直面: レンジ表の枠のとおり。水色枠は 3bet、黄枠はコール、
// 枠なしはフォールド
// BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。緑(CO)以上でコール
// (CO のオープンなら青、BTN・SB なら白以上。相手の色の一つ下まで)、三つ上ならレイズ
// 2人目以降に参加した人のレンジも同じ決まりで、コール・レイズできる一番弱い色と推定する
// (ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。
const HandRange = require('../../admin/range-data');
const TOP_TIER = 6; // 紺
const BB_CALL_TIER = 3; // 緑: BB はどこからのオープンにもこの色以上でコールできる
const BB_DEFENSE_MAX_OPEN = 4; // BB で広く守るのはオープンがこの BB 数まで
const ACTION_KIND = { FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'raise', RAISE: 'raise' };
const ACTION_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const VERDICTS = {
good: { mark: '◎', score: 100, label: 'レンジどおり' },
passive: { mark: '△', score: 50, label: 'レイズできる手でコール(チェック)した' },
aggressive: { mark: '△', score: 40, label: 'コールまでの手でレイズした' },
loose: { mark: '✕', score: 0, label: 'レンジ外の手で参加した' },
tight: { mark: '✕', score: 0, label: '参加できる手で降りた' },
needless: { mark: '✕', score: 0, label: 'チェックできるのに降りた' },
};
// 9人のテーブルの呼び方。UTG から CO までは人数に合わせて後ろから当てはめる
const MIDDLE_POSITIONS = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO'];
function positionOf(hand, seat) {
const n = hand.order.length;
if (seat === hand.button) return n === 2 ? 'BTN/SB' : 'BTN';
if (seat === hand.sbSeat) return 'SB';
if (seat === hand.bbSeat) return 'BB';
const middle = hand.preflopOrder.slice(0, n - 3);
const names = MIDDLE_POSITIONS.slice(-middle.length);
return names[middle.indexOf(seat)] || '?';
}
const tierName = (tier) => (tier > TOP_TIER ? 'なし' : HandRange.categoryOfTier(Math.max(0, tier)).name);
// 前に参加した人がいるときに、コール・レイズできる一番弱い色の段階。rule は
// 'normal': 一つ上でコール、二つ上でレイズ(紺は紺に対してもコールできる)
// 'reraised': 自分のレイズにリレイズされた。同じ色でコール、二つ上でレイズ
// 'bb': BB でオープンに直面。相手の色の一つ下(緑まで)でコール、三つ上でレイズ(白より下は参加しない)
// 'vsCO': BTN で CO のオープンに直面。判定はレンジ表の枠で行い、ここでの段階は推定レンジにだけ使う('normal' と同じ)
function thresholds(rule, ref) {
if (rule === 'reraised') return { call: Math.min(TOP_TIER, ref), raise: ref + 2 };
if (rule === 'bb') return { call: Math.max(1, Math.min(BB_CALL_TIER, ref - 1)), raise: ref + 3 };
return { call: Math.min(TOP_TIER, ref + 1), raise: ref + 2 };
}
// 推奨: 'raise' / 'call' / 'fold'(チェックできるときの 'fold' と 'call' は 'check')
function recommend(tier, situation, entry) {
let rec;
if (situation.kind === 'open') {
rec = situation.behind === 0 ? 'check' : tier >= situation.required ? 'raise' : 'fold';
} else if (situation.rule === 'vsCO') {
rec = situation.facing === '3bet' ? 'raise' : situation.facing === 'call' ? 'call' : 'fold';
} else {
rec = tier >= situation.raise ? 'raise' : tier >= situation.call ? 'call' : 'fold';
}
if (rec === 'raise' && !entry.canRaise) rec = 'call';
if ((rec === 'call' || rec === 'fold') && entry.canCheck) rec = 'check';
return rec;
}
function verdictOf(rec, actual) {
if (rec === actual) return 'good';
if (actual === 'fold') return rec === 'check' ? 'needless' : 'tight';
if (rec === 'raise') return 'passive'; // コール・チェックした
if (rec === 'call') return 'aggressive'; // レイズした
return 'loose'; // 降りる(チェックする)べき手で参加した
}
// プリフロップの各行動を順に見て、hero の判断ごとに判定する。あわせて各席の推定レンジ(色の段階)を返す
function judgePreflop(hand, hero) {
const key = HandRange.handKey(hand.players.get(hero).cards);
const tier = HandRange.tierOf(key);
const order = hand.preflopOrder;
const acted = new Set();
const estimated = new Map(); // 席 → 推定レンジの一番弱い色の段階
let maxTier = null;
let maxSeat = null;
let raises = 0;
let entrants = 0; // プリフロップでコール・レイズした回数
const raisers = new Set(); // プリフロップでレイズした席
const judgments = [];
// まだ行動していない後ろの人数(自分を含めない)
const behindOf = (seat) => order.slice(order.indexOf(seat) + 1).filter((s) => !acted.has(s)).length;
// 前に参加した人がいるときの決まり(thresholds を参照)
const ruleOf = (e) => {
if (raisers.has(e.seat)) return 'reraised';
if (entrants === 1 && raises === 1 && positionOf(hand, e.seat) === 'BTN' && positionOf(hand, maxSeat) === 'CO') return 'vsCO';
if (e.seat === hand.bbSeat && raises === 1 && e.currentBet <= BB_DEFENSE_MAX_OPEN * hand.bb) return 'bb';
return 'normal';
};
hand.log.forEach((e, index) => {
if (e.street !== 'PREFLOP') return;
const kind = ACTION_KIND[e.type];
const rule = maxTier == null ? null : ruleOf(e);
const limits = rule && thresholds(rule, maxTier);
if (e.seat === hero && !e.auto) {
const situation =
maxTier == null
? { kind: 'open', behind: behindOf(hero), required: HandRange.requiredTier(behindOf(hero)) }
: { kind: 'facing', rule, refTier: maxTier, refSeat: maxSeat, ...limits, ...(rule === 'vsCO' && { facing: HandRange.facingOf(key) }) };
const rec = recommend(tier, situation, e);
const verdict = verdictOf(rec, kind);
judgments.push({ index, situation, recommended: rec, actual: kind, verdict, ...VERDICTS[verdict] });
}
if (kind === 'call' || kind === 'raise') {
const est =
maxTier == null
? HandRange.requiredTier(behindOf(e.seat))
: Math.min(TOP_TIER, kind === 'raise' ? limits.raise : limits.call);
estimated.set(e.seat, Math.max(estimated.get(e.seat) || 0, est));
// 一番強いレンジ(同じならレイズした人)を、後の人の判断の基準にする
if (maxTier == null || est > maxTier || (est === maxTier && kind === 'raise')) {
maxTier = est;
maxSeat = e.seat;
}
}
if (kind === 'call' || kind === 'raise') entrants++;
if (kind === 'raise') {
raises++;
raisers.add(e.seat);
}
acted.add(e.seat);
});
return { tier, judgments, estimated };
}
// 状況の説明(画面表示用)
function describeSituation(situation, nameOf) {
if (situation.kind === 'open') {
if (situation.behind === 0) return '全員が降りてBBに回ってきた';
return `前に参加者なし・後ろに${situation.behind}人 → ${tierName(situation.required)}以上でオープン`;
}
const who = nameOf(situation.refSeat);
if (situation.rule === 'vsCO') {
const action = HandRange.facingAction(situation.facing);
return `BTNで ${who}(CO)のオープンに直面 → レンジ表の枠(水色枠は3bet、黄枠はコール)。この手は${action ? `${action.name}で${action.label}` : '枠なしでフォールド'}`;
}
const ref = tierName(situation.refTier);
const head =
situation.rule === 'reraised'
? `自分のレイズに ${who} がリレイズ(推定レンジ ${ref})`
: situation.rule === 'bb'
? `BBで ${who} のオープン(推定レンジ ${ref})に直面`
: `${who} の推定レンジ ${ref}`;
const raise = situation.raise > TOP_TIER ? 'レイズはなし' : `${tierName(situation.raise)}以上でレイズ`;
return `${head} → ${tierName(situation.call)}以上でコール、${raise}`;
}
module.exports = { judgePreflop, positionOf, describeSituation, tierName, ACTION_KIND, ACTION_LABEL, VERDICTS };
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// 1ハンドの記録(analyze.js の analyzeHand の結果)を、AI に貼り付けて「自分の判断は良かったか」を
// 質問できる文章にする。そのとき Hero に見えていた情報(自分の手札・場札・全員の行動とスタック・
// ショーダウンで公開された手札)だけを書き、公開されなかった相手の手札は書かない
const { STREETS } = require('./analyze');
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const SUIT = { s: '♠', h: '♥', d: '♦', c: '♣' };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
// プリフロップの行動順
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN/SB', 'BTN', 'SB', 'BB'];
const money = (n) => '$' + Number(n).toLocaleString('en-US');
const round1 = (x) => Math.round(x * 10) / 10;
const signedMoney = (n) => (n > 0 ? '+' : n < 0 ? '-' : '±') + money(Math.abs(n));
const signedNum = (n) => (n > 0 ? '+' : '') + n;
const cardText = (c) => (typeof c === 'string' && c.length === 2 ? c[0] + (SUIT[c[1]] || c[1]) : '??');
const cardsText = (cs) => cs.map(cardText).join(' ');
function buildHandPrompt(h) {
const bbOf = (n) => `${round1(n / h.bb)}BB`;
const amount = (n) => `${money(n)}(${bbOf(n)})`;
const player = (seat) => h.players.find((p) => p.seat === seat);
const who = (seat) => {
const p = player(seat);
if (!p) return `席${seat + 1}`;
return seat === h.heroSeat ? `${p.position}(Hero・私)` : `${p.position}(${p.name})`;
};
const players = h.players
.slice()
.sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
const hero = player(h.heroSeat);
const out = [];
const line = (s = '') => out.push(s);
line('以下は、ノーリミット・テキサスホールデムのキャッシュゲームで私(Hero)がプレイした1ハンドの記録です。');
line('私の各アクションが良かったか悪かったかを評価してください。');
line('評価は結果論ではなく、それぞれの判断の時点で私に見えていた情報(自分の手札・場札・相手の行動とスタック)をもとにしてください。');
line();
line('## ゲームの条件');
line(`- ノーリミット・テキサスホールデム、キャッシュゲーム(${h.players.length}人でプレイ。チップは開始スタックで何度でもリバイ可)`);
line(`- ブラインド: SB ${money(h.sb)} / BB ${money(h.bb)}(アンティなし・レーキなし)`);
line('- 相手はすべて CPU。CPU ごとに打ち方(タイト/ルーズ、攻撃的/受動的、ブラフの頻度)が違い、どの CPU がどんな打ち方かはわからない');
line('- 金額は $ で書き、()内に BB 換算を添える');
line();
line('## 席とスタック(ハンド開始時、プリフロップの行動順)');
for (const p of players) {
line(`- ${who(p.seat)}: ${amount(p.start)}${p.seat === h.heroSeat ? ' ← 私' : ''}`);
}
const others = h.players.filter((p) => p.seat !== h.heroSeat).map((p) => p.start);
if (others.length) line(`- 私と一番深い相手との有効スタック: ${amount(Math.min(hero.start, Math.max(...others)))}`);
line();
line('## 私の手札');
line(`${cardsText(h.hero.cards)}(${h.hero.key})`);
line();
// アクション。ポット・各自の残りスタックをたどりながら書く
line('## アクション');
line('(「ベット/レイズ to」はそのストリートでの合計額、「コール」はその行動で出した額)');
const stack = new Map(h.players.map((p) => [p.seat, p.start]));
const folded = new Set();
let pot = 0;
const post = (seat, blind) => {
const p = player(seat);
if (!p) return 0;
const paid = Math.min(blind, p.start);
stack.set(seat, stack.get(seat) - paid);
pot += paid;
return paid;
};
const sbPaid = post(h.sbSeat, h.sb);
const bbPaid = post(h.bbSeat, h.bb);
const actionsOf = (st) => h.actions.filter((a) => a.street === st);
for (const st of STREETS) {
const acts = actionsOf(st);
const boardShown = h.board.length >= BOARD_COUNT[st];
if (st !== 'PREFLOP' && !boardShown) break;
const alive = h.players.filter((p) => !folded.has(p.seat)).length;
if (alive <= 1) break;
line();
if (st === 'PREFLOP') {
line(`### プリフロップ(${h.players.length}人)`);
line(`- ${who(h.sbSeat)}: SB ${money(sbPaid)} を支払う`);
line(`- ${who(h.bbSeat)}: BB ${money(bbPaid)} を支払う`);
} else {
const board = h.board.slice(0, BOARD_COUNT[st]);
const head = st === 'FLOP' ? cardsText(board) : `${cardText(board[board.length - 1])}(ボード: ${cardsText(board)})`;
line(`### ${STREET_LABEL[st]}: ${head}`);
line(`- ポット ${amount(pot)}、残り${alive}人`);
if (!acts.length) line('- (全員オールインのため行動なし)');
}
for (const a of acts) {
const mine = a.seat === h.heroSeat && !a.auto;
let text;
if (a.type === 'FOLD') text = 'フォールド';
else if (a.type === 'CHECK') text = 'チェック';
else if (a.type === 'CALL') text = `コール ${money(a.added)}`;
else if (a.type === 'BET') text = `ベット ${money(a.amount)}`;
else text = `レイズ to ${money(a.amount)}`;
if (a.allIn) text += '(オールイン)';
if (a.auto) text += '(離席中の自動の行動)';
const before = mine ? ` ← 私の判断(行動前のポット ${amount(a.pot)}${a.toCall ? `、コールに ${money(a.toCall)} 必要` : ''}、私の残りスタック ${amount(stack.get(a.seat))})` : '';
line(`- ${who(a.seat)}: ${text}${before}`);
stack.set(a.seat, stack.get(a.seat) - a.added);
pot += a.added;
if (a.type === 'FOLD') folded.add(a.seat);
}
}
line();
line('## 結果');
const shown = h.showdown ? h.players.filter((p) => !p.folded && p.seat !== h.heroSeat) : [];
if (shown.length) {
line('- ショーダウンで公開された手札:');
for (const p of shown) line(` - ${who(p.seat)}: ${cardsText(p.cards)}`);
} else if (!h.showdown) {
line('- ショーダウンなし(相手の手札は公開されていない)');
}
(h.results || []).forEach((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メインポット' : `サイドポット${i}`) : 'ポット';
const winners = r.winners.map(who).join('・');
line(`- ${tag} ${money(r.amount)} → ${winners}${r.handName ? `(${r.handName})` : ''}`);
});
line(`- 私の収支: ${signedMoney(h.hero.net)}(${signedNum(round1(h.hero.net / h.bb))}BB)`);
line();
// サイトの自動判定(参考)
line('## このサイトの自動判定(参考)');
line('このサイトが簡易的に出した目安です。ソルバー(GTO)ではありません。鵜呑みにせず独立に評価し、おかしいと思う点があれば指摘してください。');
const color = h.hero.colorLabel ? `${h.hero.colorName}(${h.hero.colorLabel})` : h.hero.colorName;
line(`- 登録してあるハンドレンジ(6-max のオープンレンジ表)での私の手の分類: ${color}`);
for (const j of h.preflop) {
line(`- プリフロップ ${j.mark} ${j.score}点: ${j.situation}。推奨 ${KIND_LABEL[j.recommended]}、私は ${KIND_LABEL[j.actual]}(${j.label})`);
if (j.equity != null) line(` - 参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)`);
}
for (const d of h.decisions) {
const opt = (o) => (o.kind === 'raise' ? `ベット/レイズ to ${money(o.to)}` : KIND_LABEL[o.kind]);
const isActual = (o) => o.kind === d.actual.kind && o.to === d.actual.to;
const evs = d.options.map((o) => `${opt(o)} ${signedNum(o.evBB)}BB${isActual(o) ? '(私)' : ''}`).join(' / ');
line(`- ${STREET_LABEL[d.street]} ${d.mark} ${d.score}点: 相手の推定レンジに対する勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}`);
line(` - 選択肢ごとの期待値の目安: ${evs}`);
}
const ai = h.hero.allIn;
if (ai) line(`- オールイン: ${STREET_LABEL[ai.street]}で決着した時点の私の勝率 ${ai.equity}%(期待値 ${money(ai.ev)}、実際に受け取った額 ${money(ai.won)})`);
line();
line('## 質問');
line('1. 私の各アクション(「← 私の判断」の行)は良かったですか? それぞれ「良い/許容範囲/悪い」で評価し、理由を説明してください。');
line('2. 悪かった判断があれば、代わりにどうすべきだったか(アクションとサイズ)と、その理由を教えてください。');
line('3. 相手の行動から考えられる相手のレンジと、このハンドから学べるポイントを教えてください。');
return out.join('\n');
}
module.exports = { buildHandPrompt };
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// レビューの保存先(data/reviews/<id>.json)。テーブルを閉じた後も見返せるようにファイルに残す。
// 対局中のものはメモリにも置き、ハンドが終わるたびに書き出す
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.REVIEW_DIR || path.join(__dirname, '..', '..', 'data', 'reviews');
const MAX_SESSIONS = 50; // これより古い対局は消す
const MAX_HANDS = 1000; // 1回の対局で保存するハンド数の上限
const active = new Map(); // id → 対局(メモリ上)
const writing = new Map(); // id → 書き出し中の Promise(順番に書く)
const validId = (id) => typeof id === 'string' && /^[0-9a-f]{16}$/.test(id);
const fileOf = (id) => path.join(DIR, `${id}.json`);
function create({ tableName, heroName, startingStack }) {
const session = {
id: crypto.randomBytes(8).toString('hex'),
tableName,
heroName,
startingStack,
createdAt: Date.now(),
updatedAt: Date.now(),
hands: [],
};
active.set(session.id, session);
return session;
}
function addHand(session, record) {
if (session.hands.length >= MAX_HANDS) return;
session.hands.push(record);
session.updatedAt = Date.now();
save(session);
}
function save(session) {
const prev = writing.get(session.id) || Promise.resolve();
const next = prev
.then(async () => {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(session.id)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(session), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(session.id));
await prune();
})
.catch((err) => console.error('[review] 保存に失敗しました', err.message));
writing.set(session.id, next);
next.then(() => {
if (writing.get(session.id) === next) writing.delete(session.id);
});
return next;
}
// テーブルが閉じられたらメモリから外す(ファイルには残る)
function close(session) {
active.delete(session.id);
}
async function readAll() {
let files;
try {
files = await fs.promises.readdir(DIR);
} catch {
return [];
}
const out = [];
for (const f of files) {
const id = f.replace(/\.json$/, '');
if (!f.endsWith('.json') || !validId(id)) continue;
const s = await get(id);
if (s) out.push(s);
}
return out;
}
// 古い対局のファイルを消す(更新日時で判断する。中身は読まない)
async function prune() {
let files;
try {
files = (await fs.promises.readdir(DIR)).filter((f) => f.endsWith('.json'));
} catch {
return;
}
if (files.length <= MAX_SESSIONS) return;
const stats = [];
for (const f of files) {
const st = await fs.promises.stat(path.join(DIR, f)).catch(() => null);
if (st) stats.push({ f, mtime: st.mtimeMs });
}
stats.sort((a, b) => b.mtime - a.mtime);
for (const { f } of stats.slice(MAX_SESSIONS)) {
if (active.has(f.replace(/\.json$/, ''))) continue;
await fs.promises.unlink(path.join(DIR, f)).catch(() => {});
}
}
async function get(id) {
if (!validId(id)) return null;
if (active.has(id)) return active.get(id);
try {
return JSON.parse(await fs.promises.readFile(fileOf(id), 'utf8'));
} catch {
return null;
}
}
// 一覧(新しい順)。中身のハンドは含めない
async function list() {
const all = await readAll();
for (const s of active.values()) if (!all.some((x) => x.id === s.id)) all.push(s);
return all
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((s) => ({
id: s.id,
tableName: s.tableName,
heroName: s.heroName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
hands: s.hands.length,
active: active.has(s.id),
}));
}
module.exports = { create, addHand, close, get, list, validId, DIR };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// 1回の対局(レビュー付きCPU戦のテーブル1つ)のハンドのレビューをまとめる
// 成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、ストリートごとの平均点、
// 色ごとの参加率とレンジどおりに打てた割合、ポジションごとの傾向とクセ、見直したいハンド、収支の推移
const HandRange = require('../../admin/range-data');
const { STREETS, COLOR_LABEL } = require('./analyze');
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((x, y) => x + y, 0) / xs.length) : null);
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'BTN/SB', 'SB', 'BB'];
const STREET_LABEL = { FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
// クセとして数える判定と、その説明
const LEAK_TEXT = {
loose: 'レンジ外の手で参加',
tight: '参加できる手で降りた',
passive: 'レイズできる手でコール',
aggressive: 'コールまでの手でレイズ',
needless: 'チェックできるのに降りた',
};
function summarize(hands) {
const n = hands.length;
const count = (f) => hands.filter(f).length;
const stat = (key) => count((h) => h.stats[key]);
const sawFlop = stat('sawFlop');
const showdowns = stat('showdown');
const aggressive = hands.reduce((a, h) => a + h.stats.aggressive, 0);
const calls = hands.reduce((a, h) => a + h.stats.calls, 0);
const stats = {
hands: n,
vpip: pct(stat('vpip'), n),
pfr: pct(stat('pfr'), n),
threeBet: pct(stat('threeBet'), stat('threeBetOpp')),
threeBetOpp: stat('threeBetOpp'),
cbet: pct(stat('cbet'), stat('cbetOpp')),
cbetOpp: stat('cbetOpp'),
wtsd: pct(showdowns, sawFlop),
wsd: pct(stat('wonShowdown'), showdowns),
af: calls ? Math.round((aggressive / calls) * 100) / 100 : aggressive ? null : 0,
};
const streets = {};
for (const st of STREETS) streets[st] = mean(hands.map((h) => h.streetScores[st]).filter((x) => x != null));
const total = mean(hands.map((h) => h.handScore).filter((x) => x != null));
// 色ごと: 配られた回数・参加した回数・プリフロップの判断がレンジどおりだった割合
const colors = HandRange.CATEGORIES.map((c) => {
const hs = hands.filter((h) => h.hero.color === c.id);
const judged = hs.flatMap((h) => h.preflop);
return {
id: c.id,
name: c.name,
label: COLOR_LABEL[c.id] || c.label,
dealt: hs.length,
played: hs.filter((h) => h.stats.vpip).length,
judged: judged.length,
good: judged.filter((j) => j.verdict === 'good').length,
};
});
// ポジションごと
const byPos = new Map();
for (const h of hands) {
const pos = h.hero.position;
if (!byPos.has(pos)) byPos.set(pos, { position: pos, hands: 0, played: 0, judged: 0, good: 0, verdicts: {} });
const p = byPos.get(pos);
p.hands++;
if (h.stats.vpip) p.played++;
for (const j of h.preflop) {
p.judged++;
if (j.verdict === 'good') p.good++;
else p.verdicts[j.verdict] = (p.verdicts[j.verdict] || 0) + 1;
}
}
const positions = [...byPos.values()].sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
// クセ: ポジション × 間違いの種類で多いもの。フロップ以降は失った期待値が大きいストリート × 行動
const leaks = [];
for (const p of positions) {
for (const [v, k] of Object.entries(p.verdicts)) leaks.push({ text: `${p.position}: ${LEAK_TEXT[v]}`, count: k, of: p.judged });
}
const postLeaks = new Map();
for (const h of hands) {
for (const d of h.decisions) {
if (d.score >= 70) continue;
const key = `${d.street}:${d.actual.kind}`;
if (!postLeaks.has(key)) postLeaks.set(key, { street: d.street, kind: d.actual.kind, count: 0, lossBB: 0 });
const x = postLeaks.get(key);
x.count++;
x.lossBB += d.lossBB;
}
}
const KIND = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'ベット・レイズ' };
for (const x of postLeaks.values()) {
leaks.push({ text: `${STREET_LABEL[x.street]}: ${KIND[x.kind]}で期待値を失った(合計 ${Math.round(x.lossBB * 10) / 10}BB)`, count: x.count });
}
leaks.sort((a, b) => b.count - a.count);
// 見直したいハンド: 失った期待値(BB)が大きい順、同じならプリフロップの判定が悪い順
const worst = hands
.filter((h) => h.lossBB > 0 || (h.handScore != null && h.handScore < 100))
.map((h) => ({
number: h.number,
key: h.hero.key,
color: h.hero.color,
position: h.hero.position,
lossBB: h.lossBB,
handScore: h.handScore,
net: h.hero.net,
bb: h.bb,
}))
.sort((a, b) => b.lossBB - a.lossBB || a.handScore - b.handScore)
.slice(0, 5);
// 収支の推移(実際の収支と、オールインの期待値で運を除いた収支)
let net = 0;
let evNet = 0;
const graph = hands.map((h) => {
net += h.hero.net;
evNet += h.hero.evNet;
return { number: h.number, net, evNet };
});
return { stats, streets, total, colors, positions, leaks: leaks.slice(0, 6), worst, graph };
}
module.exports = { summarize };
+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 };
+183 -6
View File
@@ -5,12 +5,18 @@ const config = require('./config');
const Blinds = require('../public/assets/blinds');
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 : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
// friends: 友人戦。サーバがカードを配り、ショーダウンも自動で判定する
// cpu : CPU戦。友人戦に CPU プレイヤーが加わる
const MODES = ['chips', 'friends', 'cpu'];
// review : レビュー付きCPU戦(管理者専用)。作成者と CPU だけで遊び、ハンドごとにレビューを作る。
// レビューを見てから次のハンドに進めるよう、ハンドが終わると作成者が「次のハンドへ」を押すまで待つ
const MODES = ['chips', 'friends', 'cpu', 'review'];
const CPU_NAMES = ['ハル', 'ソラ', 'リク', 'ミオ', 'レン', 'ユイ', 'カイ', 'ナギ'];
class Table {
@@ -33,7 +39,11 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
if (this.mode === 'cpu') this.seatBots(opts.cpuCount);
this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー
this.review = this.mode === 'review' ? { session: null, last: null } : null;
this.hand = null;
this.started = false;
this.paused = false;
@@ -109,6 +119,24 @@ class Table {
.filter((i) => i >= 0);
}
// ハンドが終わったら、ホストが「次のハンドへ」を押すまで次のハンドを始めない
// (ホストが着席していなければ待たずに進める)
get awaitingNext() {
const h = this.hand;
if (!h || !h.finished || h.nextApproved) return false;
const host = this.seatOf(this.hostToken);
return host >= 0 && !this.seats[host].left;
}
nextHand(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
if (this.paused) return '一時停止中です';
if (!this.awaitingNext) return 'ハンドが終わっていません';
this.hand.nextApproved = true;
this.nextHandAt = now; // 待たずに次の tick で始める
return null;
}
// CPU だけでハンドを続けても意味がないので、人間が1人は参加している必要がある
canDeal() {
const eligible = this.eligibleSeats();
@@ -127,6 +155,7 @@ class Table {
}
return existing;
}
if (this.mode === 'review' && token !== this.hostToken) return -1; // 作成者と CPU だけのテーブル
const free = this.seats.findIndex((s) => !s);
if (free < 0) return -1;
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
@@ -155,7 +184,7 @@ class Table {
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
shouldClose() {
return this.mode === 'cpu' && !this.seats.some((s) => s && !s.left && !s.bot);
return this.withBots && !this.seats.some((s) => s && !s.left && !s.bot);
}
removeLeftSeats() {
@@ -172,6 +201,7 @@ class Table {
startNewHand() {
this.removeLeftSeats();
this.applySeating();
// チップがなくなった CPU は自動でリバイ
for (const s of this.seats) {
if (s && s.bot && s.stack === 0) {
@@ -250,6 +280,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 'すでに開始しています';
@@ -331,6 +400,13 @@ 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;
}
if (this.awaitingNext) return changed;
if (this.hand && this.hand.finished) {
if (this.nextHandAt == null) {
const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
@@ -341,6 +417,7 @@ class Table {
this.hand = null;
this.nextHandAt = null;
this.removeLeftSeats();
this.applySeating();
}
changed = true;
}
@@ -355,6 +432,82 @@ 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戦 ----
// 終わったハンドのレビューを作って保存する(作成者が見終わって「次のハンドへ」を押すまで次のハンドは始まらない)
reviewHand() {
const h = this.hand;
h.reviewed = true;
const hero = this.seatOf(this.hostToken);
if (hero < 0 || !h.players.has(hero)) return;
let record;
try {
record = analyzeHand(h, hero, {
names: new Map(h.order.map((s) => [s, this.seats[s] ? this.seats[s].name : '退席者'])),
bots: new Set(h.order.filter((s) => this.seats[s] && this.seats[s].bot)),
iterations: config.REVIEW_ITERATIONS,
});
} catch (err) {
console.error('[review] レビューを作れませんでした', err);
return;
}
if (!this.review.session) {
this.review.session = reviews.create({ tableName: this.name, heroName: this.seats[hero].name, startingStack: this.startingStack });
}
reviews.addHand(this.review.session, record);
this.review.last = record;
}
// 作成者に送るレビュー(直前のハンドの要約)
reviewView() {
const r = this.review;
const last = r.last;
const hands = r.session ? r.session.hands : [];
const scores = hands.map((x) => x.handScore).filter((x) => x != null);
return {
sessionId: r.session ? r.session.id : null,
hands: hands.length,
average: scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : null,
last: last && {
number: last.number,
bb: last.bb,
hero: last.hero,
preflop: last.preflop,
decisions: last.decisions.map((d) => ({
street: d.street,
equity: d.equity,
need: d.need,
actual: d.actual,
best: d.best,
lossBB: d.lossBB,
score: d.score,
mark: d.mark,
})),
streetScores: last.streetScores,
handScore: last.handScore,
},
};
}
summary() {
const { sb, bb } = this.blinds;
return {
@@ -376,6 +529,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();
@@ -385,7 +550,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,
@@ -408,6 +573,7 @@ class Table {
phase: this.phase,
started: this.started,
paused: this.paused,
awaitingNext: this.awaitingNext,
handNumber: this.handNumber,
hand: hv,
toAct: this.handActive ? h.toAct : null,
@@ -417,6 +583,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,
@@ -429,7 +596,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,
@@ -438,6 +605,12 @@ 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,
};
}
@@ -452,8 +625,10 @@ class TableManager {
return this.tables.get(id);
}
list() {
// レビュー付きCPU戦は管理者にだけ見せる
list(admin = false) {
return [...this.tables.values()]
.filter((t) => admin || t.mode !== 'review')
.sort((a, b) => b.createdAt - a.createdAt)
.map((t) => t.summary());
}
@@ -480,6 +655,8 @@ class TableManager {
}
delete(id) {
const t = this.tables.get(id);
if (t && t.review && t.review.session) reviews.close(t.review.session);
this.tables.delete(id);
}
}
+59 -2
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');
@@ -228,6 +232,7 @@ test('離席中の人の手番は自動でチェック/フォールド、次の
assert.notEqual(h.toAct, first);
t.act(tk(t, h.toAct), 'fold', 0); // 残り1人で終了
assert.ok(h.finished);
assert.equal(t.nextHand(T(0), 0), null);
t.tick(10 ** 6);
t.tick(10 ** 7);
assert.equal(t.hand.number, 2);
@@ -249,15 +254,60 @@ test('一時停止中はアクションも勝者の決定も次のハンドも
t.togglePause(T(0), 4000);
assert.equal(t.award(T(0), [[h.order[0]]]), null);
t.togglePause(T(0), 5000);
assert.equal(t.nextHand(T(0), 5000), '一時停止中です');
t.tick(10 ** 7);
t.tick(10 ** 8);
assert.equal(t.hand.number, 1); // 止まっている
t.togglePause(T(0), 10 ** 8);
assert.equal(t.nextHand(T(0), 10 ** 8), null);
t.tick(10 ** 8 + 10 ** 5);
t.tick(10 ** 8 + 10 ** 6);
assert.equal(t.hand.number, 2);
});
test('次のハンドはホストが「次のハンドへ」を押すまで始まらない', () => {
for (const mode of ['chips', 'friends']) {
const t = makeTable([1000, 1000, 1000], { mode });
t.start(T(0), 0);
assert.equal(t.nextHand(T(0), 0), 'ハンドが終わっていません');
const h = t.hand;
for (let i = 0; i < 10 && !h.finished; i++) t.act(tk(t, h.toAct), 'fold', 0);
assert.ok(h.finished);
assert.equal(t.publicState(T(1), new Set()).awaitingNext, true);
for (let k = 1; k <= 5; k++) t.tick(k * 10 ** 6);
assert.equal(t.hand, h); // 待っている
assert.equal(t.nextHand(T(1), 10 ** 7), 'ホストのみ操作できます');
assert.equal(t.nextHand(T(0), 10 ** 7), null);
assert.equal(t.awaitingNext, false);
t.tick(10 ** 7);
assert.equal(t.hand.number, 2);
}
});
test('ホストが退席したら、次のホストが押す。人間がいなければ待たずに進む', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
for (let i = 0; i < 10 && !h.finished; i++) t.act(tk(t, h.toAct), 'fold', 0);
assert.ok(t.leave(T(0)));
assert.ok(t.awaitingNext);
assert.equal(t.nextHand(T(0), 0), 'ホストのみ操作できます');
assert.ok(t.isHost(T(1)));
assert.equal(t.nextHand(T(1), 0), null);
t.tick(1);
assert.equal(t.hand.number, 2);
// CPU戦で人間が退席すると、ボタンを待たずに片付けへ進む
const c = makeTable([1000], { mode: 'cpu', cpuCount: 2 });
c.start(T(0), 0);
const ch = c.hand;
c.leave(T(0));
let now = 0;
for (let k = 0; k < 400 && !ch.finished; k++) c.tick((now += 5000));
assert.ok(ch.finished);
assert.equal(c.awaitingNext, false);
});
test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
@@ -376,6 +426,7 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
});
assert.equal(t.award(T(0), winners), null);
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
@@ -619,6 +670,7 @@ test('カードあり: ランダムに遊んでもチップの合計が変わら
} else {
assert.equal(h.awaitingWinners, false);
if (h.runout && t.runoutAt == null) runouts++;
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
@@ -699,6 +751,7 @@ test('CPU: どんな状況でも正しいアクションを返す', () => {
assert.equal(h.act(h.toAct, d.type, d.amount), null, JSON.stringify(d));
actions++;
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
const inPot = t.hand && !t.hand.finished ? t.hand.potTotal : 0;
@@ -732,6 +785,7 @@ test('CPU: どの思考(ライブラリ)も自分で正しいアクション
assert.equal(h.act(h.toAct, d.type, d.amount), null, `${brain} ${JSON.stringify(d)}`);
decisions++;
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
}
@@ -814,7 +868,10 @@ test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動
// 人間が離席すると CPU だけで次のハンドは始まらない
t.setSittingOut(T(0), true);
for (let k = 0; k < 400 && t.hand; k++) t.tick((now += 5000));
for (let k = 0; k < 400 && t.hand; k++) {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 5000));
}
assert.equal(t.hand, null);
t.setSittingOut(T(0), false);
+411
View File
@@ -0,0 +1,411 @@
'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.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');
const HandRange = require('../admin/range-data');
const { Table } = require('../server/tables');
const { judgePreflop, positionOf, describeSituation } = require('../server/review/preflop');
const { analyzeHand } = require('../server/review/analyze');
const { showdownShare } = require('../server/review/equity');
const { summarize } = require('../server/review/summary');
const reviews = require('../server/review/store');
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
// n 人の友人戦(カードあり)。席0から順に座る
function makeTable(n) {
const t = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'friends',
});
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 give = (t, seat, cards) => {
t.hand.players.get(seat).cards = cards;
};
test('ハンドレンジ: 手札の表記と、後ろの人数で必要な色', () => {
assert.equal(HandRange.handKey(['Kd', 'As']), 'AKo');
assert.equal(HandRange.handKey(['5h', '4h']), '54s');
assert.equal(HandRange.handKey(['9c', '9d']), '99');
assert.equal(HandRange.categoryOf('AA'), 'navy');
assert.equal(HandRange.categoryOf('72o'), 'gray');
assert.equal(HandRange.tierOf('Q5s'), 1); // BTN だけで開く手は白(SB の青より下)
// レイズを受けたときの対応(枠)
assert.equal(HandRange.facingOf('A5s'), '3bet');
assert.equal(HandRange.facingOf('TT'), 'call');
assert.equal(HandRange.facingOf('ATo'), null);
const share = (id) => {
const combos = (h) => (h.length === 2 ? 6 : h[2] === 's' ? 4 : 12);
const n = Object.keys(HandRange.FACING).filter((h) => HandRange.facingOf(h) === id).reduce((a, h) => a + combos(h), 0);
return Math.round((n / 1326) * 1000) / 10;
};
assert.equal(share('3bet'), 5.7);
assert.equal(share('call'), 14.0);
const req = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((b) => HandRange.categoryOfTier(HandRange.requiredTier(b)).id);
assert.deepEqual(req, ['gray', 'blue', 'white', 'green', 'yellow', 'red', 'red', 'red', 'red']);
});
test('ポジションの呼び方', () => {
const t = makeTable(9);
const h = t.hand;
const names = h.preflopOrder.map((s) => positionOf(h, s));
assert.deepEqual(names, ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'SB', 'BB']);
const hu = makeTable(2).hand;
assert.deepEqual(hu.preflopOrder.map((s) => positionOf(hu, s)), ['BTN/SB', 'BB']);
});
test('プリフロップ: 前に参加者がいなければ、後ろの人数の色以上でオープン', () => {
// 6人: UTG〜CO が降りて BTN(後ろに2人 → 白以上)
for (const [cards, action, verdict] of [
[['Kh', '3h'], 'raise', 'good'], // 白でレイズ
[['Qh', '3h'], 'raise', 'loose'], // レンジ外
[['Qh', '3h'], 'fold', 'good'],
[['Ah', 'Kd'], 'call', 'passive'], // レイズできる手でコール
[['Ah', 'Kd'], 'fold', 'tight'],
]) {
const t = makeTable(6);
for (let k = 0; k < 3; k++) act(t, 'fold');
const hero = t.hand.toAct;
assert.equal(hero, t.hand.button);
act(t, action, action === 'raise' ? 25 : 0);
give(t, hero, cards);
const j = judgePreflop(t.hand, hero).judgments[0];
assert.equal(j.situation.kind, 'open');
assert.equal(j.situation.behind, 2);
assert.equal(j.verdict, verdict, `${cards} ${action}`);
}
// SB に全員降りて回ってきた(後ろに1人 → 青以上。SB は BTN より狭い)
const t = makeTable(4);
act(t, 'fold');
act(t, 'fold');
const sb = t.hand.toAct;
act(t, 'raise', 30);
give(t, sb, ['Ks', '4s']);
const j = judgePreflop(t.hand, sb).judgments[0];
assert.equal(j.situation.behind, 1);
assert.equal(j.verdict, 'good');
give(t, sb, ['Qs', '4s']); // 白は BTN だけ
assert.equal(judgePreflop(t.hand, sb).judgments[0].verdict, 'loose');
});
test('プリフロップ: 前に参加者がいたら、その人の色の一つ上でコール、二つ上でレイズ', () => {
// 6人: HJ(後ろに4人 → 黄と推定)がレイズ。次の人(hero、CO)の判定
for (const [cards, action, verdict] of [
[['Kh', 'Qd'], 'call', 'good'], // 赤 = 黄の一つ上 → コール
[['Kh', 'Qd'], 'raise', 'aggressive'],
[['Ah', 'Ad'], 'raise', 'good'], // 紺 = 二つ上 → レイズ
[['Ah', 'Ad'], 'call', 'passive'],
[['Kh', '9h'], 'call', 'loose'], // 黄 = 同じ → フォールド
[['Kh', '9h'], 'fold', 'good'],
]) {
const t = makeTable(6);
act(t, 'fold');
const hj = act(t, 'raise', 30);
const hero = t.hand.toAct;
act(t, action, action === 'raise' ? 90 : 0);
give(t, hero, cards);
const { judgments, estimated } = judgePreflop(t.hand, hero);
assert.equal(estimated.get(hj), 4); // 黄
assert.equal(judgments[0].situation.refTier, 4);
assert.equal(judgments[0].verdict, verdict, `${cards} ${action}`);
}
// BB: リンプ(コール)に対してはチェックでよい。二つ上ならレイズ
const t = makeTable(4);
act(t, 'call'); // UTG(後ろに3人 → 緑)
act(t, 'fold');
act(t, 'call'); // SB: 緑の一つ上と推定
const bb = t.hand.toAct;
act(t, 'check');
give(t, bb, ['7h', '2d']);
const j = judgePreflop(t.hand, bb).judgments[0];
assert.equal(j.recommended, 'check');
assert.equal(j.verdict, 'good');
});
test('プリフロップ: BTN で CO のオープンを受けたら、レンジ表の枠(水色枠は3bet、黄枠はコール)のとおり', () => {
const coOpen = (cards, action, limp = false) => {
const t = makeTable(6);
act(t, limp ? 'call' : 'fold'); // UTG
act(t, 'fold');
act(t, 'raise', 30); // CO
const hero = t.hand.toAct;
assert.equal(hero, t.hand.button);
act(t, action, action === 'raise' ? 90 : 0);
give(t, hero, cards);
return judgePreflop(t.hand, hero).judgments[0];
};
for (const [cards, action, verdict] of [
[['Ah', '5h'], 'raise', 'good'], // 水色枠 → 3bet
[['Ah', '5h'], 'call', 'passive'],
[['Th', 'Td'], 'call', 'good'], // 黄枠 → コール(色の決まりなら赤でレイズ)
[['Th', 'Td'], 'raise', 'aggressive'],
[['Qh', 'Jd'], 'call', 'good'], // 黄枠(色は CO の緑)
[['Ah', 'Td'], 'call', 'loose'], // 枠なし → フォールド(色は HJ の黄)
[['Ah', 'Td'], 'fold', 'good'],
]) {
const j = coOpen(cards, action);
assert.equal(j.situation.rule, 'vsCO');
assert.equal(j.verdict, verdict, `${cards} ${action}`);
}
assert.match(describeSituation(coOpen(['Th', 'Td'], 'call').situation, () => 'CPU'), /黄枠でコール/);
// 前にリンプした人がいたら CO のオープンではないので、色の決まりで判定する
assert.equal(coOpen(['Th', 'Td'], 'call', true).situation.rule, 'normal');
});
test('プリフロップ: 自分のレイズにリレイズされたら、相手と同じ色でもコールできる', () => {
// 6人: UTG(hero、後ろに5人 → 赤)がオープン、HJ が3bet(赤の二つ上はないので紺と推定)、ほかは降りて hero に戻る
for (const [cards, action, verdict] of [
[['Ah', 'Ad'], 'call', 'good'], // 紺 = 同じ → コール(二つ上はない)
[['Ah', 'Ad'], 'fold', 'tight'],
[['Jh', 'Jd'], 'call', 'loose'], // 赤 = 一つ下 → フォールド
[['Jh', 'Jd'], 'fold', 'good'],
]) {
const t = makeTable(6);
const hero = act(t, 'raise', 30);
const hj = act(t, 'raise', 90);
for (let k = 0; k < 4; k++) act(t, 'fold');
assert.equal(t.hand.toAct, hero);
act(t, action);
give(t, hero, cards);
const { judgments, estimated } = judgePreflop(t.hand, hero);
assert.equal(estimated.get(hj), 6);
const j = judgments[1];
assert.equal(j.situation.rule, 'reraised');
assert.equal(j.verdict, verdict, `${cards} ${action}`);
}
});
test('プリフロップ: BB は 4BB までのオープンに広くコールし、三つ上でレイズ', () => {
// 6人: CO(後ろに3人 → 緑)がオープンして BB(hero)まで降りる。青以上でコール、紺でレイズ
const coOpen = (to, cards, action) => {
const t = makeTable(6);
act(t, 'fold');
act(t, 'fold');
act(t, 'raise', to);
act(t, 'fold');
act(t, 'fold');
const bb = t.hand.toAct;
assert.equal(bb, t.hand.bbSeat);
act(t, action, action === 'raise' ? to * 3 : 0);
give(t, bb, cards);
return judgePreflop(t.hand, bb).judgments[0];
};
for (const [cards, action, verdict] of [
[['Kh', '4h'], 'call', 'good'], // 青 = 緑の一つ下 → コール
[['Qh', '5h'], 'call', 'loose'], // 白 → フォールド
[['Kh', 'Qd'], 'call', 'good'], // 赤 = 二つ上 → まだコール
[['Kh', 'Qd'], 'raise', 'aggressive'],
[['Ah', 'Ad'], 'raise', 'good'], // 紺 = 三つ上 → レイズ
]) {
const j = coOpen(30, cards, action);
assert.equal(j.situation.rule, 'bb');
assert.equal(j.verdict, verdict, `${cards} ${action}`);
}
// 4BB を超えるオープンには、ほかの席と同じく一つ上が必要
const big = coOpen(50, ['Kh', '4h'], 'call');
assert.equal(big.situation.rule, 'normal');
assert.equal(big.verdict, 'loose');
// BTN(後ろに2人 → 白)のオープンには白からコールできる。コールした BB のレンジも白と推定する
const t = makeTable(6);
for (let k = 0; k < 3; k++) act(t, 'fold');
act(t, 'raise', 25);
act(t, 'fold');
const bb = act(t, 'call');
give(t, bb, ['Qh', '5h']);
const { judgments, estimated } = judgePreflop(t.hand, bb);
assert.equal(judgments[0].verdict, 'good');
assert.equal(estimated.get(bb), 1);
});
test('オールインの期待値: AA 対 KK はおよそ 82%', () => {
const hands = new Map([
[0, ['As', 'Ad']],
[1, ['Ks', 'Kd']],
]);
const r = showdownShare(0, hands, [], [{ amount: 200, eligible: [0, 1] }], { samples: 6000 });
assert.ok(r.equity > 0.79 && r.equity < 0.85, String(r.equity));
assert.ok(Math.abs(r.ev - 200 * r.equity) < 1e-6);
// ターンまで出ていれば残り1枚を全部数える
const turn = showdownShare(0, hands, ['2c', '7h', '9d', 'Kh'], [{ amount: 100, eligible: [0, 1] }]);
assert.equal(Math.round(turn.equity * 44), 2); // KK のセットに勝てるのは残り44枚のうち Ac・Ah の2枚だけ
});
test('ハンドのレビュー: 点数は 0〜100、ストリートごとに付き、収支が合う', () => {
for (let round = 0; round < 15; round++) {
const t = makeTable(4);
const h = t.hand;
for (let g = 0; g < 100 && !h.finished; g++) {
if (h.toAct == null) {
h.runoutStep();
continue;
}
const o = h.options(h.toAct);
const r = Math.random();
if (o.canRaise && r < 0.2) act(t, 'raise', o.minRaiseTo);
else if (!o.canCheck && r < 0.35) act(t, 'fold');
else act(t, o.canCheck ? 'check' : 'call');
}
assert.ok(h.finished);
const rec = analyzeHand(h, 0, { iterations: 200 });
const scores = rec.preflop.map((x) => x.score).concat(rec.decisions.map((x) => x.score));
for (const s of scores) assert.ok(s >= 0 && s <= 100);
for (const st of ['PREFLOP', 'FLOP', 'TURN', 'RIVER']) {
const has = rec.decisions.some((d) => d.street === st) || (st === 'PREFLOP' && rec.preflop.length > 0);
assert.equal(rec.streetScores[st] != null, has);
}
assert.equal(rec.hero.net, t.seats[0].stack - 1000);
if (!rec.hero.allIn) assert.equal(rec.hero.evNet, rec.hero.net);
assert.equal(rec.actions.length, h.log.length);
}
});
test('まとめ: 成績・色ごと・見直したいハンド・収支の推移', () => {
const hands = [];
for (let round = 0; round < 10; round++) {
const t = makeTable(3);
const h = t.hand;
for (let g = 0; g < 100 && !h.finished; g++) {
if (h.toAct == null) {
h.runoutStep();
continue;
}
const o = h.options(h.toAct);
act(t, o.canCheck ? 'check' : 'call');
}
hands.push(analyzeHand(h, 0, { iterations: 100 }));
}
const s = summarize(hands);
assert.equal(s.stats.hands, 10);
assert.equal(s.graph.length, 10);
assert.equal(s.graph[9].net, hands.reduce((a, h) => a + h.hero.net, 0));
assert.equal(s.colors.reduce((a, c) => a + c.dealt, 0), 10);
assert.ok(s.worst.length <= 5);
assert.ok(s.stats.vpip >= 0 && s.stats.vpip <= 100);
assert.equal(s.stats.pfr, 0); // 誰もレイズしていない
});
test('レビュー付きCPU戦: 作成者だけが座れ、ハンドごとにレビューを作って次のハンドを待つ', async () => {
const t = new Table({
id: 'r',
name: 'review',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'review',
cpuCount: 3,
});
assert.equal(t.cardsEnabled, true);
assert.equal(t.join(T(0), 'me'), 0);
assert.equal(t.join(T(5), 'friend'), -1); // 友達は入れない
assert.equal(t.start(T(0), 0), null);
let now = 0;
for (let k = 0; k < 400 && !t.hand.finished; k++) {
if (t.hand.toAct === 0) {
const o = t.hand.options(0);
t.act(T(0), o.canCheck ? 'check' : 'fold', 0);
}
t.tick((now += 5000));
}
assert.ok(t.hand.finished);
t.tick((now += 5000));
const v = t.publicState(T(0), new Set([T(0)]));
assert.ok(v.review && v.review.last && v.awaitingNext);
assert.equal(v.review.hands, 1);
assert.equal(t.publicState(T(5), new Set()).review, null); // 作成者以外には送らない
// 押すまで次のハンドは始まらない
const number = t.hand.number;
for (let k = 0; k < 10; k++) t.tick((now += 60000));
assert.equal(t.hand.number, number);
assert.equal(t.nextHand(T(5), now), 'ホストのみ操作できます');
assert.equal(t.nextHand(T(0), now), null);
for (let k = 0; k < 10 && t.hand.number === number; k++) t.tick((now += 5000));
assert.equal(t.hand.number, number + 1);
// ファイルに保存され、一覧と中身を読める
const session = t.review.session;
await new Promise((r) => setTimeout(r, 200));
const list = await reviews.list();
assert.ok(list.some((s) => s.id === session.id && s.hands === 1));
reviews.close(session);
const saved = await reviews.get(session.id);
assert.equal(saved.hands.length, 1);
assert.equal(await reviews.get('../etc/passwd'), null);
});
test('AI に質問する文章: Hero に見えていた情報だけでハンドの状況を書く', () => {
const { buildHandPrompt } = require('../server/review/prompt');
const cardText = (c) => c[0] + { s: '♠', h: '♥', d: '♦', c: '♣' }[c[1]];
// 全員が降りて終わる(ショーダウンなし): 相手の手札は書かない
{
const t = makeTable(3);
const h = t.hand;
act(t, 'raise', 30); // BTN
act(t, 'fold'); // SB
act(t, 'fold'); // BB
const btn = h.button;
const rec = analyzeHand(h, btn, { iterations: 100 });
const text = buildHandPrompt(rec);
assert.match(text, new RegExp(rec.hero.cards.map(cardText).join(' ')));
assert.match(text, /BTN(Hero・私): レイズ to \$30/);
assert.match(text, /ショーダウンなし/);
for (const p of rec.players) {
if (p.seat === btn) continue;
assert.ok(!text.includes(p.cards.map(cardText).join(' ')), '公開されていない手札を書かない');
}
}
// ショーダウンまで行く: 場札と公開された手札を書く
{
const t = makeTable(2);
const h = t.hand;
for (let g = 0; g < 100 && !h.finished; g++) {
if (h.toAct == null) {
h.runoutStep();
continue;
}
const o = h.options(h.toAct);
act(t, o.canCheck ? 'check' : 'call');
}
const rec = analyzeHand(h, 0, { iterations: 100 });
const text = buildHandPrompt(rec);
assert.ok(text.includes(`リバー: ${cardText(rec.board[4])}`));
const villain = rec.players.find((p) => p.seat !== 0);
assert.ok(text.includes(villain.cards.map(cardText).join(' ')));
assert.match(text, /## 質問/);
}
});
+92
View File
@@ -0,0 +1,92 @@
'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);
assert.equal(t.nextHand(T(0), 0), null);
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);
assert.equal(t.nextHand(T(0), 0), null);
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);
});