Compare commits
20
Commits
6d33440048
...
main
No files matched your search
@@ -6,6 +6,13 @@
|
|||||||
"runtimeExecutable": "node",
|
"runtimeExecutable": "node",
|
||||||
"runtimeArgs": ["server/index.js"],
|
"runtimeArgs": ["server/index.js"],
|
||||||
"port": 4671
|
"port": 4671
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poker-test",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["server/index.js"],
|
||||||
|
"env": { "ACCESS_KEY": "000000", "ADMIN_KEY": "000001", "PORT": "4672" },
|
||||||
|
"port": 4672
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,43 @@
|
|||||||
|
|
||||||
友達専用のテキサスホールデム(リングゲーム)サイト。Node.js + Express + Socket.IO。
|
友達専用のテキサスホールデム(リングゲーム)サイト。Node.js + Express + Socket.IO。
|
||||||
|
|
||||||
「鍵入力 → ロビー → テーブル作成 → プレイ」まで動く。
|
「鍵入力 → ロビー → テーブル作成 → プレイ」まで動く。Node.js 24 以上が必要(@pokertools/engine の要件)。
|
||||||
|
|
||||||
## ルール・仕様
|
## ルール・仕様
|
||||||
|
|
||||||
カードは実物で配り、このサイトはチップの代わりに使う(ベット・ポット・スタックの管理)。
|
テーブル作成時に種類を選ぶ(既定はキャッシュオンリー)。
|
||||||
|
|
||||||
- ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。
|
| 種類 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| キャッシュオンリー | カードは実物で配り、サイトはチップの代わりに使う(ベット・ポット・スタックの管理)。ショーダウンの勝者はホストが選ぶ |
|
||||||
|
| 友人戦 | 実物のカードがなくても遊べる。サイトがカードを配り、勝敗とポットの分配まで自動で判定する |
|
||||||
|
| 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 を押すとハンドが始まる。ハンドが終わると結果を表示したまま止まり、ホストが「次のハンドへ」を押すと次のハンドが配られる(ホストが退席したら次のホストが押す。人間が誰も着席していなければ待たずに進む)。
|
||||||
- 画面にディーラーボタン(D)・SB・BB と、いまのストリート(PREFLOP / FLOP / TURN / RIVER)が出るので、それに合わせて実物のカードを配る。
|
- 画面にディーラーボタン(D)・SB・BB と、いまのストリート(PREFLOP / FLOP / TURN / RIVER)が出るので、それに合わせて実物のカードを配る。
|
||||||
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。
|
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。BB は SB より大きくする。ヘッズアップはボタンが SB。
|
||||||
- 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。
|
- 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。
|
||||||
- ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。
|
- ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。
|
||||||
- **ショーダウン**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。
|
- **ショーダウン(キャッシュオンリー)**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。
|
||||||
|
- **カードを配るテーブル(友人戦・CPU戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札はエンジンが暗号論的乱数(Node.js の crypto)でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。
|
||||||
|
- **CPU**: 性格ごとに別の公開ライブラリで考える。テーブルの CPU にはなるべく違う種類が割り当てられ、さらに同じ種類でも攻撃性・ブラフ頻度などがランダムに違う。どの CPU がどのライブラリを使っているかは画面にもブラウザに送るデータにも出さない。どの CPU も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
|
||||||
|
| 種類 | ライブラリ | 打ち方 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| バランス型 | [@pokertools/evaluator](https://www.npmjs.com/package/@pokertools/evaluator) | モンテカルロ法で残っている相手に勝つ確率を見積もり、ポットオッズ・レイズの回数・相手の人数から決める |
|
||||||
|
| 計算型 | [poker-calculations](https://www.npmjs.com/package/poker-calculations)(ネイティブ実装) | ライブラリの `decideAction`(勝率・ポットオッズ・コールの期待値)。積極的にレイズする。12BB 以下はオールインの損益分岐勝率でプッシュ/フォールド |
|
||||||
|
| 理論型(タイト) | [poker-utils](https://www.npmjs.com/package/poker-utils) | プリフロップは手の強さの順位で上位だけ参加。フロップ以降は相手のレンジ(ベットされるほど強い手に絞る)に対する勝率で判断し、ブラフは alpha・bluffEV で割に合うときだけ |
|
||||||
|
| 直感型(ルーズ) | [pokersolver](https://www.npmjs.com/package/pokersolver) | 勝率は計算せず、いまできている役とドローだけを見る。参加する手が広く、ペアがあればなかなか降りない |
|
||||||
|
|
||||||
|
計算型のネイティブアドオンが読み込めない環境では、残りの3種類だけを使う。思考がエラーになったときはバランス型で決め直し、最後に必ず受け付けられるアクションに直す(`server/ai.js`)。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。
|
||||||
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
|
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
|
||||||
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
|
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
|
||||||
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
|
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
|
||||||
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る)。
|
- テーブル上部のトロフィーのボタンで、着席している全員のランキングを開ける。「スタック」(持っているチップ。ハンド中は、まだ降りていない人がそのハンドで出した額も含める)と「収支」(持っているチップ − バイインの合計。リバイを含む)を切り替えられる。同じ値は同じ順位。
|
||||||
- ホストの一時停止中は、アクション・勝者の確定・ブラインドタイマー・次のハンドの開始がすべて止まる。
|
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。
|
||||||
|
- チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。
|
||||||
|
- ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。
|
||||||
|
|
||||||
## ローカルで動かす
|
## ローカルで動かす
|
||||||
|
|
||||||
@@ -43,9 +63,37 @@ http://localhost:4671 を開く。
|
|||||||
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
|
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
|
||||||
| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 |
|
| `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` を開いて管理者キーを入れる。
|
- 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。
|
||||||
- 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。
|
- 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。
|
||||||
@@ -71,16 +119,23 @@ http://localhost:4671 を開く。
|
|||||||
| `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO |
|
| `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO |
|
||||||
| `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック |
|
| `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック |
|
||||||
| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) |
|
| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) |
|
||||||
| `server/hand.js` | 1ハンドのチップの動き(ブラインド・ベッティング・サイドポット・勝者への分配) |
|
| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う |
|
||||||
| `test/` | チップ処理のテスト(`npm test`) |
|
| `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`) |
|
||||||
| `server/config.js` | `.env` の読み込みと各種上限値 |
|
| `server/config.js` | `.env` の読み込みと各種上限値 |
|
||||||
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
|
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
|
||||||
| `public/*.html`, `public/assets/*.js` | 各画面 |
|
| `public/*.html`, `public/assets/*.js` | 各画面 |
|
||||||
| `admin/` | 管理者専用ページ(ハンドレンジ学習・カード画像) |
|
| `public/assets/style.css`, `public/assets/fluid.js` | 見た目の土台(light / dark の色・半透明の素材・字の大きさ)と、動きの共通部品(ばね・下から出るシート・滑るセグメント・指の速度と勢いの先読み) |
|
||||||
|
| `admin/` | 管理者専用ページ(ハンドレンジ学習・CPU戦のレビュー・登録してあるハンドレンジ・カード画像) |
|
||||||
|
|
||||||
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
|
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
|
||||||
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
|
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
|
||||||
- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
|
- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
|
||||||
|
- ホストはテーブル名の右の「席順を変更」(↑↓のボタン、3人以上のとき)で、着席者の並びを変えられる。自分を一番上に固定し、その下に左隣から時計回りの順で並び、行の右端のつまみをドラッグ(キーボードでは ↑↓)して並べ替える。小さなテーブルの図で仕上がりを確かめられ、「シャッフル」でランダムにもできる。ハンドの途中(結果の表示中を含む)に決めた席順は次のハンドの始めに反映し(ボタンに印が付く)、決めた後に座った人は並びの最後に入る。ディーラーボタンは同じ人に付いたまま、次のハンドで新しい並びの次の人へ回る。
|
||||||
|
|
||||||
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
|
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,22 +1,8 @@
|
|||||||
/* ハンドレンジ学習(管理者専用)。基本のデザインは /assets/style.css を使う */
|
/* ハンドレンジ学習(管理者専用)。基本のデザインとセグメントは /assets/style.css を使う */
|
||||||
|
|
||||||
.range-wrap { padding-bottom: 32px; }
|
.range-tabs {
|
||||||
|
margin: 0 0 16px;
|
||||||
/* タブ */
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
.seg-btn.active { background: var(--panel-2); color: var(--text); }
|
|
||||||
|
|
||||||
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
|
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
|
||||||
.cat-navy { --c: #20365d; --fg: #fff; }
|
.cat-navy { --c: #20365d; --fg: #fff; }
|
||||||
@@ -25,58 +11,203 @@
|
|||||||
.cat-green { --c: #4e8051; --fg: #101910; }
|
.cat-green { --c: #4e8051; --fg: #101910; }
|
||||||
.cat-blue { --c: #6aa5c9; --fg: #14191f; }
|
.cat-blue { --c: #6aa5c9; --fg: #14191f; }
|
||||||
.cat-white { --c: #f7f7f7; --fg: #222; }
|
.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 の表 */
|
/* 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 {
|
.range-cell {
|
||||||
aspect-ratio: 1;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
aspect-ratio: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
background: var(--c);
|
background: var(--c);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font: inherit;
|
|
||||||
font-size: clamp(8px, 2.4vw, 12px);
|
font-size: clamp(8px, 2.4vw, 12px);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
cursor: pointer;
|
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 {
|
||||||
.range-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
display: flex;
|
||||||
.swatch-dot { width: 14px; height: 14px; border-radius: 4px; background: var(--c); flex: none; }
|
flex-wrap: wrap;
|
||||||
.swatch-dot.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; }
|
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; }
|
.selected-card {
|
||||||
.sel-label { display: flex; align-items: center; gap: 8px; font-weight: 800; margin-top: 6px; }
|
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 {
|
||||||
.quiz-progress i { display: block; height: 100%; width: 0; background: var(--green); transition: width 0.2s; }
|
height: 6px;
|
||||||
.quiz-stats { display: flex; justify-content: center; gap: 12px; font-size: 13px; color: var(--muted); }
|
margin: 18px 0 8px;
|
||||||
.quiz-hand { text-align: center; margin: 12px 0; padding: 18px 12px; }
|
overflow: hidden;
|
||||||
.quiz-cards { display: flex; justify-content: center; gap: 10px; min-height: 0; }
|
border-radius: 999px;
|
||||||
.quiz-cards img { width: min(32%, 120px); height: auto; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); }
|
background: var(--fill-2);
|
||||||
.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-progress i {
|
||||||
.quiz-answers .btn {
|
display: block;
|
||||||
font-size: 15px;
|
width: 0;
|
||||||
padding: 12px 8px;
|
height: 100%;
|
||||||
justify-content: flex-start;
|
border-radius: inherit;
|
||||||
background: var(--panel-2);
|
background: var(--tint);
|
||||||
color: var(--text);
|
transition: width 0.5s var(--ease-spring);
|
||||||
border: 2px solid transparent;
|
}
|
||||||
|
.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
@@ -2,43 +2,50 @@
|
|||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#24493b">
|
<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">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>ハンドレンジ - Hold'em Night</title>
|
<title>ハンドレンジ - Hold'em Night</title>
|
||||||
<link rel="icon" href="/assets/favicon.ico">
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
<link rel="stylesheet" href="/range/files/range.css">
|
<link rel="stylesheet" href="/range/files/range.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="page-range">
|
||||||
<main class="wrap range-wrap">
|
<header class="nav-bar" id="nav">
|
||||||
<div class="topbar">
|
<div class="nav-inner">
|
||||||
<a class="icon-btn" href="/" aria-label="ロビーへ戻る">←</a>
|
<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>
|
||||||
<h1>ハンドレンジ</h1>
|
<span class="nav-title"></span>
|
||||||
</div>
|
</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 active" data-view="range">レンジ表</button>
|
||||||
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
|
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- レンジ表 -->
|
<!-- レンジ表 -->
|
||||||
<section id="view-range">
|
<section id="view-range">
|
||||||
|
<div class="matrix-card">
|
||||||
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
|
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
|
||||||
|
</div>
|
||||||
<div class="range-legend" id="legend"></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="sel-hand">—</div>
|
||||||
<div class="hint">セルをタップすると分類を確認できます</div>
|
<div class="hint">セルをタップすると分類を確認できます</div>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- クイズ -->
|
<!-- クイズ -->
|
||||||
<section id="view-quiz" hidden>
|
<section id="view-quiz" hidden>
|
||||||
<div class="label">問題数(全169ハンドからランダム)</div>
|
<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 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="20">20問</button>
|
||||||
<button type="button" class="seg-btn" data-count="0">全問</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="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<div class="row mt-12">
|
<div class="row mt-12">
|
||||||
<button type="button" class="btn btn-dark" id="start-quiz">開始</button>
|
<button type="button" class="btn btn-lg" id="start-quiz">開始</button>
|
||||||
<button type="button" class="btn btn-blue" id="next-question" disabled>次の問題 →</button>
|
<button type="button" class="btn btn-tint btn-lg" id="next-question" disabled>次の問題</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/fluid.js"></script>
|
||||||
|
<script src="/range/files/range-data.js"></script>
|
||||||
<script src="/range/files/range.js"></script>
|
<script src="/range/files/range.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+34
-38
@@ -3,39 +3,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
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 = [
|
Fluid.navBar($('nav'), $('large-title'));
|
||||||
{ id: 'navy', label: '8人(強)' },
|
const tabsSeg = Fluid.Segmented($('tabs'));
|
||||||
{ id: 'red', label: '8人(弱)' },
|
Fluid.Segmented($('count'));
|
||||||
{ 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;
|
|
||||||
|
|
||||||
function swatch(id) {
|
function swatch(id) {
|
||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
@@ -43,11 +15,19 @@
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// レイズを受けたときの対応の枠(3bet は水色、コールは黄)
|
||||||
|
function frameSwatch(id) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = `swatch-dot swatch-frame facing-${id}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- タブ ----------
|
// ---------- タブ ----------
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
$('view-range').hidden = name !== 'range';
|
$('view-range').hidden = name !== 'range';
|
||||||
$('view-quiz').hidden = name !== 'quiz';
|
$('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) => {
|
$('tabs').addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('[data-view]');
|
const b = e.target.closest('[data-view]');
|
||||||
@@ -60,6 +40,11 @@
|
|||||||
item.append(swatch(c.id), c.label);
|
item.append(swatch(c.id), c.label);
|
||||||
$('legend').append(item);
|
$('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 r = 0; r < 13; r++) {
|
||||||
for (let c = 0; c < 13; c++) {
|
for (let c = 0; c < 13; c++) {
|
||||||
@@ -67,9 +52,10 @@
|
|||||||
const cat = categoryOf(hand);
|
const cat = categoryOf(hand);
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = '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.textContent = hand;
|
||||||
b.title = `${hand}:${labelOf(cat)}`;
|
b.title = `${hand}:${labelOf(cat)}${facing ? `/レイズを受けたら${facingAction(facing).label}` : ''}`;
|
||||||
b.addEventListener('click', () => select(b, hand, cat));
|
b.addEventListener('click', () => select(b, hand, cat));
|
||||||
$('matrix').append(b);
|
$('matrix').append(b);
|
||||||
}
|
}
|
||||||
@@ -86,10 +72,17 @@
|
|||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'sel-label';
|
label.className = 'sel-label';
|
||||||
label.append(swatch(cat), labelOf(cat));
|
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');
|
const note = document.createElement('div');
|
||||||
note.className = 'hint';
|
note.className = 'hint';
|
||||||
note.textContent = cat === 'purple' ? '灰色+紫枠。クイズの答えは「紫枠」です。' : 'このハンドの分類です。';
|
note.textContent =
|
||||||
box.append(name, label, note);
|
(cat === 'gray' ? 'どのポジションからもオープンしない手です。' : `${labelOf(cat)} からオープンできる一番狭い分類です。`) +
|
||||||
|
'枠は CO のオープンを BTN で受けたときの対応です。';
|
||||||
|
box.append(name, label, vs, note);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('start-from-range').addEventListener('click', () => {
|
$('start-from-range').addEventListener('click', () => {
|
||||||
@@ -111,7 +104,7 @@
|
|||||||
const b = e.target.closest('[data-count]');
|
const b = e.target.closest('[data-count]');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
quizCount = Number(b.dataset.count);
|
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) {
|
function shuffle(a) {
|
||||||
@@ -198,6 +191,9 @@
|
|||||||
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
|
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
|
||||||
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
|
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
|
||||||
fb.hidden = false;
|
fb.hidden = false;
|
||||||
|
// 間違えたら選んだボタンが揺れる(振動と同じ瞬間に)
|
||||||
|
if (!ok) Fluid.shake(button);
|
||||||
|
Fluid.haptic(ok ? 10 : [18, 50, 18]);
|
||||||
updateStats();
|
updateStats();
|
||||||
$('next-question').disabled = false;
|
$('next-question').disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
Generated
+97
-1
@@ -8,11 +8,57 @@
|
|||||||
"name": "friends-holdem",
|
"name": "friends-holdem",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pokertools/engine": "2.1.0",
|
||||||
|
"@pokertools/evaluator": "2.1.0",
|
||||||
"express": "^4.22.3",
|
"express": "^4.22.3",
|
||||||
|
"poker-calculations": "4.0.3",
|
||||||
|
"poker-utils": "14.0.1",
|
||||||
|
"pokersolver": "2.1.4",
|
||||||
"socket.io": "^4.8.4"
|
"socket.io": "^4.8.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pokertools/engine": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pokertools/engine/-/engine-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/3kXKpFf6nHygWQuX1AMu8yQa6HW9RhLW7TkOZJnXSESJ/vs00YWs976F2zNzqnlBPK90LvRSw4ZyqwK+9Ajow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pokertools/evaluator": "2.1.0",
|
||||||
|
"@pokertools/types": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^24.15.0 || >=26.0.0",
|
||||||
|
"npm": ">=12.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@pokertools/evaluator": "2.1.0",
|
||||||
|
"@pokertools/types": "2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pokertools/evaluator": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pokertools/evaluator/-/evaluator-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9ZmmwrA9g2IvKST2y0HrdiwTGIM6uoMoom9fXq0ZfQHGcd24X5ydQJi3S5ciR/LFjSN4T2EakwXqVED6tuWD+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^24.15.0 || >=26.0.0",
|
||||||
|
"npm": ">=12.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pokertools/types": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pokertools/types/-/types-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FBKLd3cczFfHQAH4DY0n+VEcdccEW7C5wa4PaAA/jaBvmYo0OU7PBHduPEhcO3ZpWBAr3KjW1ESzaajhlvTzCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^24.15.0 || >=26.0.0",
|
||||||
|
"npm": ">=12.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
@@ -631,6 +677,17 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -679,6 +736,36 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/poker-calculations": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/poker-calculations/-/poker-calculations-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-vpX+2nVuSGxbVd4+oGrbUSthEQ+/T5sLnkOQsxOB9ir7SAl0vcb9XCDWgjXUsFBHXR+h+MQCK2TIrfWaj+SyhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/poker-utils": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/poker-utils/-/poker-utils-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-wj1dKtUr2uXSs4/53NIoSc6ZRQw+HblwtSVqAZMqraApqrhfv/RfietsLWShd0J7A2U3N3i4umnfh4IhRxs2Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pokersolver": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pokersolver/-/pokersolver-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-vmgZS+K8H8r1RePQykFM5YyvlKo1v3xVec8FMBjg9N6mR2Tj/n/X415w+lG67FWbrk71D/CADmKFinDgaQlAsw==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 4.0.0"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
@@ -1078,6 +1165,15 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||||
|
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-1
@@ -10,10 +10,15 @@
|
|||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pokertools/engine": "2.1.0",
|
||||||
|
"@pokertools/evaluator": "2.1.0",
|
||||||
"express": "^4.22.3",
|
"express": "^4.22.3",
|
||||||
|
"poker-calculations": "4.0.3",
|
||||||
|
"poker-utils": "14.0.1",
|
||||||
|
"pokersolver": "2.1.4",
|
||||||
"socket.io": "^4.8.4"
|
"socket.io": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+178
-3
@@ -28,8 +28,46 @@
|
|||||||
}
|
}
|
||||||
return t;
|
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) {
|
function esc(s) {
|
||||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
@@ -38,18 +76,109 @@
|
|||||||
return '$' + Number(n).toLocaleString('en-US');
|
return '$' + Number(n).toLocaleString('en-US');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上から降りてきて、少ししたら同じ道を上へ戻る通知
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
function toast(msg) {
|
function toast(msg) {
|
||||||
let el = document.querySelector('.toast');
|
let el = document.querySelector('.toast');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
el.className = 'toast';
|
el.className = 'toast';
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
el.setAttribute('aria-live', 'polite');
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
void el.offsetWidth; // 最初の1回も画面の外から動かす
|
||||||
}
|
}
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.hidden = false;
|
el.classList.add('show');
|
||||||
clearTimeout(toastTimer);
|
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) {
|
async function api(method, url, body) {
|
||||||
@@ -68,6 +197,52 @@
|
|||||||
return data;
|
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 のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
|
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
|
||||||
function connectSocket() {
|
function connectSocket() {
|
||||||
const socket = io({ transports: ['websocket'] });
|
const socket = io({ transports: ['websocket'] });
|
||||||
@@ -96,5 +271,5 @@
|
|||||||
FINISHING: 'FINISHING',
|
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 };
|
||||||
})();
|
})();
|
||||||
+43
-4
@@ -1,7 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { Store, api, chips } = App;
|
const { Store, api, chips, selectOnFocus } = App;
|
||||||
if (!Store.name) {
|
if (!Store.name) {
|
||||||
location.replace('/');
|
location.replace('/');
|
||||||
return;
|
return;
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
const timerInput = $('timer');
|
const timerInput = $('timer');
|
||||||
const intervalInput = $('interval');
|
const intervalInput = $('interval');
|
||||||
const scheduleEl = $('schedule');
|
const scheduleEl = $('schedule');
|
||||||
|
const cpuOptions = $('cpu-options');
|
||||||
|
const intervalRow = $('interval-row');
|
||||||
const blindHint = $('blind-hint');
|
const blindHint = $('blind-hint');
|
||||||
const errorEl = $('error');
|
const errorEl = $('error');
|
||||||
const submitBtn = $('submit');
|
const submitBtn = $('submit');
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
const stack = int(stackInput);
|
const stack = int(stackInput);
|
||||||
const bb = int(bbInput);
|
const bb = int(bbInput);
|
||||||
if (blindsTouched && stack > 0 && bb > 0) {
|
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 {
|
} else {
|
||||||
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,8 @@
|
|||||||
const bb = int(bbInput);
|
const bb = int(bbInput);
|
||||||
const on = timerInput.checked;
|
const on = timerInput.checked;
|
||||||
intervalInput.disabled = !on;
|
intervalInput.disabled = !on;
|
||||||
scheduleEl.hidden = !on || !(sb > 0 && bb >= sb);
|
setOpen(intervalRow, on);
|
||||||
|
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
|
||||||
if (scheduleEl.hidden) return;
|
if (scheduleEl.hidden) return;
|
||||||
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
||||||
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
|
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
|
||||||
@@ -66,9 +69,23 @@
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
setOpen(cpuOptions, mode === 'cpu' || mode === 'review');
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
updateHint();
|
updateHint();
|
||||||
updateSchedule();
|
updateSchedule();
|
||||||
|
updateMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
stackInput.addEventListener('input', () => {
|
stackInput.addEventListener('input', () => {
|
||||||
@@ -91,7 +108,11 @@
|
|||||||
});
|
});
|
||||||
timerInput.addEventListener('change', refresh);
|
timerInput.addEventListener('change', refresh);
|
||||||
intervalInput.addEventListener('input', 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);
|
$('reroll').addEventListener('click', suggestName);
|
||||||
|
Fluid.Segmented(form.querySelector('.segmented.numbers'));
|
||||||
|
Fluid.navBar($('nav'), $('large-title'));
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
const name = nameInput.value.trim();
|
const name = nameInput.value.trim();
|
||||||
@@ -102,7 +123,7 @@
|
|||||||
if (!name) return [nameInput, 'テーブル名を入力してください'];
|
if (!name) return [nameInput, 'テーブル名を入力してください'];
|
||||||
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
|
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
|
||||||
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
|
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
|
||||||
if (!(bb >= sb)) return [bbInput, 'BBはSB以上にしてください'];
|
if (!(bb > sb)) return [bbInput, 'BBはSBより大きくしてください'];
|
||||||
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
|
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
|
||||||
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
|
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
|
||||||
return null;
|
return null;
|
||||||
@@ -128,8 +149,13 @@
|
|||||||
bigBlind: int(bbInput),
|
bigBlind: int(bbInput),
|
||||||
blindTimerEnabled: timerInput.checked,
|
blindTimerEnabled: timerInput.checked,
|
||||||
blindIntervalMin: int(intervalInput),
|
blindIntervalMin: int(intervalInput),
|
||||||
|
mode: radioValue('mode'),
|
||||||
|
cpuCount: Number(radioValue('cpu-count')),
|
||||||
token: Store.token,
|
token: Store.token,
|
||||||
});
|
});
|
||||||
|
// 次にテーブルを作るときの初期値にする
|
||||||
|
Store.defaultStack = int(stackInput);
|
||||||
|
Store.lastBlinds = { stack: int(stackInput), sb: int(sbInput), bb: int(bbInput) };
|
||||||
location.href = '/table/' + encodeURIComponent(id);
|
location.href = '/table/' + encodeURIComponent(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
@@ -137,7 +163,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// レビュー付きCPU戦は管理者だけが作れる(サーバでも確認する)
|
||||||
|
api('GET', '/api/me')
|
||||||
|
.then((me) => { $('review-mode').hidden = !me.admin; })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// 開始スタックは設定(=前回値)。SB/BB は前回と同じ開始スタックなら前回値を引き継ぐ
|
||||||
|
stackInput.value = Store.defaultStack;
|
||||||
applyDefaultBlinds();
|
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();
|
refresh();
|
||||||
suggestName();
|
suggestName();
|
||||||
})();
|
})();
|
||||||
@@ -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
@@ -16,12 +16,18 @@
|
|||||||
|
|
||||||
const value = () => inputs.map((i) => i.value).join('');
|
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) {
|
function fill(digits, from) {
|
||||||
let i = from;
|
let i = from;
|
||||||
for (const d of digits) {
|
for (const d of digits) {
|
||||||
if (i >= inputs.length) break;
|
if (i >= inputs.length) break;
|
||||||
inputs[i++].value = d;
|
inputs[i++].value = d;
|
||||||
}
|
}
|
||||||
|
markFilled();
|
||||||
inputs[Math.min(i, inputs.length - 1)].focus();
|
inputs[Math.min(i, inputs.length - 1)].focus();
|
||||||
if (value().length === 6) submit();
|
if (value().length === 6) submit();
|
||||||
}
|
}
|
||||||
@@ -31,11 +37,13 @@
|
|||||||
const digits = input.value.replace(/\D/g, '');
|
const digits = input.value.replace(/\D/g, '');
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (digits) fill(digits, idx);
|
if (digits) fill(digits, idx);
|
||||||
|
else markFilled();
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Backspace' && !input.value && idx > 0) {
|
if (e.key === 'Backspace' && !input.value && idx > 0) {
|
||||||
inputs[idx - 1].value = '';
|
inputs[idx - 1].value = '';
|
||||||
|
markFilled();
|
||||||
inputs[idx - 1].focus();
|
inputs[idx - 1].focus();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'ArrowLeft' && idx > 0) {
|
} else if (e.key === 'ArrowLeft' && idx > 0) {
|
||||||
@@ -68,10 +76,11 @@
|
|||||||
location.replace(App.safeNext(next));
|
location.replace(App.safeNext(next));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
box.classList.remove('shake');
|
// 揺れと振動は同じ瞬間に(どちらも「違う」の合図)
|
||||||
void box.offsetWidth;
|
Fluid.shake(box);
|
||||||
box.classList.add('shake');
|
Fluid.haptic([18, 50, 18]);
|
||||||
inputs.forEach((i) => { i.value = ''; });
|
inputs.forEach((i) => { i.value = ''; });
|
||||||
|
markFilled();
|
||||||
inputs[0].focus();
|
inputs[0].focus();
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
|
|||||||
+218
-28
@@ -6,6 +6,9 @@
|
|||||||
const nameError = document.getElementById('name-error');
|
const nameError = document.getElementById('name-error');
|
||||||
const listEl = document.getElementById('list');
|
const listEl = document.getElementById('list');
|
||||||
const countEl = document.getElementById('count');
|
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.value = Store.name;
|
||||||
nameInput.addEventListener('input', () => {
|
nameInput.addEventListener('input', () => {
|
||||||
@@ -22,9 +25,17 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404)
|
let admin = false;
|
||||||
|
let tables = null;
|
||||||
|
|
||||||
|
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
|
||||||
|
// (ページ・API 自体もサーバ側で管理者以外は 404)
|
||||||
App.api('GET', '/api/me')
|
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(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
document.getElementById('create').addEventListener('click', () => {
|
document.getElementById('create').addEventListener('click', () => {
|
||||||
@@ -32,43 +43,222 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
listEl.addEventListener('click', (e) => {
|
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]');
|
const btn = e.target.closest('[data-join]');
|
||||||
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
|
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
|
||||||
});
|
});
|
||||||
|
|
||||||
function phaseBadge(t) {
|
async function deleteTable(id, btn) {
|
||||||
if (t.paused) return '<span class="badge paused">PAUSED</span>';
|
const t = tables && tables.find((x) => x.id === id);
|
||||||
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
|
if (!t) return;
|
||||||
return '<span class="badge live">PLAYING</span>';
|
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} テーブル` : '';
|
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
||||||
|
footerEl.hidden = !admin || !tables.length;
|
||||||
if (!tables.length) {
|
if (!tables.length) {
|
||||||
|
rows.clear();
|
||||||
|
openRow = null;
|
||||||
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listEl.innerHTML = tables
|
const empty = listEl.querySelector('.empty');
|
||||||
.map((t) => {
|
if (empty) empty.remove();
|
||||||
const full = t.playerCount >= t.maxSeats;
|
const ids = new Set(tables.map((t) => t.id));
|
||||||
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
|
for (const [id, row] of rows) {
|
||||||
return `
|
if (ids.has(id)) continue;
|
||||||
<div class="table-item">
|
if (openRow === row) openRow = null;
|
||||||
<div class="info">
|
row.remove();
|
||||||
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
|
rows.delete(id);
|
||||||
<div class="meta">
|
}
|
||||||
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
|
tables.forEach((t, i) => {
|
||||||
<span>開始 <b>${chips(t.startingStack)}</b></span>
|
const row = rowEl(t.id);
|
||||||
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
|
const html = rowHtml(t);
|
||||||
<span>${timer}</span>
|
const content = row.querySelector('.table-row');
|
||||||
</div>
|
if (content.html !== html) {
|
||||||
</div>
|
content.html = html;
|
||||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
content.innerHTML = html;
|
||||||
${full ? '観戦' : '参加'}
|
}
|
||||||
</button>
|
const action = row.querySelector('.swipe-action');
|
||||||
</div>`;
|
action.hidden = !admin;
|
||||||
})
|
action.tabIndex = admin ? 0 : -1;
|
||||||
.join('');
|
if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = App.connectSocket();
|
const socket = App.connectSocket();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
+3824
-524
File diff suppressed because it is too large.
Load diff
+1084
-120
File diff suppressed because it is too large.
Load diff
+108
-29
@@ -2,58 +2,137 @@
|
|||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#24493b">
|
<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">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>テーブル作成 - Hold'em Night</title>
|
<title>テーブル作成 - Hold'em Night</title>
|
||||||
<link rel="icon" href="/assets/favicon.ico">
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="page-create">
|
||||||
<main class="wrap">
|
<header class="nav-bar" id="nav">
|
||||||
<div class="topbar">
|
<div class="nav-inner">
|
||||||
<a class="icon-btn" href="/" aria-label="戻る">←</a>
|
<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>
|
||||||
<h1>テーブルを作成</h1>
|
<span class="nav-title"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<h1 class="large-title" id="large-title">テーブルを作成</h1>
|
||||||
|
|
||||||
<form id="form" novalidate>
|
<form id="form" novalidate>
|
||||||
<label for="table-name">テーブル名</label>
|
<section class="group">
|
||||||
<div class="row">
|
<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>
|
<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>
|
||||||
|
|
||||||
<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="ビッグブラインド">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
|
</section>
|
||||||
|
|
||||||
<div class="switch-row">
|
<section class="group">
|
||||||
<span>ブラインドタイマー</span>
|
<div class="group-header" id="mode-label">テーブルの種類</div>
|
||||||
<label class="switch">
|
<div class="list" role="radiogroup" aria-labelledby="mode-label">
|
||||||
<input id="timer" type="checkbox" aria-label="ブラインドタイマー">
|
<label class="cell choice">
|
||||||
<span></span>
|
<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>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
<label><input type="radio" name="cpu-count" value="3"><span>3</span></label>
|
||||||
|
<label><input type="radio" name="cpu-count" value="4"><span>4</span></label>
|
||||||
|
<label><input type="radio" name="cpu-count" value="5" checked><span>5</span></label>
|
||||||
|
<label><input type="radio" name="cpu-count" value="6"><span>6</span></label>
|
||||||
|
<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">
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
<label for="interval">インターバル</label>
|
||||||
<div class="unit-input">
|
|
||||||
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
|
<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>
|
||||||
<div class="schedule" id="schedule" hidden></div>
|
<div class="schedule" id="schedule" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="error-text" id="error"></div>
|
<div class="submit-area">
|
||||||
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">テーブルを作成</button>
|
<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>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/fluid.js"></script>
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
<script src="/assets/blinds.js"></script>
|
<script src="/assets/blinds.js"></script>
|
||||||
<script src="/assets/create.js"></script>
|
<script src="/assets/create.js"></script>
|
||||||
|
|||||||
+15
-9
@@ -2,20 +2,25 @@
|
|||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#24493b">
|
<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">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Hold'em Night</title>
|
<title>Hold'em Night</title>
|
||||||
<link rel="icon" href="/assets/favicon.ico">
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="page-key">
|
||||||
<main class="wrap">
|
<main class="key-screen">
|
||||||
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
<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>
|
<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">
|
<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="1桁目" autocomplete="one-time-code">
|
||||||
<input type="tel" inputmode="numeric" aria-label="2桁目">
|
<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="5桁目">
|
||||||
<input type="tel" inputmode="numeric" aria-label="6桁目">
|
<input type="tel" inputmode="numeric" aria-label="6桁目">
|
||||||
</div>
|
</div>
|
||||||
<div class="error-text text-center" id="error"></div>
|
<p class="error-text" id="error" role="alert"></p>
|
||||||
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">入室する</button>
|
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">入室する</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/assets/fluid.js"></script>
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
<script src="/assets/key.js"></script>
|
<script src="/assets/key.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+70
-16
@@ -2,34 +2,88 @@
|
|||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#24493b">
|
<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">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Hold'em Night</title>
|
<title>Hold'em Night</title>
|
||||||
<link rel="icon" href="/assets/favicon.ico">
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
</head>
|
</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">
|
<main class="wrap">
|
||||||
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
|
<div class="hero">
|
||||||
|
<div class="app-mark small" aria-hidden="true">
|
||||||
<label for="name">あなたの名前</label>
|
<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>
|
||||||
<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>
|
</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 class="empty">読み込み中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="group-footer" id="list-footer" hidden>行を左へスワイプすると削除できます(管理者)。</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/assets/fluid.js"></script>
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
<script src="/assets/lobby.js"></script>
|
<script src="/assets/lobby.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+178
-47
@@ -2,27 +2,47 @@
|
|||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#24493b">
|
<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">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>テーブル - Hold'em Night</title>
|
<title>テーブル - Hold'em Night</title>
|
||||||
<link rel="icon" href="/assets/favicon.ico">
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="table-page">
|
<body class="page-table">
|
||||||
<div class="conn-lost" id="conn-lost" hidden>接続が切れました。再接続中…</div>
|
<div class="conn-lost" id="conn-lost" role="status" hidden><span class="spinner" aria-hidden="true"></span>接続が切れました。再接続中…</div>
|
||||||
|
|
||||||
<main class="wrap" id="main" hidden>
|
<main class="table-main" id="main" hidden>
|
||||||
<div class="table-header">
|
<header class="table-bar">
|
||||||
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
|
<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>
|
<h1 id="table-name"></h1>
|
||||||
<!-- ホストだけに表示 -->
|
<!-- ホストだけに表示 -->
|
||||||
<span class="host-controls" id="host-controls" hidden>
|
<span class="host-controls" id="host-controls" hidden>
|
||||||
<button class="btn btn-green btn-sm" id="start">START</button>
|
<button class="btn btn-tint btn-sm" id="start">START</button>
|
||||||
<button class="icon-btn" id="pause" aria-label="一時停止" title="一時停止">⏸</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>
|
</span>
|
||||||
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
|
<!-- レビュー付きCPU戦のみ -->
|
||||||
</div>
|
<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">
|
<div class="level-bar">
|
||||||
<span class="lv" id="level"></span>
|
<span class="lv" id="level"></span>
|
||||||
@@ -35,50 +55,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="felt-area" id="felt-area">
|
<div class="felt-area" id="felt-area">
|
||||||
|
<div class="rail">
|
||||||
<div class="felt">
|
<div class="felt">
|
||||||
<div class="pot-label">POT</div>
|
<div class="pot-label">POT</div>
|
||||||
<div class="pot" id="pot">$0</div>
|
<div class="pot" id="pot">$0</div>
|
||||||
|
<div class="board" id="board" hidden></div>
|
||||||
<div class="phase"><span id="phase">WAITING</span><span class="hand-no" id="hand-no"></span></div>
|
<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 class="result" id="result" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="seats"></div>
|
<div id="seats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spectator-note" id="spectator-note" hidden>
|
<div class="spectator-note" id="spectator-note" hidden>
|
||||||
<span id="spectator-text">満席のため観戦中です</span>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="action-bar" id="action-bar" hidden>
|
<section class="action-bar" id="action-bar" hidden>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<div class="me-row">
|
<div class="me-row">
|
||||||
|
<div class="my-cards" id="my-cards" hidden></div>
|
||||||
<div class="me-info">
|
<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 id="my-net"></span><span class="my-bet" id="my-bet"></span></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>
|
</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>
|
||||||
|
|
||||||
<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 class="award-panel" id="award-panel" hidden>
|
||||||
<div id="award-pots"></div>
|
<div class="award-pots" id="award-pots"></div>
|
||||||
<button class="btn btn-orange btn-block" id="award-confirm" disabled>確定</button>
|
<button class="btn btn-tint btn-lg 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>
|
</div>
|
||||||
|
|
||||||
<div class="actions" id="actions">
|
<div class="actions" id="actions">
|
||||||
@@ -86,33 +96,154 @@
|
|||||||
<button class="btn act-call" id="act-call" disabled>Check</button>
|
<button class="btn act-call" id="act-call" disabled>Check</button>
|
||||||
<button class="btn act-raise" id="act-raise" disabled>Raise</button>
|
<button class="btn act-raise" id="act-raise" disabled>Raise</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions raising" id="raise-actions" hidden>
|
<div class="actions single" id="next-actions" hidden>
|
||||||
<button class="btn btn-dark" id="raise-cancel">戻る</button>
|
<button class="btn" id="review-show" hidden>レビュー</button>
|
||||||
<button class="btn act-raise" id="raise-confirm">Raise</button>
|
<button class="btn btn-tint" id="next-hand">次のハンドへ</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions single" id="alt-actions" hidden>
|
<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>
|
<button class="btn btn-blue" id="rebuy">リバイ</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="center-message" id="not-found" hidden>
|
<div class="center-message" id="not-found" hidden>
|
||||||
<h2>テーブルが見つかりません</h2>
|
<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>
|
||||||
<p>終了したか、URLが間違っている可能性があります。</p>
|
<h2 id="not-found-title">テーブルが見つかりません</h2>
|
||||||
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
|
||||||
|
<a class="btn btn-tint btn-lg" href="/">ロビーへ戻る</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop" id="name-modal" hidden>
|
<!-- ベット/レイズ額(Bet・Raise ボタンで下から出るシート。つまみを下へ払うと閉じる)。
|
||||||
<form class="modal" id="name-form">
|
候補はプリフロップのオープン・3bet・4bet、フロップ以降のベット・レイズで入れ替わる -->
|
||||||
<h2>名前を入力</h2>
|
<div class="sheet-backdrop bet-backdrop" id="bet-modal" hidden>
|
||||||
<p class="hint">テーブルで表示される名前です</p>
|
<div class="sheet bet-sheet" role="dialog" aria-modal="true" aria-labelledby="bet-title" tabindex="-1">
|
||||||
<input id="name-input" type="text" maxlength="12" placeholder="例: とらのすけ" class="mt-12" autocomplete="nickname">
|
<div class="sheet-header bet-header" data-sheet-drag>
|
||||||
<button class="btn btn-orange btn-block mt-12" type="submit">参加する</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/assets/fluid.js"></script>
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
<script src="/assets/table.js"></script>
|
<script src="/assets/table.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CPU プレイヤーの思考。性格ごとに別の公開ライブラリを使った思考を持たせる(どれを使っているかは画面に出さない)。
|
||||||
|
// montecarlo: バランス型。@pokertools/evaluator でモンテカルロ法の勝率を見積もる
|
||||||
|
// calculator: 計算型。poker-calculations の decideAction(勝率・ポットオッズ・期待値)
|
||||||
|
// range : 理論型(タイト)。poker-utils のハンドの順位とレンジに対する勝率・ブラフの理論値
|
||||||
|
// madehand : 直感型(ルーズ)。pokersolver で、いまできている役だけを見て打つ
|
||||||
|
// どの思考も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { legalize } = require('./bots/common');
|
||||||
|
const montecarlo = require('./bots/montecarlo');
|
||||||
|
|
||||||
|
const BRAINS = {
|
||||||
|
montecarlo,
|
||||||
|
calculator: require('./bots/calculator'),
|
||||||
|
range: require('./bots/range'),
|
||||||
|
madehand: require('./bots/madehand'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// この環境で使える思考(ネイティブアドオンが読み込めないなどで使えないものを除く)
|
||||||
|
function availableBrains() {
|
||||||
|
return Object.keys(BRAINS).filter((name) => !BRAINS[name].available || BRAINS[name].available());
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomIndex = (rng, n) => Math.floor(rng() * n);
|
||||||
|
|
||||||
|
// CPU ごとの性格。brain を省くとランダムに選ぶ
|
||||||
|
function randomStyle(rng = Math.random, brain) {
|
||||||
|
const names = availableBrains();
|
||||||
|
const name = names.includes(brain) ? brain : names[randomIndex(rng, names.length)];
|
||||||
|
return { brain: name, ...BRAINS[name].randomStyle(rng) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// n 人分の性格。同じテーブルになるべく違う思考が並ぶようにする
|
||||||
|
function makeStyles(n, rng = () => crypto.randomInt(2 ** 32) / 2 ** 32) {
|
||||||
|
const names = availableBrains();
|
||||||
|
for (let i = names.length - 1; i > 0; i--) {
|
||||||
|
const j = randomIndex(rng, i + 1);
|
||||||
|
[names[i], names[j]] = [names[j], names[i]];
|
||||||
|
}
|
||||||
|
return Array.from({ length: n }, (_, k) => randomStyle(rng, names[k % names.length]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手番の CPU の行動を決める。{ type, amount } を返す(hand.act にそのまま受け付けられる形に直してある)
|
||||||
|
// 思考がエラーになったり何も返さなかったりしたら、バランス型で決め直す
|
||||||
|
function decide(hand, seat, { style = montecarlo.DEFAULT_STYLE, rng = Math.random, iterations = 500 } = {}) {
|
||||||
|
if (!hand.options(seat)) return null;
|
||||||
|
const brain = BRAINS[style.brain] || montecarlo;
|
||||||
|
let d = null;
|
||||||
|
try {
|
||||||
|
d = brain.decide(hand, seat, { style, rng, iterations });
|
||||||
|
} catch {
|
||||||
|
d = null;
|
||||||
|
}
|
||||||
|
if (!d && brain !== montecarlo) {
|
||||||
|
try {
|
||||||
|
d = montecarlo.decide(hand, seat, { rng, iterations });
|
||||||
|
} catch {
|
||||||
|
d = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return legalize(hand, seat, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decide, randomStyle, makeStyles, availableBrains, equity: montecarlo.equity, BRAINS };
|
||||||
+6
-1
@@ -71,8 +71,12 @@ function isAuthed(req) {
|
|||||||
return isAuthedCookieHeader(req.headers.cookie);
|
return isAuthedCookieHeader(req.headers.cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdminCookieHeader(cookieHeader) {
|
||||||
|
return roleOf(cookieHeader) === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
function isAdmin(req) {
|
function isAdmin(req) {
|
||||||
return roleOf(req.headers.cookie) === 'admin';
|
return isAdminCookieHeader(req.headers.cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthCookie(req, res, role) {
|
function setAuthCookie(req, res, role) {
|
||||||
@@ -232,6 +236,7 @@ module.exports = {
|
|||||||
isAuthed,
|
isAuthed,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isAuthedCookieHeader,
|
isAuthedCookieHeader,
|
||||||
|
isAdminCookieHeader,
|
||||||
isSameOrigin,
|
isSameOrigin,
|
||||||
isAllowedHost,
|
isAllowedHost,
|
||||||
requireAllowedHost,
|
requireAllowedHost,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 計算型: poker-calculations(ネイティブ実装)の decideAction に、勝率・ポットオッズ・コールの期待値から決めさせる。
|
||||||
|
// スタックが少ないときは、同じライブラリのオールインの損益分岐勝率と比べてプッシュかフォールドにする。
|
||||||
|
const { situation, CALL, FOLD, CHECK, passive, allIn, raiseToAmount, seedFrom, between } = require('./common');
|
||||||
|
|
||||||
|
// ネイティブアドオンが読み込めない環境では、この思考は使わない
|
||||||
|
let pc = null;
|
||||||
|
try {
|
||||||
|
pc = require('poker-calculations');
|
||||||
|
} catch {
|
||||||
|
pc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = () => pc !== null;
|
||||||
|
|
||||||
|
function randomStyle(rng) {
|
||||||
|
return {
|
||||||
|
// 小さいほどベット・レイズが多い(0.4 未満は無謀になり、0.6 を超えるとほとんどレイズしない)
|
||||||
|
aggressionThreshold: between(rng, 0.4, 0.52),
|
||||||
|
riskTolerance: between(rng, 0.4, 0.6), // 大きいほど薄い勝率でもコールする
|
||||||
|
raisePotFraction: between(rng, 0.55, 0.9), // ベット・レイズの大きさ(ポットに対する割合)
|
||||||
|
pushBias: between(rng, -0.04, 0.04), // プッシュの基準勝率への上乗せ(負ほど広くプッシュ)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASES = ['preflop', null, null, 'flop', 'turn', 'river'];
|
||||||
|
|
||||||
|
// decideAction に渡すテーブルの状態。他人の手札は空にする
|
||||||
|
function tableState(hand, seat) {
|
||||||
|
const list = hand.list;
|
||||||
|
const index = (s) => list.findIndex((p) => p.seat === s);
|
||||||
|
const firstToAct = hand.board.length === 0 ? (index(hand.bbSeat) + 1) % list.length : 0;
|
||||||
|
return {
|
||||||
|
players: list.map((p, i) => ({
|
||||||
|
holeCards: p.seat === seat ? p.cards : [],
|
||||||
|
stack: hand.seats[p.seat].stack,
|
||||||
|
committedThisStreet: p.bet,
|
||||||
|
totalCommittedHand: p.committed,
|
||||||
|
folded: p.folded,
|
||||||
|
seat: i,
|
||||||
|
})),
|
||||||
|
communityCards: hand.board,
|
||||||
|
phase: PHASES[hand.board.length],
|
||||||
|
pot: hand.potTotal,
|
||||||
|
currentBet: hand.currentBet,
|
||||||
|
buttonSeat: index(hand.button),
|
||||||
|
smallBlind: hand.sb,
|
||||||
|
bigBlind: hand.bb,
|
||||||
|
actingIndex: index(seat),
|
||||||
|
lastRaiseIncrement: hand.state.lastRaiseAmount || hand.bb,
|
||||||
|
streetOpeningIndex: firstToAct,
|
||||||
|
// このストリートで動いた人(直前のアクションの表示はストリートが変わると消える)
|
||||||
|
actedThisStreet: list.map((p) => Boolean(p.last && p.last.type !== 'FOLD')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(hand, seat, { style, rng = Math.random, iterations = 500 } = {}) {
|
||||||
|
const s = situation(hand, seat);
|
||||||
|
if (!s) return null;
|
||||||
|
const { o, p, stack, bb, preflop, opponents, pot } = s;
|
||||||
|
const villains = Math.max(1, opponents);
|
||||||
|
|
||||||
|
// スタックが 12BB 以下ならプッシュかフォールド
|
||||||
|
if (preflop && stack + p.bet <= 12 * bb) {
|
||||||
|
const eq = pc.simulateHandOutcome(p.cards, [], iterations, seedFrom(rng), villains);
|
||||||
|
const breakeven = pc.pushFoldSymmetricBreakevenEquity(stack + p.bet, pot - p.bet);
|
||||||
|
if (eq >= breakeven + style.pushBias + 0.04 * (villains - 1)) return o.canRaise ? allIn(o) : CALL;
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = pc.decideAction(tableState(hand, seat), {
|
||||||
|
aggressionThreshold: style.aggressionThreshold,
|
||||||
|
riskTolerance: style.riskTolerance,
|
||||||
|
raisePotFraction: style.raisePotFraction,
|
||||||
|
monteCarloSimulations: iterations,
|
||||||
|
monteCarloVillains: villains,
|
||||||
|
rngSeed: seedFrom(rng),
|
||||||
|
});
|
||||||
|
if (d.action === 'raise' && o.canRaise) return raiseToAmount(hand, seat, hand.currentBet + d.raiseBy);
|
||||||
|
if (d.action === 'raise' || d.action === 'call') return o.canCheck ? CHECK : CALL;
|
||||||
|
if (d.action === 'check') return passive(o);
|
||||||
|
return FOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decide, randomStyle, available };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CPU の思考で共通に使う道具。どの思考も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)
|
||||||
|
|
||||||
|
// 手番の状況。手番でなければ null
|
||||||
|
function situation(hand, seat) {
|
||||||
|
const o = hand.options(seat);
|
||||||
|
if (!o) return null;
|
||||||
|
const p = hand.players.get(seat);
|
||||||
|
const pot = hand.potTotal;
|
||||||
|
return {
|
||||||
|
o,
|
||||||
|
p,
|
||||||
|
cards: p.cards,
|
||||||
|
board: hand.board,
|
||||||
|
stack: hand.seats[seat].stack,
|
||||||
|
bb: hand.bb,
|
||||||
|
preflop: hand.board.length === 0,
|
||||||
|
opponents: hand.live().length - 1,
|
||||||
|
pot,
|
||||||
|
potOdds: o.toCall / (pot + o.toCall),
|
||||||
|
raises: hand.raises || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHECK = { type: 'check' };
|
||||||
|
const CALL = { type: 'call' };
|
||||||
|
const FOLD = { type: 'fold' };
|
||||||
|
|
||||||
|
// チェックできればチェック、できなければフォールド
|
||||||
|
const passive = (o) => (o.canCheck ? CHECK : FOLD);
|
||||||
|
// チェックできればチェック、できなければコール
|
||||||
|
const keepGoing = (o) => (o.canCheck ? CHECK : CALL);
|
||||||
|
const allIn = (o) => ({ type: 'raise', amount: o.maxRaiseTo });
|
||||||
|
|
||||||
|
// 合計 amount までのレイズ。最低額〜オールインに収め、SB 単位に丸め、残りがわずかになるならオールイン
|
||||||
|
function raiseToAmount(hand, seat, amount) {
|
||||||
|
const o = hand.options(seat);
|
||||||
|
const p = hand.players.get(seat);
|
||||||
|
const unit = Math.max(1, hand.sb);
|
||||||
|
let to = Math.round(amount / unit) * unit;
|
||||||
|
to = Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, to));
|
||||||
|
if (to - p.bet >= hand.seats[seat].stack * 0.7) to = o.maxRaiseTo;
|
||||||
|
return { type: 'raise', amount: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ポット(コールした後)の fraction 倍のレイズ
|
||||||
|
function raisePot(hand, seat, fraction) {
|
||||||
|
const o = hand.options(seat);
|
||||||
|
return raiseToAmount(hand, seat, hand.currentBet + fraction * (hand.potTotal + o.toCall));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 思考の結果を、その場で必ず受け付けられるアクションに直す
|
||||||
|
function legalize(hand, seat, d) {
|
||||||
|
const o = hand.options(seat);
|
||||||
|
if (!o) return null;
|
||||||
|
const type = d && d.type;
|
||||||
|
if (type === 'raise' && o.canRaise && Number.isFinite(d.amount)) {
|
||||||
|
const amount = Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(d.amount)));
|
||||||
|
return { type: 'raise', amount };
|
||||||
|
}
|
||||||
|
if (type === 'raise' || type === 'call') return keepGoing(o);
|
||||||
|
if (type === 'check') return passive(o);
|
||||||
|
// ただで見られるのに降りることはしない
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 以上 2^31 未満の整数のシード(ネイティブのシミュレーション用)
|
||||||
|
const seedFrom = (rng) => Math.floor(rng() * 2147483647);
|
||||||
|
|
||||||
|
// 範囲内の一様乱数
|
||||||
|
const between = (rng, min, max) => min + rng() * (max - min);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
situation,
|
||||||
|
CHECK,
|
||||||
|
CALL,
|
||||||
|
FOLD,
|
||||||
|
passive,
|
||||||
|
keepGoing,
|
||||||
|
allIn,
|
||||||
|
raiseToAmount,
|
||||||
|
raisePot,
|
||||||
|
legalize,
|
||||||
|
seedFrom,
|
||||||
|
between,
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 直感型(ルーズ): pokersolver で「いまできている役」だけを見て打つ。勝率の計算はしない。
|
||||||
|
// 参加する手が広く、ペアやドローがあればなかなか降りない。強い役ができたときだけ大きく打つ。
|
||||||
|
const { Hand } = require('pokersolver');
|
||||||
|
const { situation, CHECK, CALL, FOLD, passive, keepGoing, allIn, raiseToAmount, raisePot, between } = require('./common');
|
||||||
|
|
||||||
|
// pokersolver のカードのランク: 2 → 1 … A → 13。役のランク: ハイカード 1、ワンペア 2、ツーペア 3 …
|
||||||
|
const PAIR = 2;
|
||||||
|
const TWO_PAIR = 3;
|
||||||
|
const rankOf = (card) => '23456789TJQKA'.indexOf(card[0]) + 1;
|
||||||
|
|
||||||
|
function randomStyle(rng) {
|
||||||
|
return {
|
||||||
|
callCapBb: between(rng, 4, 9), // プリフロップでコールする上限(BB)
|
||||||
|
stickiness: between(rng, 0.8, 1.5), // ペアでコールするベットの上限(ポットに対する割合)
|
||||||
|
looseness: between(rng, 0.15, 0.4), // 何もなくても小さいベットにはコールする頻度
|
||||||
|
bluff: between(rng, 0.05, 0.2), // ドローでベットする頻度
|
||||||
|
betSize: between(rng, 0.5, 0.85),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function preflop(hand, seat, s, style, rng) {
|
||||||
|
const { o, p, stack, bb, raises } = s;
|
||||||
|
const solved = Hand.solve(p.cards);
|
||||||
|
const [hi, lo] = p.cards.map(rankOf).sort((a, b) => b - a);
|
||||||
|
const pair = solved.rank === PAIR;
|
||||||
|
const suited = p.cards[0][1] === p.cards[1][1];
|
||||||
|
const premium = (pair && hi >= 10) || (hi === 13 && lo === 12); // JJ 以上・AK
|
||||||
|
const playable = pair || hi === 13 || lo >= 9 || suited || (hi - lo <= 2 && lo >= 4);
|
||||||
|
|
||||||
|
// スタックが 10BB 以下なら、ペア・エース・プレミアムはオールイン
|
||||||
|
if (stack + p.bet <= 10 * bb) {
|
||||||
|
if (pair || hi === 13 || premium) return o.canRaise ? allIn(o) : CALL;
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
if (premium && o.canRaise && raises < 2) {
|
||||||
|
return raiseToAmount(hand, seat, raises === 0 ? Math.max(3 * bb, hand.currentBet * 3) : hand.currentBet * 3);
|
||||||
|
}
|
||||||
|
if (premium) return keepGoing(o);
|
||||||
|
if (playable && o.toCall <= style.callCapBb * bb) {
|
||||||
|
// たまにリンプではなく小さくオープンする
|
||||||
|
if (raises === 0 && o.canRaise && rng() < 0.15) return raiseToAmount(hand, seat, 2.5 * bb);
|
||||||
|
return keepGoing(o);
|
||||||
|
}
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// フラッシュドローかストレートドロー(オープンエンド)があるか。自分の手札を使うものだけ
|
||||||
|
function hasDraw(cards, board) {
|
||||||
|
if (board.length >= 5) return false;
|
||||||
|
const all = cards.concat(board);
|
||||||
|
for (const suit of 'shdc') {
|
||||||
|
const n = all.filter((c) => c[1] === suit).length;
|
||||||
|
if (n === 4 && cards.some((c) => c[1] === suit)) return true;
|
||||||
|
}
|
||||||
|
const ranks = new Set(all.map(rankOf));
|
||||||
|
if (ranks.has(13)) ranks.add(0); // A は 1 としても数える
|
||||||
|
const mine = new Set(cards.map(rankOf));
|
||||||
|
for (let lo = 0; lo <= 9; lo++) {
|
||||||
|
const run = [lo, lo + 1, lo + 2, lo + 3];
|
||||||
|
if (run.every((r) => ranks.has(r)) && run.some((r) => mine.has(r) || (r === 0 && mine.has(13)))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(hand, seat, { style, rng = Math.random } = {}) {
|
||||||
|
const s = situation(hand, seat);
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.preflop) return preflop(hand, seat, s, style, rng);
|
||||||
|
|
||||||
|
const { o, p, pot, raises } = s;
|
||||||
|
const mine = Hand.solve(p.cards.concat(hand.board));
|
||||||
|
const onBoard = Hand.solve(hand.board);
|
||||||
|
const improved = mine.rank > onBoard.rank; // 自分の手札で役が上がっている
|
||||||
|
const strong = improved && mine.rank >= TWO_PAIR;
|
||||||
|
const boardTop = Math.max(...hand.board.map(rankOf));
|
||||||
|
const topPair = improved && mine.rank === PAIR && mine.cards[0].rank >= boardTop;
|
||||||
|
const pair = improved && mine.rank === PAIR;
|
||||||
|
const draw = hasDraw(p.cards, hand.board);
|
||||||
|
|
||||||
|
if (o.canCheck) {
|
||||||
|
if (strong && o.canRaise) return raisePot(hand, seat, style.betSize);
|
||||||
|
if (topPair && o.canRaise && rng() < 0.6) return raisePot(hand, seat, style.betSize * 0.7);
|
||||||
|
if (draw && o.canRaise && rng() < style.bluff) return raisePot(hand, seat, 0.5);
|
||||||
|
return CHECK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bet = o.toCall / Math.max(1, pot - o.toCall); // ポットに対するベットの大きさ
|
||||||
|
if (strong) return o.canRaise && raises < 2 ? raisePot(hand, seat, style.betSize) : CALL;
|
||||||
|
if (pair && bet <= style.stickiness * (topPair ? 1.3 : 0.8)) return CALL;
|
||||||
|
if (draw && bet <= 0.7) return CALL;
|
||||||
|
if (bet <= 0.35 && rng() < style.looseness) return CALL;
|
||||||
|
return FOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decide, randomStyle, hasDraw };
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// バランス型: @pokertools/evaluator の役判定でモンテカルロ法の勝率を見積もり、
|
||||||
|
// ポットオッズ・レイズの回数・相手の人数から行動を決める。
|
||||||
|
const { evaluate, getCardCode } = require('@pokertools/evaluator');
|
||||||
|
const { situation, CHECK, CALL, FOLD, allIn, raisePot, between } = require('./common');
|
||||||
|
|
||||||
|
// hole / board は "As" 形式。opponents 人のランダムな手札を相手にした勝率(引き分けは等分)
|
||||||
|
// 役の強さのスコアは小さいほど強い。カードは 0〜51 の整数
|
||||||
|
function equity(hole, board, opponents, iterations, rng = Math.random) {
|
||||||
|
const mine = hole.map(getCardCode);
|
||||||
|
const known = board.map(getCardCode);
|
||||||
|
const used = new Set([...mine, ...known]);
|
||||||
|
const deck = [];
|
||||||
|
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
|
||||||
|
const needBoard = 5 - known.length;
|
||||||
|
const need = needBoard + opponents * 2;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
for (let it = 0; it < iterations; it++) {
|
||||||
|
// 必要な枚数だけ部分的にシャッフル
|
||||||
|
for (let i = 0; i < need; i++) {
|
||||||
|
const j = i + Math.floor(rng() * (deck.length - i));
|
||||||
|
const tmp = deck[i];
|
||||||
|
deck[i] = deck[j];
|
||||||
|
deck[j] = tmp;
|
||||||
|
}
|
||||||
|
const fullBoard = known.concat(deck.slice(0, needBoard));
|
||||||
|
const me = evaluate(mine.concat(fullBoard));
|
||||||
|
let ties = 0;
|
||||||
|
let lost = false;
|
||||||
|
for (let k = 0; k < opponents; k++) {
|
||||||
|
const o = needBoard + k * 2;
|
||||||
|
const v = evaluate([deck[o], deck[o + 1]].concat(fullBoard));
|
||||||
|
if (v < me) {
|
||||||
|
lost = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (v === me) ties++;
|
||||||
|
}
|
||||||
|
if (!lost) score += 1 / (ties + 1);
|
||||||
|
}
|
||||||
|
return score / iterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomStyle(rng) {
|
||||||
|
return {
|
||||||
|
aggression: between(rng, 0.8, 1.25), // 大きいほど積極的にベット・レイズする
|
||||||
|
bluff: between(rng, 0.03, 0.12), // 弱い手でベットする頻度
|
||||||
|
looseness: between(rng, 0, 0.08), // 大きいほど弱い手でもコールする
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STYLE = { aggression: 1, bluff: 0.06, looseness: 0.04 };
|
||||||
|
|
||||||
|
function decide(hand, seat, { style = DEFAULT_STYLE, rng = Math.random, iterations = 500 } = {}) {
|
||||||
|
const s = situation(hand, seat);
|
||||||
|
if (!s) return null;
|
||||||
|
const { o, p, stack, bb, preflop, opponents, pot, potOdds, raises } = s;
|
||||||
|
const aggr = style.aggression;
|
||||||
|
const raiseTo = (fraction) => raisePot(hand, seat, fraction);
|
||||||
|
const sizing = () => {
|
||||||
|
const r = rng();
|
||||||
|
return r < 0.4 ? 0.5 : r < 0.8 ? 0.75 : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preflop) {
|
||||||
|
// プリフロップ: 1人のランダムな手に対する勝率で手の強さを順位付けする
|
||||||
|
// (0.6 ≒ 上位15%、0.65 ≒ 上位5%、0.7 ≒ 上位3%)
|
||||||
|
const hs = equity(p.cards, [], 1, iterations, rng);
|
||||||
|
const s1 = (hs - 0.5) / 0.5;
|
||||||
|
|
||||||
|
// スタックが少ないときはプッシュかフォールド
|
||||||
|
if (o.canRaise && stack + p.bet <= 10 * bb && s1 > 0.24 / aggr) return allIn(o);
|
||||||
|
|
||||||
|
// 誰もレイズしていなければオープン(残りの人数が少ないほど広く)、レイズされていたら 3bet・4bet
|
||||||
|
const raiseBar = (raises === 0 ? (opponents <= 3 ? 0.13 : 0.2) : raises === 1 ? 0.33 : 0.45) / aggr;
|
||||||
|
if (o.canRaise && (s1 > raiseBar || (raises === 1 && s1 > 0.1 && rng() < style.bluff * 0.5))) {
|
||||||
|
// オープンは 2.5〜3BB 程度、リレイズはポットの 0.75〜1倍
|
||||||
|
return raiseTo(raises === 0 ? 0.6 + rng() * 0.3 : 0.75 + rng() * 0.25);
|
||||||
|
}
|
||||||
|
if (o.canCheck) return CHECK;
|
||||||
|
// コール: レイズしてきた相手は強い手を持っているので、その分だけ割り引いてポットオッズと比べる
|
||||||
|
const callScore = hs - 0.18 * raises - 0.04 * Math.max(0, opponents - 1);
|
||||||
|
if (callScore + style.looseness >= potOdds + 0.08) return CALL;
|
||||||
|
return FOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// フロップ以降: 残っている相手全員のランダムな手に対する勝率
|
||||||
|
let e = equity(p.cards, hand.board, Math.max(1, opponents), iterations, rng);
|
||||||
|
// ベットしてきた相手は強い手を持っていることが多いので、ベットが大きいほど勝率を割り引く
|
||||||
|
if (o.toCall > 0) e *= 1 - 0.25 * Math.min(1, o.toCall / Math.max(pot - o.toCall, bb));
|
||||||
|
const fair = 1 / (Math.max(1, opponents) + 1);
|
||||||
|
const strength = (e - fair) / (1 - fair); // 0 で平均的、1 で必勝
|
||||||
|
|
||||||
|
// 強い手はベット・レイズ(何度もレイズされているほど慎重に)
|
||||||
|
const raiseBar = (0.35 + 0.17 * Math.min(raises, 3)) / aggr;
|
||||||
|
if (o.canRaise && strength > raiseBar) {
|
||||||
|
// 最強クラスはたまにスロープレイ
|
||||||
|
if (o.canCheck && strength > 0.85 && rng() < 0.2) return CHECK;
|
||||||
|
return raiseTo(strength > 0.75 ? Math.max(0.75, sizing()) : sizing());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.canCheck) {
|
||||||
|
// 前のストリートで最後にレイズした人は、相手が少なければ続けてベット(コンティニュエーションベット)
|
||||||
|
if (o.canRaise && hand.prevAggressor === seat && opponents <= 2 && rng() < 0.55 * aggr) return raiseTo(0.5);
|
||||||
|
// たまにブラフ
|
||||||
|
if (o.canRaise && opponents <= 2 && rng() < style.bluff * aggr) return raiseTo(0.5);
|
||||||
|
return CHECK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// コールするか: 勝率がポットオッズを上回るか
|
||||||
|
if (e + style.looseness >= potOdds + 0.02) return CALL;
|
||||||
|
if (o.canRaise && raises <= 1 && opponents === 1 && rng() < style.bluff * 0.4) return raiseTo(0.75);
|
||||||
|
return FOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decide, randomStyle, equity, DEFAULT_STYLE };
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 理論型(タイト): poker-utils のプリフロップの強さの順位で参加する手を絞り、
|
||||||
|
// フロップ以降は「相手がこのくらいの範囲の手を持っている」と仮定したレンジに対する勝率で判断する。
|
||||||
|
// ブラフはライブラリの alpha(必要なフォールド率)と bluffEV で割に合うときだけ打つ。
|
||||||
|
const pu = require('poker-utils');
|
||||||
|
const { situation, CHECK, CALL, FOLD, passive, allIn, raiseToAmount, raisePot, between } = require('./common');
|
||||||
|
|
||||||
|
// 169種類の手 → 上位何%か(0 が最強)。preflopStrengthOrder は弱い順
|
||||||
|
const PERCENTILE = new Map();
|
||||||
|
{
|
||||||
|
const combos = (h) => (h.length === 2 ? 6 : h[2] === 's' ? 4 : 12);
|
||||||
|
let stronger = 0;
|
||||||
|
for (const h of pu.preflopStrengthOrder.slice().reverse()) {
|
||||||
|
PERCENTILE.set(h, stronger / 1326);
|
||||||
|
stronger += combos(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handPercentile = (cards) => PERCENTILE.get(pu.handToIsoPre(pu.boardToInts(cards.join(''))));
|
||||||
|
|
||||||
|
// 上位 width の手のレンジ(5%刻みで作り置き)
|
||||||
|
const ranges = new Map();
|
||||||
|
function topRange(width) {
|
||||||
|
const key = Math.min(20, Math.max(1, Math.round(width * 20)));
|
||||||
|
if (!ranges.has(key)) ranges.set(key, pu.PokerRange.fromPreflop(pu.PreflopRange.fromPercentiles(0, key / 20)));
|
||||||
|
return ranges.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RIVER_SAMPLES = 24;
|
||||||
|
|
||||||
|
// 相手のレンジに対する勝率(引き分けは半分)。フロップは残り2枚をサンプリングする
|
||||||
|
function rangeEquity(cards, board, vsRange, rng) {
|
||||||
|
const hand = pu.boardToInts(cards.join(''));
|
||||||
|
const known = pu.boardToInts(board.join(''));
|
||||||
|
const boards = [];
|
||||||
|
if (known.length === 3) {
|
||||||
|
const used = new Set([...hand, ...known]);
|
||||||
|
const deck = [];
|
||||||
|
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
|
||||||
|
for (let k = 0; k < RIVER_SAMPLES; k++) {
|
||||||
|
const i = Math.floor(rng() * deck.length);
|
||||||
|
let j = Math.floor(rng() * (deck.length - 1));
|
||||||
|
if (j >= i) j++;
|
||||||
|
boards.push(known.concat([deck[i], deck[j]]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boards.push(known);
|
||||||
|
}
|
||||||
|
let sum = 0;
|
||||||
|
let n = 0;
|
||||||
|
for (const b of boards) {
|
||||||
|
const r = pu.equityEval({ board: b, hand, vsRange });
|
||||||
|
for (const [win, tie, lose] of Array.isArray(r[0]) ? r : [r]) {
|
||||||
|
const total = win + tie + lose;
|
||||||
|
if (total > 0) {
|
||||||
|
sum += (win + tie / 2) / total;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n ? sum / n : 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomStyle(rng) {
|
||||||
|
return {
|
||||||
|
width: between(rng, 0.8, 1.2), // 参加する手の広さ
|
||||||
|
aggression: between(rng, 0.9, 1.2), // 大きいほど薄い手でもバリューベットする
|
||||||
|
bluff: between(rng, 0.04, 0.12), // ブラフを考える頻度
|
||||||
|
foldEstimate: between(rng, 0.35, 0.5), // 相手が降りると見積もる割合
|
||||||
|
margin: between(rng, 0, 0.05), // コールに必要な勝率の上乗せ
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function preflop(hand, seat, s, style) {
|
||||||
|
const { o, p, stack, bb, opponents, raises } = s;
|
||||||
|
const pct = handPercentile(p.cards);
|
||||||
|
const stackBb = (stack + p.bet) / bb;
|
||||||
|
|
||||||
|
// スタックが 12BB 以下ならプッシュかフォールド
|
||||||
|
if (stackBb <= 12) {
|
||||||
|
const pushPct = Math.min(0.6, 2.5 / stackBb) * style.width;
|
||||||
|
if (pct < (raises === 0 ? pushPct : pushPct * 0.5)) return o.canRaise ? allIn(o) : CALL;
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 残りの人数が少ないほど広く参加する
|
||||||
|
const open = (opponents >= 5 ? 0.14 : opponents >= 3 ? 0.2 : opponents === 2 ? 0.3 : 0.45) * style.width;
|
||||||
|
if (raises === 0) {
|
||||||
|
if (pct < open && o.canRaise) return raiseToAmount(hand, seat, Math.max(2.5 * bb, hand.currentBet * 3));
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
if (raises === 1) {
|
||||||
|
if (pct < open * 0.2 && o.canRaise) return raiseToAmount(hand, seat, hand.currentBet * 3);
|
||||||
|
if (pct < open * 0.55) return CALL;
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
if (pct < 0.025) return o.canRaise ? allIn(o) : CALL;
|
||||||
|
if (pct < 0.05) return CALL;
|
||||||
|
return passive(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(hand, seat, { style, rng = Math.random } = {}) {
|
||||||
|
const s = situation(hand, seat);
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.preflop) return preflop(hand, seat, s, style);
|
||||||
|
|
||||||
|
const { o, p, opponents, pot, potOdds, raises } = s;
|
||||||
|
// 相手のレンジ: フロップまで残った手(上位45%)。ベット・レイズされるほど強い手に絞る
|
||||||
|
let width = 0.45 * style.width;
|
||||||
|
if (o.toCall > 0) width *= 1 - 0.5 * Math.min(1, o.toCall / Math.max(1, pot - o.toCall));
|
||||||
|
if (raises >= 2) width = Math.min(width, 0.12);
|
||||||
|
const eq = rangeEquity(p.cards, hand.board, topRange(width), rng);
|
||||||
|
// 複数の相手全員に勝つ確率(独立とみなす)
|
||||||
|
const eqAll = eq ** Math.max(1, opponents);
|
||||||
|
|
||||||
|
if (o.canCheck) {
|
||||||
|
if (o.canRaise && eqAll > 0.62 / style.aggression) return raisePot(hand, seat, 0.66);
|
||||||
|
if (o.canRaise && opponents <= 2 && rng() < style.bluff) {
|
||||||
|
// ポットの半分のブラフで必要なフォールド率(alpha)より、相手が降りる見積もりが大きければ打つ
|
||||||
|
const bet = pot * 0.5;
|
||||||
|
if (pu.bluffEV(style.foldEstimate, pu.alpha(bet, pot)) > 0) return raisePot(hand, seat, 0.5);
|
||||||
|
}
|
||||||
|
return CHECK;
|
||||||
|
}
|
||||||
|
if (o.canRaise && eqAll > 0.8 && raises < 3) return raisePot(hand, seat, 0.75);
|
||||||
|
if (eqAll >= potOdds + style.margin) return CALL;
|
||||||
|
return FOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decide, randomStyle, handPercentile, rangeEquity };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 役の判定は @pokertools/evaluator(@pokertools/engine のショーダウンと同じ判定器)を使う。
|
||||||
|
// ここでは表示用に、最強の5枚と日本語の役名を求める。
|
||||||
|
// カードは "As"(スペードのA), "Td"(ダイヤの10)のような2文字
|
||||||
|
const { evaluate, getCardCodes, rank, HandRank } = require('@pokertools/evaluator');
|
||||||
|
|
||||||
|
const HAND_NAMES = {
|
||||||
|
[HandRank.HighCard]: 'ハイカード',
|
||||||
|
[HandRank.OnePair]: 'ワンペア',
|
||||||
|
[HandRank.TwoPair]: 'ツーペア',
|
||||||
|
[HandRank.ThreeOfAKind]: 'スリーカード',
|
||||||
|
[HandRank.Straight]: 'ストレート',
|
||||||
|
[HandRank.Flush]: 'フラッシュ',
|
||||||
|
[HandRank.FullHouse]: 'フルハウス',
|
||||||
|
[HandRank.FourOfAKind]: 'フォーカード',
|
||||||
|
[HandRank.StraightFlush]: 'ストレートフラッシュ',
|
||||||
|
};
|
||||||
|
const ROYAL_FLUSH = 1; // 判定器のスコアは小さいほど強く、1 がロイヤルフラッシュ
|
||||||
|
|
||||||
|
// 5〜7枚の中で最も強い役。score は小さいほど強い
|
||||||
|
function bestHand(cards) {
|
||||||
|
const codes = getCardCodes(cards);
|
||||||
|
const score = evaluate(codes);
|
||||||
|
const name = score === ROYAL_FLUSH ? 'ロイヤルフラッシュ' : HAND_NAMES[rank(codes)];
|
||||||
|
// 役を作っている5枚(7枚から5枚を選ぶ21通りで、全体と同じ強さになる組)
|
||||||
|
let five = cards.slice(0, 5);
|
||||||
|
if (cards.length > 5) {
|
||||||
|
const n = cards.length;
|
||||||
|
const pick = (start, chosen) => {
|
||||||
|
if (chosen.length === 5) return evaluate(chosen.map((i) => codes[i])) === score ? chosen.slice() : null;
|
||||||
|
for (let i = start; i <= n - (5 - chosen.length); i++) {
|
||||||
|
chosen.push(i);
|
||||||
|
const found = pick(i + 1, chosen);
|
||||||
|
chosen.pop();
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
five = pick(0, []).map((i) => cards[i]);
|
||||||
|
}
|
||||||
|
return { score, cards: five, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// a が強ければ正、b が強ければ負、同じ強さなら 0
|
||||||
|
function compareScore(a, b) {
|
||||||
|
return b - a;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bestHand, compareScore };
|
||||||
+12
-1
@@ -61,9 +61,20 @@ module.exports = {
|
|||||||
// 誰も接続していないテーブルを削除するまでの時間
|
// 誰も接続していないテーブルを削除するまでの時間
|
||||||
EMPTY_TABLE_TTL_MS: 30 * 60 * 1000,
|
EMPTY_TABLE_TTL_MS: 30 * 60 * 1000,
|
||||||
|
|
||||||
// ハンド終了後、次のハンドまで結果を表示する時間
|
// オールインで残りの場札をめくる間隔(カード機能あり)
|
||||||
|
RUNOUT_STEP_MS: 1500,
|
||||||
|
// ハンド終了後、次のハンドまで結果を表示する時間(カードありは手札を見比べられるよう長め)
|
||||||
SHOWDOWN_MS: 4000,
|
SHOWDOWN_MS: 4000,
|
||||||
|
CARD_SHOWDOWN_MS: 6000,
|
||||||
FOLD_WIN_MS: 2500,
|
FOLD_WIN_MS: 2500,
|
||||||
|
// CPU の考える時間(ランダム)。人間がハンドに残っていないときは BOT_FAST_MS
|
||||||
|
BOT_THINK_MIN_MS: 700,
|
||||||
|
BOT_THINK_MAX_MS: 1800,
|
||||||
|
BOT_FAST_MS: 400,
|
||||||
|
// CPU が勝率を見積もるときのシミュレーション回数
|
||||||
|
BOT_ITERATIONS: 800,
|
||||||
|
// レビューで勝率を見積もるときのシミュレーション回数(1回の判断・選択肢ごと)
|
||||||
|
REVIEW_ITERATIONS: 1200,
|
||||||
// 参加者が2人揃ってから次のハンドを始めるまで
|
// 参加者が2人揃ってから次のハンドを始めるまで
|
||||||
NEXT_HAND_DELAY_MS: 2000,
|
NEXT_HAND_DELAY_MS: 2000,
|
||||||
|
|
||||||
|
|||||||
+302
-148
@@ -1,245 +1,361 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// 1ハンド分のチップの動き(ブラインド・ベッティング・ポット分配)。
|
const path = require('path');
|
||||||
// カードは実物で配るので扱わない。ショーダウンでは勝者をプレイヤーが選ぶ。
|
const { PokerEngine, ActionType, PlayerStatus } = require('@pokertools/engine');
|
||||||
// seats は Table.seats への参照で、スタックはここで直接増減する。
|
// サイドポットの計算はエンジン内部の関数を使う(パッケージの公開 API に無いので、ファイルを直接読む。
|
||||||
|
// package.json でバージョンを固定している)
|
||||||
|
const { calculateSidePots } = require(path.join(path.dirname(require.resolve('@pokertools/engine')), 'rules/side-pots'));
|
||||||
|
const { bestHand, compareScore } = require('./cards');
|
||||||
|
|
||||||
|
// 1ハンド分の進行。ルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・
|
||||||
|
// ショーダウンの判定と分配)は @pokertools/engine に任せ、ここではテーブルとのやり取りと表示用の状態を持つ。
|
||||||
|
// cards = false: カードは実物で配るので扱わない。ショーダウンでは勝者をホストが選ぶ。
|
||||||
|
// cards = true : エンジンが配り、ショーダウンで役を判定して自動で分配する。
|
||||||
|
// seats は Table.seats への参照で、スタックはエンジンの状態に合わせて書き換える。
|
||||||
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
|
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
|
||||||
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
|
const RUNOUT_STREET = { 3: 'FLOP', 4: 'TURN', 5: 'RIVER' };
|
||||||
|
|
||||||
class Hand {
|
class Hand {
|
||||||
constructor({ number, seats, order, button, sb, bb }) {
|
constructor({ number, seats, order, button, sb, bb, cards = false }) {
|
||||||
this.number = number;
|
this.number = number;
|
||||||
this.seats = seats;
|
this.seats = seats;
|
||||||
this.order = order; // 参加者の席番号。ボタンの次から時計回り
|
this.order = order; // 参加者の席番号。ボタンの次から時計回り
|
||||||
this.button = button;
|
this.button = button;
|
||||||
this.sb = sb;
|
this.sb = sb;
|
||||||
this.bb = bb;
|
this.bb = bb;
|
||||||
|
this.cards = cards;
|
||||||
|
|
||||||
|
// エンジンには参加者だけを、ボタンを席0として時計回りに座らせる(空席の無い n 席のテーブル)
|
||||||
|
const n = order.length;
|
||||||
|
this.toEngine = new Map(order.map((s, k) => [s, (k + 1) % n]));
|
||||||
|
this.fromEngine = [];
|
||||||
|
for (const [s, e] of this.toEngine) this.fromEngine[e] = s;
|
||||||
|
this.engine = new PokerEngine({ smallBlind: sb, bigBlind: bb, maxPlayers: n });
|
||||||
|
for (const s of order) this.engine.sit(this.toEngine.get(s), String(s), String(s), seats[s].stack);
|
||||||
|
|
||||||
|
this.board = []; // 公開済みの場札(オールイン後はエンジンが配り終えていても順にめくる)
|
||||||
|
this.revealed = false; // ショーダウン・オールイン時に残った人の手札を公開
|
||||||
|
this.runout = false; // オールインでこれ以上ベットが無く、残りの場札を順にめくっている
|
||||||
|
|
||||||
this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN
|
this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN
|
||||||
this.pot = 0; // 前のストリートまでに集めた額
|
this.pot = 0; // 前のストリートまでに集めた額
|
||||||
this.currentBet = 0;
|
this.currentBet = 0;
|
||||||
this.minRaise = bb;
|
this.raises = 0; // このストリートのベット・レイズの回数(CPU の判断用)
|
||||||
|
this.actions = 0; // アクションの通し番号(CPU の手番の管理用)
|
||||||
|
this.aggressor = null; // このストリートで最後にベット・レイズした席(CPU の判断用)
|
||||||
|
this.prevAggressor = null; // 前のストリートで最後にベット・レイズした席
|
||||||
this.toAct = null;
|
this.toAct = null;
|
||||||
this.finished = false;
|
this.finished = false;
|
||||||
this.showdown = false;
|
this.showdown = false;
|
||||||
this.results = null;
|
this.results = null;
|
||||||
|
this.settled = false; // エンジンの中ではハンドが決着した(結果の公開待ちを含む)
|
||||||
|
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();
|
this.players = new Map();
|
||||||
for (const i of order) {
|
for (const i of order) {
|
||||||
this.players.set(i, {
|
this.players.set(i, {
|
||||||
seat: i,
|
seat: i,
|
||||||
token: seats[i].token,
|
token: seats[i].token,
|
||||||
|
cards: [],
|
||||||
|
best: null, // ショーダウンでの最強の5枚
|
||||||
bet: 0, // このストリートで出した額
|
bet: 0, // このストリートで出した額
|
||||||
committed: 0, // このハンドで出した合計
|
committed: 0, // このハンドで出した合計
|
||||||
folded: false,
|
folded: false,
|
||||||
allIn: false,
|
allIn: false,
|
||||||
acted: false,
|
|
||||||
last: null, // 直前のアクション(表示用)
|
last: null, // 直前のアクション(表示用)
|
||||||
won: 0,
|
won: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ヘッズアップはボタンが SB
|
// ヘッズアップはボタンが SB(エンジンも同じ配置にする)
|
||||||
const headsUp = order.length === 2;
|
const headsUp = n === 2;
|
||||||
this.sbSeat = headsUp ? button : order[0];
|
this.sbSeat = headsUp ? button : order[0];
|
||||||
this.bbSeat = headsUp ? order[0] : order[1];
|
this.bbSeat = headsUp ? order[0] : order[1];
|
||||||
this.post(this.sbSeat, sb);
|
// プリフロップに行動する順(UTG から BB まで)
|
||||||
this.post(this.bbSeat, bb);
|
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
|
||||||
this.currentBet = bb; // BB がショートでもコール額は BB
|
this.engine.deal();
|
||||||
this.setNextActor(this.bbSeat);
|
this.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this.engine.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
get list() {
|
get list() {
|
||||||
return this.order.map((i) => this.players.get(i));
|
return this.order.map((i) => this.players.get(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勝者の選択待ち(カードありのときは自動で判定するので来ない)
|
||||||
get awaitingWinners() {
|
get awaitingWinners() {
|
||||||
return this.street === 'SHOWDOWN' && !this.finished;
|
return !this.cards && this.street === 'SHOWDOWN' && !this.finished;
|
||||||
}
|
}
|
||||||
|
|
||||||
live() {
|
live() {
|
||||||
return this.list.filter((p) => !p.folded);
|
return this.list.filter((p) => !p.folded);
|
||||||
}
|
}
|
||||||
|
|
||||||
put(p, amount) {
|
// 前のストリートまでのポット + このストリートのベット
|
||||||
const s = this.seats[p.seat];
|
get potTotal() {
|
||||||
s.stack -= amount;
|
return this.pot + this.list.reduce((sum, p) => sum + p.bet, 0);
|
||||||
p.bet += amount;
|
|
||||||
p.committed += amount;
|
|
||||||
if (s.stack === 0) p.allIn = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post(seat, amount) {
|
// エンジンの状態を写す。ハンドが決着していたら結果を作る
|
||||||
const p = this.players.get(seat);
|
sync() {
|
||||||
this.put(p, Math.min(amount, this.seats[seat].stack));
|
if (this.settled) return;
|
||||||
|
const st = this.state;
|
||||||
|
for (const p of this.list) {
|
||||||
|
const e = this.toEngine.get(p.seat);
|
||||||
|
const ep = st.players[e];
|
||||||
|
p.bet = st.currentBets.get(e) || 0;
|
||||||
|
if (!ep) {
|
||||||
|
// 退席でエンジンから外れた(フォールド済み)
|
||||||
|
p.folded = true;
|
||||||
|
this.seats[p.seat].stack = this.taken.get(p.seat) || 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.cards) p.cards = ep.hand || [];
|
||||||
|
p.committed = ep.totalInvestedThisHand;
|
||||||
|
p.folded = ep.status === PlayerStatus.FOLDED;
|
||||||
|
p.allIn = ep.status === PlayerStatus.ALL_IN;
|
||||||
|
this.seats[p.seat].stack = (this.taken.get(p.seat) || 0) + ep.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
needsToAct(p) {
|
const prevStreet = this.street;
|
||||||
return !p.folded && !p.allIn && (!p.acted || p.bet < this.currentBet);
|
const ended = st.winners !== null;
|
||||||
|
if (ended || st.street !== prevStreet) {
|
||||||
|
// ストリートが終わったら直前のアクションの表示を消す(フォールドとオールインは残す)
|
||||||
|
for (const p of this.list) {
|
||||||
|
if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null;
|
||||||
}
|
}
|
||||||
|
this.raises = 0;
|
||||||
bettingClosed() {
|
this.prevAggressor = this.aggressor;
|
||||||
const live = this.live();
|
this.aggressor = null;
|
||||||
if (live.length <= 1) return true;
|
|
||||||
const actors = live.filter((p) => !p.allIn);
|
|
||||||
if (actors.length === 0) return true;
|
|
||||||
// 動ける人が1人だけで、誰かのベットに応じる必要もない
|
|
||||||
const maxBet = Math.max(...live.map((p) => p.bet));
|
|
||||||
if (actors.length === 1 && actors[0].bet >= maxBet) return true;
|
|
||||||
return actors.every((p) => !this.needsToAct(p));
|
|
||||||
}
|
}
|
||||||
|
// 誰もコールしなかった分が返ってオールインでなくなった
|
||||||
|
for (const p of this.list) if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null;
|
||||||
|
|
||||||
setNextActor(fromSeat) {
|
if (ended) return this.settle();
|
||||||
this.toAct = null;
|
|
||||||
if (this.bettingClosed()) return this.endStreet();
|
this.street = st.street;
|
||||||
const n = this.order.length;
|
if (this.cards) this.board = st.board.slice();
|
||||||
const start = this.order.indexOf(fromSeat);
|
this.pot = st.pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||||
for (let k = 1; k <= n; k++) {
|
// コール額(プリフロップは BB がショートでも BB)
|
||||||
const p = this.players.get(this.order[(start + k) % n]);
|
this.currentBet = Math.max(0, ...st.currentBets.values(), st.street === 'PREFLOP' ? st.bigBlind : 0);
|
||||||
if (!this.needsToAct(p)) continue;
|
this.toAct = st.actionTo == null ? null : this.fromEngine[st.actionTo];
|
||||||
this.toAct = p.seat;
|
|
||||||
// 離席中・退席済みの人は自動でチェック/フォールド
|
// 離席中・退席済みの人は自動でチェック/フォールド
|
||||||
const s = this.seats[p.seat];
|
if (this.toAct != null) {
|
||||||
|
const s = this.seats[this.toAct];
|
||||||
if (s.sittingOut || s.left) this.autoAct();
|
if (s.sittingOut || s.left) this.autoAct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// エンジンの中でハンドが決着した
|
||||||
|
settle() {
|
||||||
|
const st = this.state;
|
||||||
|
this.settled = true;
|
||||||
|
this.toAct = null;
|
||||||
|
this.engineWon = new Map();
|
||||||
|
for (const w of st.winners) this.engineWon.set(this.fromEngine[w.seat], (this.engineWon.get(this.fromEngine[w.seat]) || 0) + w.amount);
|
||||||
|
// 結果を公開するまでは分配前のスタックを見せる
|
||||||
|
for (const [s, amount] of this.engineWon) this.seats[s].stack -= amount;
|
||||||
|
|
||||||
|
if (this.live().length <= 1) {
|
||||||
|
const total = [...this.engineWon.values()].reduce((a, b) => a + b, 0);
|
||||||
|
this.pot = total;
|
||||||
|
this.applyEngineWon();
|
||||||
|
return this.end(false, [{ amount: total, winners: [...this.engineWon.keys()] }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pots = this.toPots(calculateSidePots(st));
|
||||||
|
this.pot = this.pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||||
|
if (!this.cards) {
|
||||||
|
this.street = 'SHOWDOWN';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.endStreet();
|
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
|
||||||
|
this.revealed = true;
|
||||||
|
if (this.board.length < 5) {
|
||||||
|
this.runout = true;
|
||||||
|
this.allIn = { board: this.board.slice(), pots: this.pots };
|
||||||
|
}
|
||||||
|
else this.finishShowdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// エンジンのサイドポットを席番号に直す。同じ参加者のポットはまとめる
|
||||||
|
toPots(enginePots) {
|
||||||
|
const pots = [];
|
||||||
|
for (const ep of enginePots) {
|
||||||
|
const eligible = this.order.filter((s) => ep.eligibleSeats.includes(this.toEngine.get(s)));
|
||||||
|
const last = pots[pots.length - 1];
|
||||||
|
if (last && last.eligible.join() === eligible.join()) last.amount += ep.amount;
|
||||||
|
else pots.push({ amount: ep.amount, eligible });
|
||||||
|
}
|
||||||
|
return pots;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEngineWon() {
|
||||||
|
for (const [s, amount] of this.engineWon) {
|
||||||
|
this.seats[s].stack += amount;
|
||||||
|
this.players.get(s).won += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engineAction(seat, type, amount) {
|
||||||
|
const playerId = String(seat);
|
||||||
|
if (type === 'fold') return { type: ActionType.FOLD, playerId };
|
||||||
|
if (type === 'check') return { type: ActionType.CHECK, playerId };
|
||||||
|
if (type === 'call') return { type: ActionType.CALL, playerId };
|
||||||
|
// amount はこのストリートでの合計額("raise to")
|
||||||
|
return { type: this.currentBet === 0 ? ActionType.BET : ActionType.RAISE, playerId, amount };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手番の人の選択肢(手番でなければ null)
|
// 手番の人の選択肢(手番でなければ null)
|
||||||
options(seat) {
|
options(seat) {
|
||||||
if (this.finished || this.toAct !== seat) return null;
|
if (this.finished || this.settled || this.toAct !== seat) return null;
|
||||||
const p = this.players.get(seat);
|
const p = this.players.get(seat);
|
||||||
const stack = this.seats[seat].stack;
|
const stack = this.seats[seat].stack;
|
||||||
const toCall = Math.min(this.currentBet - p.bet, stack);
|
const toCall = Math.min(this.currentBet - p.bet, stack);
|
||||||
const maxTo = p.bet + stack;
|
const maxTo = p.bet + stack;
|
||||||
// 他の全員がオールインならレイズしても意味がない
|
// 他の全員がオールインならレイズしても意味がない
|
||||||
const opponentCanAct = this.live().some((q) => q !== p && !q.allIn);
|
const opponentCanAct = this.live().some((q) => q !== p && !q.allIn);
|
||||||
|
// レイズできるか(足りないオールインのあとの再レイズ権など)はエンジンに聞く
|
||||||
|
const canRaise =
|
||||||
|
maxTo > this.currentBet && opponentCanAct && this.engine.validate(this.engineAction(seat, 'raise', maxTo)).valid;
|
||||||
return {
|
return {
|
||||||
toCall,
|
toCall,
|
||||||
canCheck: p.bet >= this.currentBet,
|
canCheck: p.bet >= this.currentBet,
|
||||||
canRaise: !p.acted && stack > toCall && opponentCanAct,
|
canRaise,
|
||||||
minRaiseTo: Math.min(this.currentBet + this.minRaise, maxTo),
|
minRaiseTo: Math.min(this.state.minRaise, maxTo),
|
||||||
maxRaiseTo: maxTo,
|
maxRaiseTo: maxTo,
|
||||||
isBet: this.currentBet === 0,
|
isBet: this.currentBet === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
|
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
|
||||||
act(seat, type, amount) {
|
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
|
||||||
|
act(seat, type, amount, auto = false) {
|
||||||
const o = this.options(seat);
|
const o = this.options(seat);
|
||||||
if (!o) return 'あなたの番ではありません';
|
if (!o) return 'あなたの番ではありません';
|
||||||
const p = this.players.get(seat);
|
const p = this.players.get(seat);
|
||||||
|
|
||||||
|
let last;
|
||||||
if (type === 'fold') {
|
if (type === 'fold') {
|
||||||
p.folded = true;
|
last = { type: 'FOLD' };
|
||||||
p.last = { type: 'FOLD' };
|
|
||||||
} else if (type === 'check') {
|
} else if (type === 'check') {
|
||||||
if (!o.canCheck) return 'チェックできません';
|
if (!o.canCheck) return 'チェックできません';
|
||||||
p.last = { type: 'CHECK' };
|
last = { type: 'CHECK' };
|
||||||
} else if (type === 'call') {
|
} else if (type === 'call') {
|
||||||
if (o.canCheck || o.toCall <= 0) return 'コールする額がありません';
|
if (o.canCheck || o.toCall <= 0) return 'コールする額がありません';
|
||||||
this.put(p, o.toCall);
|
last = { type: 'CALL', amount: p.bet + o.toCall };
|
||||||
p.last = { type: 'CALL', amount: p.bet };
|
|
||||||
} else if (type === 'raise') {
|
} else if (type === 'raise') {
|
||||||
if (!o.canRaise) return 'レイズできません';
|
if (!o.canRaise) return 'レイズできません';
|
||||||
if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) {
|
if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) {
|
||||||
return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`;
|
return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`;
|
||||||
}
|
}
|
||||||
const raiseSize = amount - this.currentBet;
|
last = { type: o.isBet ? 'BET' : 'RAISE', amount };
|
||||||
this.put(p, amount - p.bet);
|
|
||||||
p.last = { type: o.isBet ? 'BET' : 'RAISE', amount };
|
|
||||||
if (raiseSize >= this.minRaise) {
|
|
||||||
// 正規のレイズ: 他の人はもう一度アクションできる
|
|
||||||
this.minRaise = raiseSize;
|
|
||||||
for (const q of this.list) if (q !== p) q.acted = false;
|
|
||||||
}
|
|
||||||
// 足りないオールインのレイズはアクション済みの人に再レイズ権を与えない
|
|
||||||
this.currentBet = amount;
|
|
||||||
} else {
|
} else {
|
||||||
return '不正なアクションです';
|
return '不正なアクションです';
|
||||||
}
|
}
|
||||||
if (p.allIn) p.last = { type: 'ALLIN', amount: p.bet };
|
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
|
||||||
p.acted = true;
|
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
|
||||||
this.setNextActor(seat);
|
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 {
|
||||||
|
// エンジンが受け付けない(チェックできるのに降りてサイドポットの対象者がいなくなる場合など)
|
||||||
|
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;
|
||||||
|
if (fullRaise) this.raises++;
|
||||||
|
}
|
||||||
|
this.sync();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoAct() {
|
autoAct() {
|
||||||
const o = this.options(this.toAct);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退席などで手番に関係なくフォールドさせる
|
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
|
||||||
forceFold(seat) {
|
forceFold(seat) {
|
||||||
const p = this.players.get(seat);
|
const p = this.players.get(seat);
|
||||||
if (!p || p.folded || this.finished) return;
|
if (!p || p.folded || this.finished) return;
|
||||||
if (this.toAct === seat) return this.act(seat, 'fold', 0);
|
if (this.toAct === seat) {
|
||||||
p.folded = true;
|
if (this.act(seat, 'fold', 0, true)) this.autoAct();
|
||||||
p.last = { type: 'FOLD' };
|
|
||||||
if (this.live().length === 1) {
|
|
||||||
if (this.awaitingWinners) this.finishFoldWin();
|
|
||||||
else this.endStreet();
|
|
||||||
} else if (this.toAct != null && this.bettingClosed()) {
|
|
||||||
this.endStreet();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 誰もコールしなかった分を返す
|
|
||||||
returnUncalled() {
|
|
||||||
const bets = this.list.map((p) => p.bet).sort((a, b) => b - a);
|
|
||||||
const excess = bets[0] - (bets[1] || 0);
|
|
||||||
if (excess <= 0) return;
|
|
||||||
const p = this.list.find((q) => q.bet === bets[0]);
|
|
||||||
if (p.folded) return; // 退席でフォールドした人のベットはそのままポットに残る
|
|
||||||
p.bet -= excess;
|
|
||||||
p.committed -= excess;
|
|
||||||
this.seats[p.seat].stack += excess;
|
|
||||||
p.allIn = this.seats[p.seat].stack === 0;
|
|
||||||
if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
endStreet() {
|
|
||||||
this.toAct = null;
|
|
||||||
this.returnUncalled();
|
|
||||||
for (const p of this.list) {
|
|
||||||
this.pot += p.bet;
|
|
||||||
p.bet = 0;
|
|
||||||
p.acted = false;
|
|
||||||
if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null;
|
|
||||||
}
|
|
||||||
if (this.live().length === 1) return this.finishFoldWin();
|
|
||||||
this.currentBet = 0;
|
|
||||||
this.minRaise = this.bb;
|
|
||||||
|
|
||||||
// リバーが終わった、または全員オールインでこれ以上ベットが無い → ショーダウン
|
|
||||||
if (this.street === 'RIVER' || this.live().filter((p) => !p.allIn).length < 2) {
|
|
||||||
this.street = 'SHOWDOWN';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.street = STREETS[STREETS.indexOf(this.street) + 1];
|
if (this.settled) {
|
||||||
this.setNextActor(this.button);
|
// キャッシュオンリーの勝者選び待ち: 選べる人から外す
|
||||||
}
|
if (!this.awaitingWinners) return;
|
||||||
|
p.folded = true;
|
||||||
// メインポット・サイドポット(同じ参加者のポットはまとめる)
|
p.last = { type: 'FOLD' };
|
||||||
buildPots() {
|
if (this.live().length === 1) return this.finishFoldWin();
|
||||||
const all = this.list;
|
// 選べる人がいなくなったポットは前のポットにまとめる(メインポットには残った全員が入っている)
|
||||||
const levels = [...new Set(all.map((p) => p.committed).filter((c) => c > 0))].sort((a, b) => a - b);
|
|
||||||
const pots = [];
|
const pots = [];
|
||||||
let prev = 0;
|
for (const pot of this.pots) {
|
||||||
for (const level of levels) {
|
const eligible = pot.eligible.filter((s) => s !== seat);
|
||||||
let amount = 0;
|
|
||||||
for (const p of all) amount += Math.max(0, Math.min(p.committed, level) - prev);
|
|
||||||
prev = level;
|
|
||||||
let eligible = all.filter((p) => !p.folded && p.committed >= level).map((p) => p.seat);
|
|
||||||
const last = pots[pots.length - 1];
|
const last = pots[pots.length - 1];
|
||||||
if (eligible.length === 0) eligible = last ? last.eligible : this.live().map((p) => p.seat);
|
if (last && (eligible.length === 0 || last.eligible.join() === eligible.join())) last.amount += pot.amount;
|
||||||
if (last && last.eligible.join() === eligible.join()) last.amount += amount;
|
else pots.push({ amount: pot.amount, eligible });
|
||||||
else pots.push({ amount, eligible });
|
|
||||||
}
|
}
|
||||||
return pots;
|
this.pots = pots;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// エンジンは手番以外のフォールドを「席を立つ」として扱う。持ち出したチップは自分で覚えておく。
|
||||||
|
// オールインの人などエンジンが受け付けないときは、手番が来たら自動でチェック/フォールドする
|
||||||
|
const stand = { type: ActionType.STAND, playerId: String(seat) };
|
||||||
|
if (!this.engine.validate(stand).valid) return;
|
||||||
|
const before = this.state.initialChips;
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// オールイン後に場札を1ストリート分めくる。リバーまでめくり終えていたらショーダウン
|
||||||
|
runoutStep() {
|
||||||
|
if (!this.runout || this.finished) return;
|
||||||
|
const n = this.board.length;
|
||||||
|
if (n === 5) return this.finishShowdown();
|
||||||
|
const next = n === 0 ? 3 : n + 1;
|
||||||
|
this.board = this.state.board.slice(0, next);
|
||||||
|
this.street = RUNOUT_STREET[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
// キャッシュオンリーのショーダウン中に1人を残して退席した: 残った人がすべて獲得
|
||||||
finishFoldWin() {
|
finishFoldWin() {
|
||||||
const winner = this.live()[0];
|
const winner = this.live()[0];
|
||||||
winner.won = this.pot;
|
winner.won = this.pot;
|
||||||
@@ -250,7 +366,7 @@ class Hand {
|
|||||||
// ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数)
|
// ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数)
|
||||||
award(winners) {
|
award(winners) {
|
||||||
if (!this.awaitingWinners) return 'ショーダウンではありません';
|
if (!this.awaitingWinners) return 'ショーダウンではありません';
|
||||||
const pots = this.buildPots();
|
const pots = this.pots;
|
||||||
if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください';
|
if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください';
|
||||||
for (let i = 0; i < pots.length; i++) {
|
for (let i = 0; i < pots.length; i++) {
|
||||||
const w = winners[i];
|
const w = winners[i];
|
||||||
@@ -258,49 +374,87 @@ class Hand {
|
|||||||
if (new Set(w).size !== w.length || !w.every((s) => pots[i].eligible.includes(s))) return '勝者の指定が不正です';
|
if (new Set(w).size !== w.length || !w.every((s) => pots[i].eligible.includes(s))) return '勝者の指定が不正です';
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = pots.map((pot, i) => ({ amount: pot.amount, winners: this.payout(pot.amount, winners[i]) }));
|
||||||
pots.forEach((pot, i) => {
|
|
||||||
// 割り切れない端数はボタンの左から順に
|
|
||||||
const ws = this.order.filter((s) => winners[i].includes(s));
|
|
||||||
const share = Math.floor(pot.amount / ws.length);
|
|
||||||
let rest = pot.amount - share * ws.length;
|
|
||||||
for (const s of ws) {
|
|
||||||
const amount = share + (rest > 0 ? 1 : 0);
|
|
||||||
if (rest > 0) rest--;
|
|
||||||
this.players.get(s).won += amount;
|
|
||||||
this.seats[s].stack += amount;
|
|
||||||
}
|
|
||||||
results.push({ amount: pot.amount, winners: ws });
|
|
||||||
});
|
|
||||||
this.end(true, results);
|
this.end(true, results);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// カードありのショーダウン: エンジンが分配した結果を公開する。表示用にポットごとの勝者と役を添える
|
||||||
|
finishShowdown() {
|
||||||
|
this.board = this.state.board.slice();
|
||||||
|
for (const p of this.live()) p.best = bestHand(p.cards.concat(this.board));
|
||||||
|
const results = this.pots.map((pot) => {
|
||||||
|
const contenders = pot.eligible.map((s) => this.players.get(s));
|
||||||
|
let top = null;
|
||||||
|
for (const c of contenders) if (top == null || compareScore(c.best.score, top) > 0) top = c.best.score;
|
||||||
|
const best = contenders.filter((c) => compareScore(c.best.score, top) === 0);
|
||||||
|
return {
|
||||||
|
amount: pot.amount,
|
||||||
|
winners: best.map((c) => c.seat),
|
||||||
|
handName: best[0].best.name,
|
||||||
|
bestCards: best[0].best.cards,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.applyEngineWon();
|
||||||
|
this.end(true, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// amount を seats で山分けする。割り切れない端数はボタンの左から順に。分配した順の席番号を返す
|
||||||
|
// (キャッシュオンリーでホストが勝者を選んだときだけ使う)
|
||||||
|
payout(amount, seats) {
|
||||||
|
const ws = this.order.filter((s) => seats.includes(s));
|
||||||
|
const share = Math.floor(amount / ws.length);
|
||||||
|
let rest = amount - share * ws.length;
|
||||||
|
for (const s of ws) {
|
||||||
|
const a = share + (rest > 0 ? 1 : 0);
|
||||||
|
if (rest > 0) rest--;
|
||||||
|
this.players.get(s).won += a;
|
||||||
|
this.seats[s].stack += a;
|
||||||
|
}
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
end(showdown, results) {
|
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.finished = true;
|
||||||
|
this.runout = false;
|
||||||
this.showdown = showdown;
|
this.showdown = showdown;
|
||||||
this.toAct = null;
|
this.toAct = null;
|
||||||
this.results = results;
|
this.results = results;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
// 閲覧者に見せてよい情報だけを返す(他人の手札は公開時のみ)
|
||||||
|
view(viewerSeat, viewerToken) {
|
||||||
const players = {};
|
const players = {};
|
||||||
for (const p of this.list) {
|
for (const p of this.list) {
|
||||||
players[p.seat] = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
|
const v = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
|
||||||
|
if (this.cards) {
|
||||||
|
const mine = p.seat === viewerSeat && p.token === viewerToken;
|
||||||
|
const shown = mine || (this.revealed && !p.folded);
|
||||||
|
v.cards = shown ? p.cards : null;
|
||||||
|
v.handName =
|
||||||
|
shown && !p.folded && this.board.length >= 3 ? (p.best || bestHand(p.cards.concat(this.board))).name : null;
|
||||||
|
}
|
||||||
|
players[p.seat] = v;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
number: this.number,
|
number: this.number,
|
||||||
|
cards: this.cards,
|
||||||
|
board: this.board,
|
||||||
|
runout: this.runout,
|
||||||
street: this.street,
|
street: this.street,
|
||||||
pot: this.pot,
|
pot: this.pot,
|
||||||
potTotal: this.pot + this.list.reduce((sum, p) => sum + p.bet, 0),
|
potTotal: this.potTotal,
|
||||||
currentBet: this.currentBet,
|
currentBet: this.currentBet,
|
||||||
|
raises: this.raises, // ベット額の候補を 3bet・4bet などに合わせるため
|
||||||
button: this.button,
|
button: this.button,
|
||||||
sbSeat: this.sbSeat,
|
sbSeat: this.sbSeat,
|
||||||
bbSeat: this.bbSeat,
|
bbSeat: this.bbSeat,
|
||||||
toAct: this.toAct,
|
toAct: this.toAct,
|
||||||
finished: this.finished,
|
finished: this.finished,
|
||||||
showdown: this.showdown,
|
showdown: this.showdown,
|
||||||
pots: this.awaitingWinners ? this.buildPots() : null,
|
pots: this.awaitingWinners ? this.pots : null,
|
||||||
results: this.results,
|
results: this.results,
|
||||||
players,
|
players,
|
||||||
};
|
};
|
||||||
|
|||||||
+108
-9
@@ -6,7 +6,12 @@ const express = require('express');
|
|||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const { TableManager } = require('./tables');
|
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');
|
const PUBLIC = path.join(__dirname, '..', 'public');
|
||||||
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
|
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
|
||||||
@@ -86,6 +91,8 @@ app.use(auth.requireSameOrigin);
|
|||||||
app.get('/', page('lobby.html'));
|
app.get('/', page('lobby.html'));
|
||||||
app.get('/create', page('create.html'));
|
app.get('/create', page('create.html'));
|
||||||
app.get('/table/:id', page('table.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));
|
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
|
||||||
@@ -96,12 +103,55 @@ app.use(
|
|||||||
index: false,
|
index: false,
|
||||||
dotfiles: 'deny',
|
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/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() }));
|
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;
|
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||||
@@ -114,6 +164,8 @@ app.post('/api/tables', (req, res) => {
|
|||||||
const bigBlind = b.bigBlind;
|
const bigBlind = b.bigBlind;
|
||||||
const blindTimerEnabled = b.blindTimerEnabled === true;
|
const blindTimerEnabled = b.blindTimerEnabled === true;
|
||||||
const blindIntervalMin = b.blindIntervalMin;
|
const blindIntervalMin = b.blindIntervalMin;
|
||||||
|
const mode = b.mode;
|
||||||
|
const cpuCount = b.cpuCount;
|
||||||
const hostToken = cleanToken(b.token);
|
const hostToken = cleanToken(b.token);
|
||||||
|
|
||||||
if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' });
|
if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' });
|
||||||
@@ -121,8 +173,16 @@ app.post('/api/tables', (req, res) => {
|
|||||||
if (tables.nameTaken(name)) return res.status(409).json({ error: '同じ名前のテーブルがすでにあります' });
|
if (tables.nameTaken(name)) return res.status(409).json({ error: '同じ名前のテーブルがすでにあります' });
|
||||||
if (!intIn(startingStack, 10, 100000000)) return res.status(400).json({ error: '開始スタックが不正です' });
|
if (!intIn(startingStack, 10, 100000000)) return res.status(400).json({ error: '開始スタックが不正です' });
|
||||||
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
|
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
|
||||||
if (!intIn(bigBlind, smallBlind, startingStack)) return res.status(400).json({ error: 'BBは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 (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
|
||||||
|
// レビュー付き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がありません' });
|
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
|
||||||
|
|
||||||
const table = tables.create({
|
const table = tables.create({
|
||||||
@@ -132,12 +192,23 @@ app.post('/api/tables', (req, res) => {
|
|||||||
bigBlind,
|
bigBlind,
|
||||||
blindTimerEnabled,
|
blindTimerEnabled,
|
||||||
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
|
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
|
||||||
|
mode,
|
||||||
|
cpuCount: withBots ? cpuCount : 0,
|
||||||
hostToken,
|
hostToken,
|
||||||
});
|
});
|
||||||
broadcastLobby();
|
broadcastLobby();
|
||||||
res.json({ id: table.id });
|
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'));
|
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
|
||||||
|
|
||||||
// 内部情報(スタックトレース等)を返さない
|
// 内部情報(スタックトレース等)を返さない
|
||||||
@@ -175,8 +246,10 @@ function broadcastTable(table) {
|
|||||||
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
|
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// レビュー付きCPU戦は管理者のロビーにだけ出す
|
||||||
function broadcastLobby() {
|
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) {
|
function changed(table) {
|
||||||
@@ -184,6 +257,18 @@ function changed(table) {
|
|||||||
broadcastLobby();
|
broadcastLobby();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// テーブルを削除し、残っている観戦者に知らせる(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', { reason });
|
||||||
|
}
|
||||||
|
broadcastLobby();
|
||||||
|
}
|
||||||
|
|
||||||
// 二重チェック(allowRequest を通った後も念のため)
|
// 二重チェック(allowRequest を通った後も念のため)
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next();
|
if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next();
|
||||||
@@ -192,6 +277,7 @@ io.use((socket, next) => {
|
|||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
|
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
|
||||||
|
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
|
||||||
|
|
||||||
// イベントの連打を制限
|
// イベントの連打を制限
|
||||||
let windowStart = Date.now();
|
let windowStart = Date.now();
|
||||||
@@ -207,8 +293,8 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('lobby:subscribe', () => {
|
socket.on('lobby:subscribe', () => {
|
||||||
socket.join('lobby');
|
socket.join(socket.data.admin ? 'lobby:admin' : 'lobby');
|
||||||
socket.emit('lobby:tables', tables.list());
|
socket.emit('lobby:tables', tables.list(socket.data.admin));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('table:join', (p, ack) => {
|
socket.on('table:join', (p, ack) => {
|
||||||
@@ -219,6 +305,10 @@ io.on('connection', (socket) => {
|
|||||||
const name = cleanText(p.name, 12);
|
const name = cleanText(p.name, 12);
|
||||||
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
|
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
|
||||||
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
|
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);
|
const prev = socket.data.tableId && tables.get(socket.data.tableId);
|
||||||
if (prev && prev !== table) {
|
if (prev && prev !== table) {
|
||||||
@@ -259,6 +349,13 @@ io.on('connection', (socket) => {
|
|||||||
'table:sitOut': (t, token) => t.setSittingOut(token, true),
|
'table:sitOut': (t, token) => t.setSittingOut(token, true),
|
||||||
'table:sitIn': (t, token) => t.setSittingOut(token, false),
|
'table:sitIn': (t, token) => t.setSittingOut(token, false),
|
||||||
'table:rebuy': (t, token) => t.rebuy(token),
|
'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)) {
|
for (const [event, fn] of Object.entries(actions)) {
|
||||||
socket.on(event, (p, ack) => {
|
socket.on(event, (p, ack) => {
|
||||||
@@ -266,7 +363,9 @@ io.on('connection', (socket) => {
|
|||||||
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
|
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
|
||||||
const error = fn(table, socket.data.token, p);
|
const error = fn(table, socket.data.token, p);
|
||||||
reply(ack, error ? { ok: false, error } : { ok: true });
|
reply(ack, error ? { ok: false, error } : { ok: true });
|
||||||
if (!error) changed(table);
|
if (error) return;
|
||||||
|
if (event === 'table:leave' && table.shouldClose()) return closeTable(table, socket);
|
||||||
|
changed(table);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +375,7 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ブラインドタイマー・持ち時間・次のハンドなど時間で進む処理
|
// ブラインドタイマー・オールイン後の場札・次のハンドなど時間で進む処理
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const table of tables.tables.values()) {
|
for (const table of tables.tables.values()) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
+299
-14
@@ -4,6 +4,20 @@ const crypto = require('crypto');
|
|||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const Blinds = require('../public/assets/blinds');
|
const Blinds = require('../public/assets/blinds');
|
||||||
const { Hand } = require('./hand');
|
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 プレイヤーが加わる
|
||||||
|
// review : レビュー付きCPU戦(管理者専用)。作成者と CPU だけで遊び、ハンドごとにレビューを作る。
|
||||||
|
// レビューを見てから次のハンドに進めるよう、ハンドが終わると作成者が「次のハンドへ」を押すまで待つ
|
||||||
|
const MODES = ['chips', 'friends', 'cpu', 'review'];
|
||||||
|
const CPU_NAMES = ['ハル', 'ソラ', 'リク', 'ミオ', 'レン', 'ユイ', 'カイ', 'ナギ'];
|
||||||
|
|
||||||
class Table {
|
class Table {
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
@@ -14,12 +28,22 @@ class Table {
|
|||||||
this.baseBigBlind = opts.bigBlind;
|
this.baseBigBlind = opts.bigBlind;
|
||||||
this.timerEnabled = opts.blindTimerEnabled;
|
this.timerEnabled = opts.blindTimerEnabled;
|
||||||
this.intervalMin = opts.blindIntervalMin;
|
this.intervalMin = opts.blindIntervalMin;
|
||||||
|
this.mode = MODES.includes(opts.mode) ? opts.mode : 'chips';
|
||||||
|
// カード機能: サーバがカードを配り、ショーダウンの勝敗も自動で判定する(キャッシュオンリーは実物のカード)
|
||||||
|
this.cardsEnabled = this.mode !== 'chips';
|
||||||
this.hostToken = opts.hostToken || null;
|
this.hostToken = opts.hostToken || null;
|
||||||
this.maxSeats = config.MAX_SEATS;
|
this.maxSeats = config.MAX_SEATS;
|
||||||
|
|
||||||
// { token, name, stack, buyIns, sittingOut, left }
|
// { token, name, stack, buyIns, sittingOut, left, bot, style }
|
||||||
// left: ハンド中に退席した人。ハンドが終わるまで席を予約しておく
|
// left: ハンド中に退席した人。ハンドが終わるまで席を予約しておく
|
||||||
|
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
|
||||||
this.seats = new Array(this.maxSeats).fill(null);
|
this.seats = new Array(this.maxSeats).fill(null);
|
||||||
|
this.botTurn = null; // { key, at }: CPU が行動する時刻
|
||||||
|
this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
|
||||||
|
this.withBots = this.mode === 'cpu' || this.mode === 'review';
|
||||||
|
if (this.withBots) this.seatBots(opts.cpuCount);
|
||||||
|
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー
|
||||||
|
this.review = this.mode === 'review' ? { session: null, last: null } : null;
|
||||||
this.hand = null;
|
this.hand = null;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
@@ -28,6 +52,7 @@ class Table {
|
|||||||
this.handNumber = 0;
|
this.handNumber = 0;
|
||||||
this.buttonSeat = null;
|
this.buttonSeat = null;
|
||||||
this.nextHandAt = null;
|
this.nextHandAt = null;
|
||||||
|
this.runoutAt = null; // オールイン後、次の場札をめくる時刻
|
||||||
this.levelEndsAt = null;
|
this.levelEndsAt = null;
|
||||||
this.levelRemainingMs = this.intervalMs;
|
this.levelRemainingMs = this.intervalMs;
|
||||||
|
|
||||||
@@ -35,6 +60,29 @@ class Table {
|
|||||||
this.emptySince = null;
|
this.emptySince = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPU は席1から順に座らせる(席0は作成者)
|
||||||
|
seatBots(count) {
|
||||||
|
const n = Math.max(1, Math.min(this.maxSeats - 1, Number.isInteger(count) ? count : 1));
|
||||||
|
const names = CPU_NAMES.slice();
|
||||||
|
for (let i = names.length - 1; i > 0; i--) {
|
||||||
|
const j = crypto.randomInt(i + 1);
|
||||||
|
[names[i], names[j]] = [names[j], names[i]];
|
||||||
|
}
|
||||||
|
const styles = ai.makeStyles(n); // 思考(使うライブラリ)はなるべくばらけさせる。画面には出さない
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
this.seats[k + 1] = {
|
||||||
|
token: crypto.randomBytes(16).toString('hex'), // ブラウザには渡さない
|
||||||
|
name: names[k],
|
||||||
|
stack: this.startingStack,
|
||||||
|
buyIns: 1,
|
||||||
|
sittingOut: false,
|
||||||
|
left: false,
|
||||||
|
bot: true,
|
||||||
|
style: styles[k],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get intervalMs() {
|
get intervalMs() {
|
||||||
return this.intervalMin * 60 * 1000;
|
return this.intervalMin * 60 * 1000;
|
||||||
}
|
}
|
||||||
@@ -71,8 +119,28 @@ class Table {
|
|||||||
.filter((i) => i >= 0);
|
.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() {
|
canDeal() {
|
||||||
return this.started && !this.paused && this.eligibleSeats().length >= 2;
|
const eligible = this.eligibleSeats();
|
||||||
|
return this.started && !this.paused && eligible.length >= 2 && eligible.some((i) => !this.seats[i].bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 着席した席番号を返す。満席なら -1(観戦)
|
// 着席した席番号を返す。満席なら -1(観戦)
|
||||||
@@ -87,6 +155,7 @@ class Table {
|
|||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
if (this.mode === 'review' && token !== this.hostToken) return -1; // 作成者と CPU だけのテーブル
|
||||||
const free = this.seats.findIndex((s) => !s);
|
const free = this.seats.findIndex((s) => !s);
|
||||||
if (free < 0) return -1;
|
if (free < 0) return -1;
|
||||||
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
|
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
|
||||||
@@ -107,12 +176,17 @@ class Table {
|
|||||||
this.seats[i] = null;
|
this.seats[i] = null;
|
||||||
}
|
}
|
||||||
if (this.hostToken === token) {
|
if (this.hostToken === token) {
|
||||||
const next = this.seats.find((s) => s && !s.left);
|
const next = this.seats.find((s) => s && !s.left && !s.bot);
|
||||||
this.hostToken = next ? next.token : null;
|
this.hostToken = next ? next.token : null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
|
||||||
|
shouldClose() {
|
||||||
|
return this.withBots && !this.seats.some((s) => s && !s.left && !s.bot);
|
||||||
|
}
|
||||||
|
|
||||||
removeLeftSeats() {
|
removeLeftSeats() {
|
||||||
this.seats = this.seats.map((s) => (s && s.left ? null : s));
|
this.seats = this.seats.map((s) => (s && s.left ? null : s));
|
||||||
}
|
}
|
||||||
@@ -127,6 +201,14 @@ class Table {
|
|||||||
|
|
||||||
startNewHand() {
|
startNewHand() {
|
||||||
this.removeLeftSeats();
|
this.removeLeftSeats();
|
||||||
|
this.applySeating();
|
||||||
|
// チップがなくなった CPU は自動でリバイ
|
||||||
|
for (const s of this.seats) {
|
||||||
|
if (s && s.bot && s.stack === 0) {
|
||||||
|
s.stack = this.startingStack;
|
||||||
|
s.buyIns++;
|
||||||
|
}
|
||||||
|
}
|
||||||
const eligible = this.eligibleSeats();
|
const eligible = this.eligibleSeats();
|
||||||
this.buttonSeat =
|
this.buttonSeat =
|
||||||
this.buttonSeat == null
|
this.buttonSeat == null
|
||||||
@@ -139,7 +221,17 @@ class Table {
|
|||||||
this.handNumber++;
|
this.handNumber++;
|
||||||
const { sb, bb } = this.blinds;
|
const { sb, bb } = this.blinds;
|
||||||
this.nextHandAt = null;
|
this.nextHandAt = null;
|
||||||
this.hand = new Hand({ number: this.handNumber, seats: this.seats, order, button: this.buttonSeat, sb, bb });
|
this.runoutAt = null;
|
||||||
|
this.botTurn = null;
|
||||||
|
this.hand = new Hand({
|
||||||
|
number: this.handNumber,
|
||||||
|
seats: this.seats,
|
||||||
|
order,
|
||||||
|
button: this.buttonSeat,
|
||||||
|
sb,
|
||||||
|
bb,
|
||||||
|
cards: this.cardsEnabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- プレイヤー操作(エラー時はメッセージを返す) ----
|
// ---- プレイヤー操作(エラー時はメッセージを返す) ----
|
||||||
@@ -188,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()) {
|
start(token, now = Date.now()) {
|
||||||
if (!this.isHost(token)) return 'ホストのみ開始できます';
|
if (!this.isHost(token)) return 'ホストのみ開始できます';
|
||||||
if (this.started) return 'すでに開始しています';
|
if (this.started) return 'すでに開始しています';
|
||||||
@@ -202,8 +333,10 @@ class Table {
|
|||||||
if (!this.isHost(token)) return 'ホストのみ操作できます';
|
if (!this.isHost(token)) return 'ホストのみ操作できます';
|
||||||
if (!this.started) return 'まだ開始していません';
|
if (!this.started) return 'まだ開始していません';
|
||||||
if (this.paused) {
|
if (this.paused) {
|
||||||
// 止めていた時間だけ次のハンドの開始を後ろにずらす
|
// 止めていた時間だけ次のハンドの開始・場札をめくる時刻を後ろにずらす
|
||||||
if (this.nextHandAt != null) this.nextHandAt += now - this.pausedAt;
|
if (this.nextHandAt != null) this.nextHandAt += now - this.pausedAt;
|
||||||
|
if (this.runoutAt != null) this.runoutAt += now - this.pausedAt;
|
||||||
|
this.botTurn = null; // CPU は再開してから考え直す
|
||||||
if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs;
|
if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.pausedAt = null;
|
this.pausedAt = null;
|
||||||
@@ -218,7 +351,33 @@ class Table {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 時間で進む処理(ブラインドタイマー・次のハンドの開始)
|
// CPU の考える時間。人間がハンドに残っていなければ早めに進める
|
||||||
|
botDelay() {
|
||||||
|
const h = this.hand;
|
||||||
|
const humanLive = h.live().some((p) => !this.seats[p.seat].bot && !this.seats[p.seat].left);
|
||||||
|
if (!humanLive) return config.BOT_FAST_MS;
|
||||||
|
return config.BOT_THINK_MIN_MS + crypto.randomInt(config.BOT_THINK_MAX_MS - config.BOT_THINK_MIN_MS + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手番の CPU が考える時間を過ぎていたら行動する。行動したら true
|
||||||
|
botStep(now) {
|
||||||
|
const h = this.hand;
|
||||||
|
if (!h || h.finished || h.toAct == null || !this.seats[h.toAct].bot) return false;
|
||||||
|
const key = `${h.number}:${h.actions}`;
|
||||||
|
if (!this.botTurn || this.botTurn.key !== key) {
|
||||||
|
this.botTurn = { key, at: now + this.botDelay() };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (now < this.botTurn.at) return false;
|
||||||
|
this.botTurn = null;
|
||||||
|
const seat = h.toAct;
|
||||||
|
const d = ai.decide(h, seat, { style: this.seats[seat].style, iterations: config.BOT_ITERATIONS });
|
||||||
|
// 万一判断が不正でも止まらないよう、チェック/フォールドにする
|
||||||
|
if (!d || h.act(seat, d.type, d.amount)) h.autoAct();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 時間で進む処理(ブラインドタイマー・CPU の行動・オールイン後の場札・次のハンドの開始)
|
||||||
// 状態が変わったら true
|
// 状態が変わったら true
|
||||||
tick(now) {
|
tick(now) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -229,15 +388,36 @@ class Table {
|
|||||||
}
|
}
|
||||||
if (this.paused) return changed;
|
if (this.paused) return changed;
|
||||||
|
|
||||||
|
if (this.botStep(now)) changed = true;
|
||||||
|
|
||||||
|
const h = this.hand;
|
||||||
|
if (h && h.runout) {
|
||||||
|
if (this.runoutAt == null) this.runoutAt = now + config.RUNOUT_STEP_MS;
|
||||||
|
else if (now >= this.runoutAt) {
|
||||||
|
h.runoutStep();
|
||||||
|
this.runoutAt = h.runout ? now + config.RUNOUT_STEP_MS : null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.hand && this.hand.finished) {
|
||||||
if (this.nextHandAt == null) {
|
if (this.nextHandAt == null) {
|
||||||
this.nextHandAt = now + (this.hand.showdown ? config.SHOWDOWN_MS : config.FOLD_WIN_MS);
|
const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
|
||||||
|
this.nextHandAt = now + (this.hand.showdown ? showdownMs : config.FOLD_WIN_MS);
|
||||||
} else if (now >= this.nextHandAt) {
|
} else if (now >= this.nextHandAt) {
|
||||||
if (this.canDeal()) this.startNewHand();
|
if (this.canDeal()) this.startNewHand();
|
||||||
else {
|
else {
|
||||||
this.hand = null;
|
this.hand = null;
|
||||||
this.nextHandAt = null;
|
this.nextHandAt = null;
|
||||||
this.removeLeftSeats();
|
this.removeLeftSeats();
|
||||||
|
this.applySeating();
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -252,6 +432,82 @@ class Table {
|
|||||||
return changed;
|
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() {
|
summary() {
|
||||||
const { sb, bb } = this.blinds;
|
const { sb, bb } = this.blinds;
|
||||||
return {
|
return {
|
||||||
@@ -267,26 +523,42 @@ class Table {
|
|||||||
paused: this.paused,
|
paused: this.paused,
|
||||||
timerEnabled: this.timerEnabled,
|
timerEnabled: this.timerEnabled,
|
||||||
intervalMin: this.intervalMin,
|
intervalMin: this.intervalMin,
|
||||||
|
cardsEnabled: this.cardsEnabled,
|
||||||
|
mode: this.mode,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// token = 閲覧者のトークン(他人のトークンは送らない)
|
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) {
|
publicState(token, connectedTokens) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const { sb, bb } = this.blinds;
|
const { sb, bb } = this.blinds;
|
||||||
const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1);
|
const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1);
|
||||||
const mySeat = this.seatOf(token);
|
const mySeat = this.seatOf(token);
|
||||||
const h = this.hand;
|
const h = this.hand;
|
||||||
const hv = h ? h.view() : null;
|
const hv = h ? h.view(mySeat, token) : null;
|
||||||
const me = mySeat >= 0 ? this.seats[mySeat] : 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 {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
startingStack: this.startingStack,
|
startingStack: this.startingStack,
|
||||||
maxSeats: this.maxSeats,
|
maxSeats: this.maxSeats,
|
||||||
|
cardsEnabled: this.cardsEnabled,
|
||||||
|
mode: this.mode,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
smallBlind: sb,
|
smallBlind: sb,
|
||||||
bigBlind: bb,
|
bigBlind: bb,
|
||||||
@@ -301,6 +573,7 @@ class Table {
|
|||||||
phase: this.phase,
|
phase: this.phase,
|
||||||
started: this.started,
|
started: this.started,
|
||||||
paused: this.paused,
|
paused: this.paused,
|
||||||
|
awaitingNext: this.awaitingNext,
|
||||||
handNumber: this.handNumber,
|
handNumber: this.handNumber,
|
||||||
hand: hv,
|
hand: hv,
|
||||||
toAct: this.handActive ? h.toAct : null,
|
toAct: this.handActive ? h.toAct : null,
|
||||||
@@ -310,7 +583,9 @@ class Table {
|
|||||||
seat: i,
|
seat: i,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
stack: s.stack,
|
stack: s.stack,
|
||||||
connected: connectedTokens.has(s.token),
|
...this.standing(i),
|
||||||
|
connected: Boolean(s.bot) || connectedTokens.has(s.token),
|
||||||
|
bot: Boolean(s.bot),
|
||||||
sittingOut: s.sittingOut,
|
sittingOut: s.sittingOut,
|
||||||
...(hv && hv.players[i] ? { inHand: true, ...hv.players[i] } : { inHand: false }),
|
...(hv && hv.players[i] ? { inHand: true, ...hv.players[i] } : { inHand: false }),
|
||||||
}
|
}
|
||||||
@@ -321,7 +596,7 @@ class Table {
|
|||||||
me && !me.left
|
me && !me.left
|
||||||
? {
|
? {
|
||||||
stack: me.stack,
|
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,
|
buyIns: me.buyIns,
|
||||||
sittingOut: me.sittingOut,
|
sittingOut: me.sittingOut,
|
||||||
canRebuy: me.stack === 0 && !inLiveHand,
|
canRebuy: me.stack === 0 && !inLiveHand,
|
||||||
@@ -330,6 +605,12 @@ class Table {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
isHost: this.isHost(token),
|
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,
|
serverNow: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -344,8 +625,10 @@ class TableManager {
|
|||||||
return this.tables.get(id);
|
return this.tables.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
list() {
|
// レビュー付きCPU戦は管理者にだけ見せる
|
||||||
|
list(admin = false) {
|
||||||
return [...this.tables.values()]
|
return [...this.tables.values()]
|
||||||
|
.filter((t) => admin || t.mode !== 'review')
|
||||||
.sort((a, b) => b.createdAt - a.createdAt)
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
.map((t) => t.summary());
|
.map((t) => t.summary());
|
||||||
}
|
}
|
||||||
@@ -372,8 +655,10 @@ class TableManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(id) {
|
delete(id) {
|
||||||
|
const t = this.tables.get(id);
|
||||||
|
if (t && t.review && t.review.session) reviews.close(t.review.session);
|
||||||
this.tables.delete(id);
|
this.tables.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TableManager, Table };
|
module.exports = { TableManager, Table, MODES };
|
||||||
+579
-9
@@ -1,11 +1,20 @@
|
|||||||
'use strict';
|
'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.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 test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { PokerEngine } = require('@pokertools/engine');
|
||||||
|
const { getCardCodes, stringifyCardCode } = require('@pokertools/evaluator');
|
||||||
const { Table } = require('../server/tables');
|
const { Table } = require('../server/tables');
|
||||||
|
const { bestHand, compareScore } = require('../server/cards');
|
||||||
|
const config = require('../server/config');
|
||||||
|
|
||||||
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
|
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
|
||||||
|
|
||||||
@@ -76,17 +85,20 @@ test('BBのオプションとチェックでストリートが進む', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
|
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
|
||||||
const t = makeTable([1000, 1000, 1000]);
|
// ボタンを席0にして、BB(席2)は 75 しか持っていない
|
||||||
|
const t = makeTable([1000, 1000, 75]);
|
||||||
|
t.buttonSeat = 2;
|
||||||
t.start(T(0), 0);
|
t.start(T(0), 0);
|
||||||
const h = t.hand;
|
const h = t.hand;
|
||||||
const [sb, bb, btn] = h.order;
|
const [sb, bb, btn] = h.order;
|
||||||
|
assert.deepEqual([sb, bb, btn], [1, 2, 0]);
|
||||||
// 最低レイズ額は 40(20 + 20)
|
// 最低レイズ額は 40(20 + 20)
|
||||||
assert.ok(t.act(tk(t, btn), 'raise', 39));
|
assert.ok(t.act(tk(t, btn), 'raise', 39));
|
||||||
assert.equal(t.act(tk(t, btn), 'raise', 60), null); // +40 のレイズ
|
assert.equal(t.act(tk(t, btn), 'raise', 60), null); // +40 のレイズ
|
||||||
assert.equal(h.options(sb).minRaiseTo, 100);
|
assert.equal(h.options(sb).minRaiseTo, 100);
|
||||||
assert.equal(t.act(tk(t, sb), 'call', 0), null);
|
assert.equal(t.act(tk(t, sb), 'call', 0), null);
|
||||||
// BB は 75 しか持っていないとする → 75 オールインは 100 未満なので不完全なレイズ
|
// BB の 75 オールインは 100 未満なので不完全なレイズ
|
||||||
t.seats[bb].stack = 75 - 20;
|
assert.equal(h.options(bb).minRaiseTo, 75);
|
||||||
assert.equal(t.act(tk(t, bb), 'raise', 75), null);
|
assert.equal(t.act(tk(t, bb), 'raise', 75), null);
|
||||||
assert.equal(h.currentBet, 75);
|
assert.equal(h.currentBet, 75);
|
||||||
assert.equal(h.toAct, btn);
|
assert.equal(h.toAct, btn);
|
||||||
@@ -220,6 +232,7 @@ test('離席中の人の手番は自動でチェック/フォールド、次の
|
|||||||
assert.notEqual(h.toAct, first);
|
assert.notEqual(h.toAct, first);
|
||||||
t.act(tk(t, h.toAct), 'fold', 0); // 残り1人で終了
|
t.act(tk(t, h.toAct), 'fold', 0); // 残り1人で終了
|
||||||
assert.ok(h.finished);
|
assert.ok(h.finished);
|
||||||
|
assert.equal(t.nextHand(T(0), 0), null);
|
||||||
t.tick(10 ** 6);
|
t.tick(10 ** 6);
|
||||||
t.tick(10 ** 7);
|
t.tick(10 ** 7);
|
||||||
assert.equal(t.hand.number, 2);
|
assert.equal(t.hand.number, 2);
|
||||||
@@ -241,15 +254,60 @@ test('一時停止中はアクションも勝者の決定も次のハンドも
|
|||||||
t.togglePause(T(0), 4000);
|
t.togglePause(T(0), 4000);
|
||||||
assert.equal(t.award(T(0), [[h.order[0]]]), null);
|
assert.equal(t.award(T(0), [[h.order[0]]]), null);
|
||||||
t.togglePause(T(0), 5000);
|
t.togglePause(T(0), 5000);
|
||||||
|
assert.equal(t.nextHand(T(0), 5000), '一時停止中です');
|
||||||
t.tick(10 ** 7);
|
t.tick(10 ** 7);
|
||||||
t.tick(10 ** 8);
|
t.tick(10 ** 8);
|
||||||
assert.equal(t.hand.number, 1); // 止まっている
|
assert.equal(t.hand.number, 1); // 止まっている
|
||||||
t.togglePause(T(0), 10 ** 8);
|
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 ** 5);
|
||||||
t.tick(10 ** 8 + 10 ** 6);
|
t.tick(10 ** 8 + 10 ** 6);
|
||||||
assert.equal(t.hand.number, 2);
|
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('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
|
test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
|
||||||
const t = makeTable([1000, 1000, 1000]);
|
const t = makeTable([1000, 1000, 1000]);
|
||||||
t.start(T(0), 0);
|
t.start(T(0), 0);
|
||||||
@@ -266,15 +324,46 @@ test('ハンド中の退席: フォールド扱いで席はハンド終了まで
|
|||||||
assert.equal(h.street, 'FLOP');
|
assert.equal(h.street, 'FLOP');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('2人のうち片方が退席したら残った人が勝つ(BB は返らない)', () => {
|
test('2人のうち片方が退席したら残った人が勝つ(コールされていない分は退席者に返る)', () => {
|
||||||
const t = makeTable([1000, 1000]);
|
const t = makeTable([1000, 1000]);
|
||||||
t.start(T(0), 0);
|
t.start(T(0), 0);
|
||||||
const h = t.hand;
|
const h = t.hand;
|
||||||
const stay = h.toAct; // SB(ボタン)
|
const stay = h.toAct; // SB(ボタン)
|
||||||
const other = h.order.find((s) => s !== stay);
|
const other = h.order.find((s) => s !== stay);
|
||||||
t.leave(tk(t, other));
|
t.leave(tk(t, other));
|
||||||
assert.ok(h.finished);
|
assert.ok(h.finished && !h.showdown);
|
||||||
assert.equal(t.seats[stay].stack, 1020);
|
// BB の 20 のうち SB の 10 を超える分は返り、SB は 10 + 10 を獲得
|
||||||
|
assert.equal(t.seats[stay].stack, 1010);
|
||||||
|
assert.equal(t.seats[other].stack, 990);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('チェックできるのに降りてサイドポットの対象者がいなくなるフォールドは断る', () => {
|
||||||
|
// 席0・席1 がオールイン、席2・席3 がサイドポットを作ったあと、席2 が降り、最後の席3 もチェックせずに降りようとする
|
||||||
|
// (@pokertools/engine はサイドポットの対象者がいなくなるとエラーになる)
|
||||||
|
const t = makeTable([100, 200, 1000, 1000]);
|
||||||
|
t.buttonSeat = 3;
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
assert.deepEqual(h.order, [1, 2, 3, 0]);
|
||||||
|
assert.equal(t.act(tk(t, 3), 'raise', 500), null);
|
||||||
|
assert.equal(t.act(tk(t, 0), 'call', 0), null); // オールイン
|
||||||
|
assert.equal(t.act(tk(t, 1), 'call', 0), null); // オールイン
|
||||||
|
assert.equal(t.act(tk(t, 2), 'call', 0), null);
|
||||||
|
assert.equal(h.street, 'FLOP');
|
||||||
|
assert.equal(t.act(tk(t, 2), 'fold', 0), null);
|
||||||
|
assert.equal(t.act(tk(t, 3), 'fold', 0), 'いまはフォールドできません。チェックしてください');
|
||||||
|
assert.equal(h.toAct, 3);
|
||||||
|
// 退席なら代わりにチェックしたことになる(残りはオールインの人だけなのでショーダウンへ)
|
||||||
|
assert.ok(t.leave(tk(t, 3)));
|
||||||
|
assert.equal(h.street, 'SHOWDOWN');
|
||||||
|
assert.deepEqual(
|
||||||
|
h.view().pots.map((p) => [p.amount, p.eligible]),
|
||||||
|
[
|
||||||
|
[400, [1, 3, 0]],
|
||||||
|
[300, [1, 3]],
|
||||||
|
[600, [3]],
|
||||||
|
]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('他人の手番では操作できない・不正な額は拒否', () => {
|
test('他人の手番では操作できない・不正な額は拒否', () => {
|
||||||
@@ -321,8 +410,10 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
|
|||||||
const r = rand(10);
|
const r = rand(10);
|
||||||
const me = tk(t, h.toAct);
|
const me = tk(t, h.toAct);
|
||||||
let err;
|
let err;
|
||||||
if (r < 2) err = t.act(me, 'fold', 0);
|
if (r < 2) {
|
||||||
else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
|
err = t.act(me, 'fold', 0);
|
||||||
|
if (err && o.canCheck) err = t.act(me, 'check', 0); // エンジンが断るフォールド
|
||||||
|
} else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
|
||||||
else err = t.act(me, 'raise', o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1));
|
else err = t.act(me, 'raise', o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1));
|
||||||
assert.equal(err, null);
|
assert.equal(err, null);
|
||||||
} else if (h.awaitingWinners) {
|
} else if (h.awaitingWinners) {
|
||||||
@@ -335,6 +426,7 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
|
|||||||
});
|
});
|
||||||
assert.equal(t.award(T(0), winners), null);
|
assert.equal(t.award(T(0), winners), null);
|
||||||
} else {
|
} else {
|
||||||
|
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
|
||||||
t.tick((now += 10 ** 5));
|
t.tick((now += 10 ** 5));
|
||||||
}
|
}
|
||||||
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
|
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
|
||||||
@@ -345,3 +437,481 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
|
|||||||
}
|
}
|
||||||
assert.ok(showdowns > 100 && sidePots > 20, `showdowns=${showdowns} sidePots=${sidePots}`);
|
assert.ok(showdowns > 100 && sidePots > 20, `showdowns=${showdowns} sidePots=${sidePots}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- カード機能あり ----------
|
||||||
|
|
||||||
|
// エンジンの山札("As" 形式)
|
||||||
|
const deckOf = (hand) => hand.state.deck.map(stringifyCardCode);
|
||||||
|
|
||||||
|
// 手札と、これから配られる場札を差し替える(エンジンの状態をスナップショット経由で書き換える)。holes は h.order の順
|
||||||
|
function rig(hand, holes, board) {
|
||||||
|
const snap = hand.engine.snapshot;
|
||||||
|
const used = new Set([...holes.flat(), ...board]);
|
||||||
|
const rest = deckOf(hand)
|
||||||
|
.concat(...hand.order.map((s) => snap.players[hand.toEngine.get(s)].hand))
|
||||||
|
.filter((c) => !used.has(c));
|
||||||
|
hand.order.forEach((s, k) => {
|
||||||
|
const e = hand.toEngine.get(s);
|
||||||
|
snap.players[e] = { ...snap.players[e], hand: holes[k].slice() };
|
||||||
|
});
|
||||||
|
// 山札の先頭から バーン + フロップ3枚、バーン + ターン、バーン + リバー の順に配られる
|
||||||
|
const seq = ['X', ...board.slice(0, 3), 'X', board[3], 'X', board[4]].map((c) => (c === 'X' ? rest.pop() : c));
|
||||||
|
snap.deck = getCardCodes(seq.concat(rest));
|
||||||
|
hand.engine = PokerEngine.restore(snap);
|
||||||
|
hand.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECTATOR = 's'.repeat(32);
|
||||||
|
const viewOf = (t, token) => t.publicState(token, new Set()).hand;
|
||||||
|
|
||||||
|
test('役の判定と比較', () => {
|
||||||
|
const h = (s) => bestHand(s.split(' '));
|
||||||
|
assert.equal(h('As Ks Qs Js Ts 2d 3c').name, 'ロイヤルフラッシュ');
|
||||||
|
assert.equal(h('5s 4s 3s 2s As Kd Kc').name, 'ストレートフラッシュ');
|
||||||
|
assert.equal(h('9h 9d 9s 9c 2d 3c 4h').name, 'フォーカード');
|
||||||
|
assert.equal(h('9h 9d 9s 2c 2d 3c 4h').name, 'フルハウス');
|
||||||
|
assert.equal(h('Ah 9h 7h 4h 2h Kd Kc').name, 'フラッシュ');
|
||||||
|
assert.equal(h('Ah 2d 3c 4s 5h Kd Kc').name, 'ストレート');
|
||||||
|
assert.equal(h('Qh Qd Qs 7c 2d 3c 4h').name, 'スリーカード');
|
||||||
|
assert.equal(h('Qh Qd 7s 7c 2d 3c 4h').name, 'ツーペア');
|
||||||
|
assert.equal(h('Qh Qd 8s 7c 2d 3c 5h').name, 'ワンペア');
|
||||||
|
assert.equal(h('Ah Qd 8s 7c 2d 3c 5h').name, 'ハイカード');
|
||||||
|
// A-5 は最弱のストレート
|
||||||
|
assert.ok(compareScore(h('Ah 2d 3c 4s 5h').score, h('2h 3d 4c 5s 6h').score) < 0);
|
||||||
|
// キッカー
|
||||||
|
assert.ok(compareScore(h('Ah Ad Ks 7c 2d').score, h('As Ac Qs 7d 2h').score) > 0);
|
||||||
|
// 同点
|
||||||
|
assert.equal(compareScore(h('Ah Kd Qs Jc 9d').score, h('As Kc Qd Jh 9s').score), 0);
|
||||||
|
// ツーペアは上のペア → 下のペア → キッカー
|
||||||
|
assert.ok(compareScore(h('Kh Kd 3s 3c Ad').score, h('Qh Qd Js Jc Ad').score) > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードなしのテーブルでは手札も場札も扱わない', () => {
|
||||||
|
const t = makeTable([1000, 1000]);
|
||||||
|
t.start(T(0), 0);
|
||||||
|
assert.equal(t.publicState(T(0), new Set()).cardsEnabled, false);
|
||||||
|
const v = viewOf(t, T(0));
|
||||||
|
assert.deepEqual(v.board, []);
|
||||||
|
assert.equal('cards' in v.players[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: 自分の手札だけが見え、ストリートごとに場札が配られ、勝者は自動で決まる', () => {
|
||||||
|
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [sb, bb, btn] = h.order;
|
||||||
|
assert.equal(t.publicState(tk(t, sb), new Set()).cardsEnabled, true);
|
||||||
|
let v = viewOf(t, tk(t, sb));
|
||||||
|
assert.equal(v.players[sb].cards.length, 2);
|
||||||
|
assert.equal(v.players[bb].cards, null);
|
||||||
|
assert.equal(v.players[btn].cards, null);
|
||||||
|
assert.equal(viewOf(t, SPECTATOR).players[sb].cards, null);
|
||||||
|
|
||||||
|
t.act(tk(t, btn), 'call', 0);
|
||||||
|
t.act(tk(t, sb), 'call', 0);
|
||||||
|
t.act(tk(t, bb), 'check', 0);
|
||||||
|
assert.equal(h.street, 'FLOP');
|
||||||
|
assert.equal(h.board.length, 3);
|
||||||
|
assert.ok(viewOf(t, tk(t, sb)).players[sb].handName);
|
||||||
|
t.act(tk(t, sb), 'check', 0);
|
||||||
|
t.act(tk(t, bb), 'check', 0);
|
||||||
|
t.act(tk(t, btn), 'check', 0);
|
||||||
|
assert.equal(h.board.length, 4);
|
||||||
|
checkDown(t);
|
||||||
|
assert.equal(h.board.length, 5);
|
||||||
|
assert.ok(h.finished && h.showdown);
|
||||||
|
assert.equal(h.awaitingWinners, false);
|
||||||
|
assert.equal(t.award(T(0), [[sb]]), 'ショーダウンではありません');
|
||||||
|
assert.ok(h.results[0].handName && h.results[0].bestCards.length === 5);
|
||||||
|
// ショーダウン後は残った人の手札が公開される
|
||||||
|
v = viewOf(t, SPECTATOR);
|
||||||
|
for (const s of [sb, bb, btn]) assert.equal(v.players[s].cards.length, 2);
|
||||||
|
|
||||||
|
// すべて別のカード(バーンの3枚は山札から消えている)
|
||||||
|
const all = [...deckOf(h), ...h.board, ...h.list.flatMap((p) => p.cards)];
|
||||||
|
assert.equal(new Set(all).size, all.length);
|
||||||
|
assert.equal(all.length, 52 - 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: フォールドした人の手札は公開されない', () => {
|
||||||
|
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [sb, bb, btn] = h.order;
|
||||||
|
t.act(tk(t, btn), 'fold', 0);
|
||||||
|
t.act(tk(t, sb), 'call', 0);
|
||||||
|
t.act(tk(t, bb), 'check', 0);
|
||||||
|
checkDown(t);
|
||||||
|
assert.ok(h.finished);
|
||||||
|
const v = viewOf(t, tk(t, sb));
|
||||||
|
assert.equal(v.players[btn].cards, null);
|
||||||
|
assert.equal(v.players[bb].cards.length, 2);
|
||||||
|
assert.equal(viewOf(t, tk(t, btn)).players[btn].cards.length, 2); // 本人には見える
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: 3人オールインで場札を順にめくり、サイドポットを自動で分配', () => {
|
||||||
|
const t = makeTable([100, 300, 500], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
// 席0 が最強、席1 が2番目、席2 が最弱
|
||||||
|
const holes = { 0: ['As', 'Ah'], 1: ['Ks', 'Kh'], 2: ['7c', '2d'] };
|
||||||
|
rig(h, h.order.map((s) => holes[s]), ['Ad', 'Kd', '9c', '5s', '3h']);
|
||||||
|
for (let guard = 0; guard < 10 && h.toAct != null; guard++) {
|
||||||
|
const o = h.options(h.toAct);
|
||||||
|
assert.equal(t.act(tk(t, h.toAct), o.canRaise ? 'raise' : 'call', o.canRaise ? o.maxRaiseTo : 0), null);
|
||||||
|
}
|
||||||
|
assert.ok(h.runout && h.revealed);
|
||||||
|
assert.equal(viewOf(t, SPECTATOR).players[2].cards.length, 2);
|
||||||
|
const step = config.RUNOUT_STEP_MS;
|
||||||
|
t.tick(0);
|
||||||
|
// 一時停止中はめくらない
|
||||||
|
t.togglePause(T(0), 0);
|
||||||
|
t.tick(10 * step);
|
||||||
|
assert.equal(h.board.length, 0);
|
||||||
|
t.togglePause(T(0), 10 * step);
|
||||||
|
t.tick(10 * step + step - 1);
|
||||||
|
assert.equal(h.board.length, 0);
|
||||||
|
t.tick(11 * step);
|
||||||
|
assert.equal(h.board.length, 3);
|
||||||
|
t.tick(12 * step);
|
||||||
|
assert.equal(h.board.length, 4);
|
||||||
|
t.tick(13 * step);
|
||||||
|
assert.equal(h.board.length, 5);
|
||||||
|
assert.ok(!h.finished);
|
||||||
|
t.tick(14 * step);
|
||||||
|
assert.ok(h.finished && h.showdown);
|
||||||
|
// メイン 300 → 席0、サイド 400 → 席1、席2 の超過 200 は返金
|
||||||
|
assert.equal(t.seats[0].stack, 300);
|
||||||
|
assert.equal(t.seats[1].stack, 400);
|
||||||
|
assert.equal(t.seats[2].stack, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
h.results.map((r) => [r.amount, r.winners, r.handName]),
|
||||||
|
[
|
||||||
|
[300, [0], 'スリーカード'],
|
||||||
|
[400, [1], 'スリーカード'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: 引き分けは山分け', () => {
|
||||||
|
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [sb, bb, btn] = h.order;
|
||||||
|
rig(h, [['2c', '3d'], ['2d', '3c'], ['4h', '5h']], ['As', 'Ks', 'Qs', 'Js', 'Ts']); // 全員ロイヤル
|
||||||
|
t.act(tk(t, btn), 'raise', 40);
|
||||||
|
t.act(tk(t, sb), 'call', 0);
|
||||||
|
t.act(tk(t, bb), 'call', 0);
|
||||||
|
checkDown(t);
|
||||||
|
assert.ok(h.finished);
|
||||||
|
assert.equal(t.seats[sb].stack, 1000);
|
||||||
|
assert.equal(t.seats[bb].stack, 1000);
|
||||||
|
assert.equal(t.seats[btn].stack, 1000);
|
||||||
|
assert.deepEqual(h.results[0].winners, [sb, bb, btn]);
|
||||||
|
assert.equal(h.results[0].handName, 'ロイヤルフラッシュ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: オールインのあとに退席しても手は最後まで生きている', () => {
|
||||||
|
const t = makeTable([1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [a, b] = h.order;
|
||||||
|
rig(h, [['As', 'Ah'], ['7c', '2d']], ['Ad', 'Kd', '9c', '5s', '3h']);
|
||||||
|
t.act(tk(t, h.toAct), 'raise', h.options(h.toAct).maxRaiseTo);
|
||||||
|
t.act(tk(t, h.toAct), 'call', 0);
|
||||||
|
assert.ok(h.runout);
|
||||||
|
assert.ok(t.leave(tk(t, a)));
|
||||||
|
assert.ok(h.runout && !h.finished);
|
||||||
|
const step = config.RUNOUT_STEP_MS;
|
||||||
|
for (let k = 0; k <= 4; k++) t.tick(k * step);
|
||||||
|
assert.ok(h.finished && h.showdown);
|
||||||
|
assert.deepEqual(h.results[0].winners, [a]);
|
||||||
|
assert.equal(t.seats[a].stack, 2000);
|
||||||
|
assert.equal(t.seats[b].stack, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードあり: ランダムに遊んでもチップの合計が変わらず、必ず終わる', () => {
|
||||||
|
let rnd = 777;
|
||||||
|
const rand = (n) => {
|
||||||
|
rnd = (rnd * 1103515245 + 12345) % 2147483648;
|
||||||
|
return rnd % n;
|
||||||
|
};
|
||||||
|
let showdowns = 0;
|
||||||
|
let runouts = 0;
|
||||||
|
for (let game = 0; game < 40; game++) {
|
||||||
|
const n = 2 + (game % 8);
|
||||||
|
const stacks = Array.from({ length: n }, () => 20 + rand(2000));
|
||||||
|
const t = makeTable(stacks, { mode: 'friends' });
|
||||||
|
const sum = stacks.reduce((a, b) => a + b, 0);
|
||||||
|
t.start(T(0), 0);
|
||||||
|
let now = 0;
|
||||||
|
let prev = null;
|
||||||
|
for (let step = 0; step < 20000 && t.hand; step++) {
|
||||||
|
const h = t.hand;
|
||||||
|
if (h !== prev) {
|
||||||
|
if (prev && prev.showdown) {
|
||||||
|
showdowns++;
|
||||||
|
assert.equal(prev.board.length, 5);
|
||||||
|
}
|
||||||
|
prev = h;
|
||||||
|
assert.equal(new Set([...deckOf(h), ...h.list.flatMap((p) => p.cards)]).size, 52);
|
||||||
|
}
|
||||||
|
if (!h.finished && h.toAct != null) {
|
||||||
|
const o = h.options(h.toAct);
|
||||||
|
const r = rand(10);
|
||||||
|
const me = tk(t, h.toAct);
|
||||||
|
let err;
|
||||||
|
if (r < 2) {
|
||||||
|
err = t.act(me, 'fold', 0);
|
||||||
|
if (err && o.canCheck) err = t.act(me, 'check', 0); // エンジンが断るフォールド
|
||||||
|
} else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
|
||||||
|
else err = t.act(me, 'raise', o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1));
|
||||||
|
assert.equal(err, null);
|
||||||
|
} 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);
|
||||||
|
const inPot = t.hand && !t.hand.finished ? t.hand.view().potTotal : 0;
|
||||||
|
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
|
||||||
|
if (t.hand && t.hand.number > 40) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(showdowns > 100 && runouts > 20, `showdowns=${showdowns} runouts=${runouts}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- CPU ----------
|
||||||
|
|
||||||
|
const ai = require('../server/ai');
|
||||||
|
|
||||||
|
// 再現できる乱数
|
||||||
|
function seeded(seed) {
|
||||||
|
let x = seed;
|
||||||
|
return () => {
|
||||||
|
x = (x * 1103515245 + 12345) % 2147483648;
|
||||||
|
return x / 2147483648;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('役の判定: 選んだ5枚は7枚全体と同じ強さ', () => {
|
||||||
|
const all = [];
|
||||||
|
for (const r of '23456789TJQKA') for (const su of 'shdc') all.push(r + su);
|
||||||
|
for (let i = 0; i < 3000; i++) {
|
||||||
|
for (let j = all.length - 1; j > 0; j--) {
|
||||||
|
const k = crypto.randomInt(j + 1);
|
||||||
|
[all[j], all[k]] = [all[k], all[j]];
|
||||||
|
}
|
||||||
|
const seven = all.slice(0, 7);
|
||||||
|
const best = bestHand(seven);
|
||||||
|
assert.equal(best.cards.length, 5);
|
||||||
|
assert.ok(best.cards.every((c) => seven.includes(c)));
|
||||||
|
assert.equal(compareScore(bestHand(best.cards).score, best.score), 0, `${seven}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU: 勝率の見積もり', () => {
|
||||||
|
const rng = seeded(42);
|
||||||
|
const near = (v, target) => assert.ok(Math.abs(v - target) < 0.03, `${v} ≉ ${target}`);
|
||||||
|
near(ai.equity(['As', 'Ah'], [], 1, 4000, rng), 0.85);
|
||||||
|
near(ai.equity(['7s', '2h'], [], 1, 4000, rng), 0.35);
|
||||||
|
// ナッツは負けない
|
||||||
|
assert.equal(ai.equity(['As', 'Ks'], ['Qs', 'Js', 'Ts'], 3, 500, rng), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU: 強い手はレイズし、弱い手は大きなベットに降りる', () => {
|
||||||
|
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [sb, bb, btn] = h.order;
|
||||||
|
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
|
||||||
|
const rng = seeded(1);
|
||||||
|
const d = ai.decide(h, btn, { rng });
|
||||||
|
assert.equal(d.type, 'raise');
|
||||||
|
t.act(tk(t, btn), 'raise', 1000); // オールイン
|
||||||
|
assert.equal(ai.decide(h, sb, { rng }).type, 'fold');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU: どんな状況でも正しいアクションを返す', () => {
|
||||||
|
const rng = seeded(7);
|
||||||
|
let actions = 0;
|
||||||
|
for (let game = 0; game < 12; game++) {
|
||||||
|
const n = 2 + (game % 8);
|
||||||
|
const stacks = Array.from({ length: n }, () => 20 + Math.floor(rng() * 2000));
|
||||||
|
const t = makeTable(stacks, { mode: 'friends' });
|
||||||
|
const sum = stacks.reduce((a, b) => a + b, 0);
|
||||||
|
t.start(T(0), 0);
|
||||||
|
let now = 0;
|
||||||
|
for (let step = 0; step < 5000 && t.hand && t.hand.number <= 15; step++) {
|
||||||
|
const h = t.hand;
|
||||||
|
if (!h.finished && h.toAct != null) {
|
||||||
|
const style = ai.randomStyle(rng);
|
||||||
|
const d = ai.decide(h, h.toAct, { style, rng, iterations: 60 });
|
||||||
|
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;
|
||||||
|
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(actions > 300, `actions=${actions}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU: どの思考(ライブラリ)も自分で正しいアクションを決める', () => {
|
||||||
|
const brains = ai.availableBrains();
|
||||||
|
// 少なくとも純粋な JavaScript のライブラリの思考は必ず使える
|
||||||
|
for (const b of ['montecarlo', 'range', 'madehand']) assert.ok(brains.includes(b), b);
|
||||||
|
for (const brain of brains) {
|
||||||
|
const rng = seeded(brain.length * 101);
|
||||||
|
let decisions = 0;
|
||||||
|
for (let game = 0; game < 6; game++) {
|
||||||
|
const n = 2 + (game % 5);
|
||||||
|
const stacks = Array.from({ length: n }, () => 100 + Math.floor(rng() * 1500));
|
||||||
|
const t = makeTable(stacks, { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
let now = 0;
|
||||||
|
for (let step = 0; step < 3000 && t.hand && t.hand.number <= 8; step++) {
|
||||||
|
const h = t.hand;
|
||||||
|
if (!h.finished && h.toAct != null) {
|
||||||
|
const style = ai.randomStyle(rng, brain);
|
||||||
|
assert.equal(style.brain, brain);
|
||||||
|
// 思考そのものがエラーにならず、何かを返す(バランス型に頼らない)
|
||||||
|
assert.ok(ai.BRAINS[brain].decide(h, h.toAct, { style, rng, iterations: 80 }), brain);
|
||||||
|
const d = ai.decide(h, h.toAct, { style, rng, iterations: 80 });
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(decisions > 60, `${brain} decisions=${decisions}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU: 思考の性格は弱い手で降り、強い手では降りない', () => {
|
||||||
|
for (const brain of ai.availableBrains()) {
|
||||||
|
const rng = seeded(3);
|
||||||
|
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const h = t.hand;
|
||||||
|
const [sb, bb, btn] = h.order;
|
||||||
|
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
|
||||||
|
const style = ai.randomStyle(rng, brain);
|
||||||
|
assert.notEqual(ai.decide(h, btn, { style, rng }).type, 'fold', brain); // AA
|
||||||
|
t.act(tk(t, btn), 'raise', 1000); // オールイン
|
||||||
|
assert.equal(ai.decide(h, sb, { style, rng }).type, 'fold', brain); // 72o
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU戦: CPU ごとに違う思考が割り当てられ、どれを使っているかは画面に出ない', () => {
|
||||||
|
const brains = ai.availableBrains();
|
||||||
|
assert.deepEqual(new Set(ai.makeStyles(brains.length).map((s) => s.brain)), new Set(brains));
|
||||||
|
const t = new Table({
|
||||||
|
id: 'c',
|
||||||
|
name: 'cpu',
|
||||||
|
startingStack: 1000,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
blindTimerEnabled: false,
|
||||||
|
blindIntervalMin: 10,
|
||||||
|
hostToken: T(0),
|
||||||
|
mode: 'cpu',
|
||||||
|
cpuCount: 8,
|
||||||
|
});
|
||||||
|
const used = new Set(t.seats.filter((s) => s && s.bot).map((s) => s.style.brain));
|
||||||
|
assert.equal(used.size, brains.length);
|
||||||
|
t.join(T(0), 'me');
|
||||||
|
t.start(T(0), 0);
|
||||||
|
const json = JSON.stringify([t.publicState(T(0), new Set()), t.summary()]);
|
||||||
|
for (const word of ['brain', 'style', ...brains, 'aggression', 'Threshold']) assert.ok(!json.includes(word), word);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動で行動する', () => {
|
||||||
|
const t = new Table({
|
||||||
|
id: 'c',
|
||||||
|
name: 'cpu',
|
||||||
|
startingStack: 1000,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
blindTimerEnabled: false,
|
||||||
|
blindIntervalMin: 10,
|
||||||
|
hostToken: T(0),
|
||||||
|
mode: 'cpu',
|
||||||
|
cpuCount: 3,
|
||||||
|
});
|
||||||
|
assert.equal(t.cardsEnabled, true);
|
||||||
|
assert.deepEqual(t.seats.map((s) => Boolean(s && s.bot)), [false, true, true, true, false, false, false, false, false]);
|
||||||
|
assert.equal(t.join(T(0), 'me'), 0);
|
||||||
|
assert.equal(t.isHost(T(0)), true);
|
||||||
|
const v = t.publicState(T(0), new Set([T(0)]));
|
||||||
|
assert.equal(v.mode, 'cpu');
|
||||||
|
assert.ok(v.seats[1].bot && v.seats[1].connected);
|
||||||
|
assert.equal(v.seats[0].bot, false);
|
||||||
|
|
||||||
|
assert.equal(t.start(T(0), 0), null);
|
||||||
|
// 人間の手番まで CPU が進める(人間の手番は代わりに動かない)
|
||||||
|
let now = 0;
|
||||||
|
for (let k = 0; k < 200 && t.hand.toAct !== 0 && !t.hand.finished; k++) t.tick((now += 5000));
|
||||||
|
if (!t.hand.finished) {
|
||||||
|
const before = t.hand.actions;
|
||||||
|
t.tick((now += 60000));
|
||||||
|
t.tick((now += 60000));
|
||||||
|
assert.equal(t.hand.toAct, 0);
|
||||||
|
assert.equal(t.hand.actions, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人間が離席すると CPU だけで次のハンドは始まらない
|
||||||
|
t.setSittingOut(T(0), true);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// チップがなくなった CPU は次のハンドでリバイ
|
||||||
|
t.seats[2].stack = 0;
|
||||||
|
for (let k = 0; k < 10 && !t.hand; k++) t.tick((now += 5000));
|
||||||
|
assert.ok(t.hand);
|
||||||
|
assert.equal(t.seats[2].buyIns, 2);
|
||||||
|
assert.ok(t.hand.players.has(2));
|
||||||
|
|
||||||
|
// ホストが退席しても CPU はホストにならない
|
||||||
|
t.leave(T(0));
|
||||||
|
assert.equal(t.hostToken, null);
|
||||||
|
t.join(T(5), 'friend');
|
||||||
|
assert.equal(t.isHost(T(5)), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CPU戦: 人間が全員退席したらテーブルを閉じる(ハンド中でも)', () => {
|
||||||
|
const opts = {
|
||||||
|
id: 'c',
|
||||||
|
name: 'cpu',
|
||||||
|
startingStack: 1000,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20,
|
||||||
|
blindTimerEnabled: false,
|
||||||
|
blindIntervalMin: 10,
|
||||||
|
hostToken: T(0),
|
||||||
|
};
|
||||||
|
const t = new Table({ ...opts, mode: 'cpu', cpuCount: 2 });
|
||||||
|
t.join(T(0), 'me');
|
||||||
|
t.join(T(5), 'friend');
|
||||||
|
t.start(T(0), 0);
|
||||||
|
t.leave(T(0));
|
||||||
|
assert.equal(t.shouldClose(), false); // まだ友達がいる
|
||||||
|
t.leave(T(5));
|
||||||
|
assert.equal(t.shouldClose(), true);
|
||||||
|
|
||||||
|
// CPU戦以外は閉じない(これまでどおり、誰も接続していない状態が続いたら削除)
|
||||||
|
const f = new Table({ ...opts, mode: 'friends' });
|
||||||
|
f.join(T(0), 'me');
|
||||||
|
f.leave(T(0));
|
||||||
|
assert.equal(f.shouldClose(), false);
|
||||||
|
});
|
||||||
@@ -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, /## 質問/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user