Compare commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:10 +09:00
agomizi1cmandClaude Opus 5.5 dd39c043c4 Add player stats, in-table ranking, host seating and review return
- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR,
  ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression
  frequency, hand win rate and net in BB, per table type, each with a
  bands-and-explanation sheet. Hands are counted per human player when
  they finish and stored as totals under data/stats/, keyed by a hash
  of the player ID (sent in a POST body, never in the URL).
- Ranking sheet at the table: everyone seated, ordered by stack or by
  net (stack minus buy-ins), updating live.
- The host can reorder seats by dragging (with a mini table preview and
  shuffle). Takes effect immediately between hands, otherwise at the
  start of the next hand; the dealer button stays with the same player.
- The full-session review opens in the same tab and its back button
  returns to the table it was opened from.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00
agomizi1cmandClaude Opus 5.5 3116b1da9f Give CPUs distinct personalities built on different poker libraries
Each CPU now thinks with one of four engines, each backed by a different
published library (pinned versions):

- montecarlo (@pokertools/evaluator): the existing balanced Monte Carlo
  equity player, moved to server/bots/montecarlo.js.
- calculator (poker-calculations): the library's decideAction over the
  table state; push/fold below 12BB via its jam breakeven equity.
  Skipped automatically if the native addon cannot load.
- range (poker-utils): tight player; preflop by hand-strength percentile,
  postflop equity vs an estimated opponent range, bluffs only when
  alpha/bluffEV say they pay.
- madehand (pokersolver): loose, sticky player that only looks at the
  made hand and draws.

server/ai.js assigns a mix of engines per table (makeStyles), falls back
to the Monte Carlo engine on errors, and legalizes every decision.
The engine and its parameters stay server-side; players cannot tell
which library a CPU uses. Tests cover every engine and the no-leak rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:51:15 +09:00
agomizi1cmandClaude Opus 5.5 d92953a270 Use @pokertools/engine and evaluator for poker rules and hand ranking
Replace the hand-rolled betting rules, side pots, showdown and hand
evaluator with the published @pokertools/engine and @pokertools/evaluator
libraries (MIT, pinned to 2.1.0).

- server/hand.js now wraps a PokerEngine per hand: it maps seats,
  mirrors stacks, staggers the all-in runout, and pays host-chosen
  winners on cash-only tables.
- server/cards.js and server/ai.js use the library evaluator.
- Out-of-turn folds go through the engine's stand; all-in hands stay
  live when the player leaves; uncalled bets return to a leaver.
- The engine refuses some check-able folds that would leave a side pot
  without eligible players; those now return a message (or check for
  a leaving player) instead of crashing.
- BB must be greater than SB (engine requirement); Node >= 24.
- Tests rig cards via engine snapshots and cover the new edge cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:25:15 +09:00
agomizi1cmandClaude Opus 5.5 02c8e76237 Close CPU tables as soon as every human player leaves
- When the last human presses leave on a CPU table (even mid-hand), the table
  is deleted right away instead of lingering with CPUs only; friends / cash-only
  tables keep the existing 30-minute idle cleanup
- Spectators still watching get a "テーブルが終了しました" notice, and the lobby
  updates immediately
- Don't show the "接続が切れました" banner when the page disconnects on purpose
  (closed or missing table)
- Test for the close condition; README notes the behavior

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:03:58 +09:00
agomizi1cmandClaude Opus 5.5 0d3a054f0d Add CPU opponents and a table type selector (cash only / friends / CPU)
- Create screen: choose キャッシュオンリー (real cards, chips only), 友人戦
  (server deals and settles) or CPU戦 (友人戦 plus 1–8 CPU players); replaces
  the card toggle. The lobby shows each table's type
- Poker AI written from scratch (server/ai.js, no libraries): fast 7-card
  evaluator (checked against bestHand), Monte Carlo equity, and decisions from
  hand rank, pot odds, raise count, opponents and a per-CPU style (aggression,
  bluff rate); continuation bets via the last aggressor. It never sees other
  players' hole cards
- CPU seats: think time of 0.7–1.8s (faster when no human is left in the hand),
  automatic rebuy, never the host, no new hand unless a human is playing,
  re-think after pause
- Play UI: robot avatars and a "考えています…" status for CPU turns
- Tests for the evaluator, equity, legality of AI actions and CPU tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:55:25 +09:00
agomizi1cmandClaude Opus 5.5 66907345ff Add optional card mode: server deals cards and settles showdowns automatically
- New "カードを配る" switch on the create-table screen (default off); the lobby
  shows "カードあり" for tables that use it
- Card mode (server/cards.js restored): crypto-shuffled deck kept server-side,
  hole cards sent only to their owner until showdown/all-in reveal, board dealt
  per street with burns, all-in runout on a timer (frozen while paused)
- Showdown is evaluated and every pot (incl. side pots, split pots) is paid out
  automatically; host winner selection is only used when card mode is off
- Play UI: board, hole cards, own cards in the action bar, hand names, winning
  cards highlighted; layout tuned for 7+ seats and short screens
- Playing-card styles use .pcard so they don't clash with the existing .card panel
- Tests for hand evaluation, card privacy, runout, side pots, ties and random play

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:44:22 +09:00
agomizi1cmandClaude Opus 5.5 6d33440048 Only the table host splits the pot; host buttons beside the table name
- Showdown winners can now be chosen only by the table host (others see
  "ホストが勝者を選んでいます"); if the host leaves, the next host does it
- START / pause moved into the header to the right of the table name
  (pause is an icon button), freeing the row they used to occupy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:15:19 +09:00
agomizi1cmandClaude Opus 5.5 e89d867f85 Add admin role with an admin-only hand range study page
- Optional ADMIN_KEY (6 digits, distinct from ACCESS_KEY): entering it on the
  key screen signs in as admin (role-specific HMAC cookie; member cookies
  stay valid). Logged-in members can open /key?admin to enter it.
- /range (range chart + color/category quiz, ported from poker-hand and
  restyled to the site design) and its files under admin/ are served only to
  admins; everyone else gets 404. Card images cached privately for a day.
- Lobby shows a link to the page for admins only (GET /api/me).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:13:14 +09:00
agomizi1cmandClaude Opus 5.5 1e1ac4e91d Add favicon
Link /assets/favicon.ico from every page and serve /favicon.ico without
requiring the access key (browsers request it on their own).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:04:38 +09:00
agomizi1cmandClaude Opus 5.5 e22043f9db Lock scrolling on the play screen (incl. iOS Safari)
- Fix body to the viewport (position: fixed) and fit the whole table screen
  to the screen height; overflow: hidden alone does not stop iOS scrolling
- Cancel one-finger touchmove to stop iOS rubber-band scrolling
  (range slider and an overflowing action bar are exempt; pinch zoom kept)
- touch-action: manipulation to stop double-tap zoom
- Compact layout for short screens (iPhone SE size) and 7+ players

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:59:41 +09:00
105 changed files with 13163 additions and 1008 deletions

No files matched your search

+7
View File
@@ -6,6 +6,13 @@
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 4671
},
{
"name": "poker-test",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"env": { "ACCESS_KEY": "000000", "ADMIN_KEY": "000001", "PORT": "4672" },
"port": 4672
}
]
}
+2
View File
@@ -1,5 +1,7 @@
# .env にコピーして値を設定する(chmod 600 推奨)
ACCESS_KEY=000000
# 管理者キー(任意・ACCESS_KEY と別の6桁)。入力すると管理者として入室し、ハンドレンジ学習ページが見える
# ADMIN_KEY=000001
PORT=4671
# HOST=127.0.0.1
# 本番のみ設定(ローカル開発では設定しない)
+75 -10
View File
@@ -2,23 +2,43 @@
友達専用のテキサスホールデム(リングゲーム)サイト。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)が出るので、それに合わせて実物のカードを配る。
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。BB は SB より大きくする。ヘッズアップはボタンが SB。
- 各自が自分のスマホで 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分続いたら削除)。
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る)。
- ホストの一時停止中は、アクション・勝者の確定・ブラインドタイマー・次のハンドの開始がすべて止まる。
- テーブル上部のトロフィーのボタンで、着席している全員のランキングを開ける。「スタック」(持っているチップ。ハンド中は、まだ降りていない人がそのハンドで出した額も含める)と「収支」(持っているチップ − バイインの合計。リバイを含む)を切り替えられる。同じ値は同じ順位。
- ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。
- チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。
- ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。
## ローカルで動かす
@@ -37,11 +57,48 @@ http://localhost:4671 を開く。
| 変数 | 既定値 | 内容 |
| --- | --- | --- |
| `ACCESS_KEY` | なし(必須) | 6桁の参加キー |
| `ADMIN_KEY` | なし(任意) | 6桁の管理者キー(`ACCESS_KEY` と別の値)。入力すると管理者として入室する |
| `PORT` | `4671` | 待ち受けポート |
| `HOST` | `127.0.0.1` | 待ち受けアドレス。既定では外部から直接つながらない(nginx 経由で公開する) |
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 |
## 統計
ロビーの「統計」(`/stats`)で、自分の成績を見られる。ハンドが終わるたびに、参加した人間のプレイヤーごと(ブラウザのプレイヤーIDごと。CPU は数えない)に自動で記録する。
- **まとめ**: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブのレーダーと、TAG・LAG・ロック・フィッシュのどれか)、VPIP・PFR・AFq・プレイ時間。VPIP 25% 以上をルーズ、PFR が VPIP の半分以上をアグレッシブとして判定する。レーダーを押すと4つのスタイルの説明を横に送って見られる。
- **すべての統計**: 合計・キャッシュ(キャッシュオンリー)・友人戦・CPU戦(レビュー付きCPU戦を含む)ごとに、VPIP・PFR・ATS(全員が降りて CO・BTN・SB に回ってきたときのレイズ率)・3BET・WTSD・W$SD・WWSF・ストリートごとのフォールド率・アグレッション頻度(全部の行動のうちベット・レイズの割合)・ハンドの勝率・収支(BB と 100ハンドあたり)。各数字の「?」で段階(ニット〜フィッシュなど)と説明を開き、「友達に共有」で文章を共有・コピーできる。
- 離席・退席で自動で行った行動は本人の判断として数えない。VPIP・PFR の分母は、プリフロップで判断したハンド(全員が降りて BB に回ってきたハンドなどは除く)。
- 保存先は `data/stats/`(git 管理外)。ファイル名はプレイヤーIDの SHA-256 で、中身は回数の合計だけ(ハンドの内容は残さない)。プレイヤーIDは URL に載せないよう POST の本文で送る。
## 管理者(ハンドレンジ学習・レビュー付きCPU戦)
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ。レンジ表には CO のオープンを BTN で受けたときの対応も枠で示す。3bet は水色、コールは黄)と「CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーのテーブルの行を左へスワイプすると「削除」が出て(キーボードでは Tab で削除ボタンに移ると開く)、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
### レビュー付きCPU戦
ハンドが終わるたびにレビューが開き、「次のハンドへ」を押すまで次のハンドは始まらない。
- **ハンドレンジ**: 配られた手が登録してあるハンドレンジ(Poker Skill の 6-max オープンレンジ。`admin/range-data.js`。レンジ学習ページと共用)の何色か、そのとおりに行動できたかを判定する。
- 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)の色以上ならレイズ、それより弱ければフォールド。6-max の席に当てはめて、5人以上は UTG の赤(紺も可)、4人は HJ の黄、3人は CO の緑、2人は BTN の白、1人は SB の青。SB のレンジは BTN より狭いので、青は白の一つ上の段階として扱う。
- 前にコール・レイズした人がいる: その人のレンジを、その人の後ろの人数に対応する色と推定し、自分の手がその一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド。紺はどんな相手にもコールまではできる。
- 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ。
- BTN で CO のオープン(ほかに参加者なし)に直面: レンジ表の枠のとおり。水色枠は 3bet、黄枠はコール、枠なしはフォールド。
- BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。緑以上でコール(CO のオープンには青、BTN・SB には白以上。相手の色の一つ下まで)、相手の三つ上ならレイズ。
- 2人目以降に参加した人のレンジも同じ決まりでコール・レイズできる一番弱い色と推定する(ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。
- チェックできるときのフォールド・コールの推奨はチェックになる。判定は ◎(100点)・△(消極的 50点/攻めすぎ 40点)・✕(参加しすぎ・降りすぎ 0点)。
- **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。
- **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。
- **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。
- **AIに質問する用にコピー**: テーブルのレビューとリプレイ画面のボタンで、そのハンドの状況(ゲームの条件・席とスタック・自分の手札・ストリートごとの場札とポット・全員のアクション・ショーダウンで公開された手札・結果・サイトの判定・質問文)を、ChatGPT や Claude などに貼り付けてそのまま質問できる文章にしてコピーする。判断の時点で見えていた情報だけを書き、公開されなかった相手の手札は含めない(`server/review/prompt.js`)。自動でコピーできないブラウザでは文章を表示するので手でコピーする。
- レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。
- テーブルのレビューの「対局全体のレビュー」は同じタブで開き、左上の「テーブル」でそのテーブルに戻れる(リプレイを開いても引き継ぐ。URL の `t=テーブルID`)。
- 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。
- 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。
- 管理者キーを変えると、管理者の Cookie だけが無効になる。
## セキュリティ
- **参加キー**: 正しいキーを入れたブラウザに HttpOnly / SameSite=Lax(HTTPS 時は Secure)の Cookie を保存。アクセスのたびに有効期限(400日)が延長されるので、以降は入力不要。
@@ -62,15 +119,23 @@ http://localhost:4671 を開く。
| `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO |
| `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック |
| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) |
| `server/hand.js` | 1ハンドのチップの動き(ブラインド・ベッティング・サイドポット・勝者への分配) |
| `test/` | チップ処理のテスト(`npm test`) |
| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う |
| `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) |
| `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し |
| `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `prompt` AI に質問する文章 / `store` 保存) |
| `server/stats/*.js` | プレイヤーごとの統計(`hand` 1ハンドから数える / `store` 保存 / `summary` 割合にまとめる) |
| `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) |
| `test/` | チップ処理・役の判定・CPU・レビュー・統計のテスト(`npm test`) |
| `server/config.js` | `.env` の読み込みと各種上限値 |
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
| `public/*.html`, `public/assets/*.js` | 各画面 |
| `public/assets/style.css`, `public/assets/fluid.js` | 見た目の土台(light / dark の色・半透明の素材・字の大きさ)と、動きの共通部品(ばね・下から出るシート・滑るセグメント・指の速度と勢いの先読み) |
| `admin/` | 管理者専用ページ(ハンドレンジ学習・CPU戦のレビュー・登録してあるハンドレンジ・カード画像) |
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
- 作成者がホスト(START・一時停止ができる)。ホストが退席すると次の着席者に移る。
- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
- ホストはテーブル名の右の「席順を変更」(↑↓のボタン、3人以上のとき)で、着席者の並びを変えられる。自分を一番上に固定し、その下に左隣から時計回りの順で並び、行の右端のつまみをドラッグ(キーボードでは ↑↓)して並べ替える。小さなテーブルの図で仕上がりを確かめられ、「シャッフル」でランダムにもできる。ハンドの途中(結果の表示中を含む)に決めた席順は次のハンドの始めに反映し(ボタンに印が付く)、決めた後に座った人は並びの最後に入る。ディーラーボタンは同じ人に付いたまま、次のハンドで新しい並びの次の人へ回る。
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+96
View File
@@ -0,0 +1,96 @@
// 登録してあるハンドレンジ(ハンドレンジ学習ページとレビュー付きCPU戦で共用・管理者専用)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.HandRange = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
// 6-max のオープンレンジ(Poker Skill のプリフロップレンジ表、スタック約100BB・全員が降りて回ってきた場面)。
// 強い順。tier が大きいほど強い(レビューでは「一つ上」「二つ上」をこの段階で数える)。
// 各ポジションのレンジは UTG ⊂ HJ ⊂ CO ⊂ SB ⊂ BTN と入れ子になっているので、手ごとに
// 「オープンできる一番狭いポジション」の色を付ける。SB は BTN より狭いので BTN の一つ上に置く。
// 紺は UTG のうち特に強い手
const CATEGORIES = [
{ id: 'navy', name: '紺', label: 'UTG(強)', tier: 6 },
{ id: 'red', name: '赤', label: 'UTG', tier: 5 },
{ id: 'yellow', name: '黄', label: 'HJ', tier: 4 },
{ id: 'green', name: '緑', label: 'CO', tier: 3 },
{ id: 'blue', name: '青', label: 'SB', tier: 2 },
{ id: 'white', name: '白', label: 'BTN', tier: 1 },
{ id: 'gray', name: 'グレー', label: 'フォールド', tier: 0 },
];
// 全169ハンドの分類(載っていないハンドはフォールド)
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'yellow', A8s: 'yellow', A7s: 'yellow', A6s: 'yellow', A5s: 'red', A4s: 'red', A3s: 'red', A2s: 'red',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'red', KTs: 'red', K9s: 'yellow', K8s: 'green', K7s: 'green', K6s: 'green', K5s: 'green', K4s: 'blue', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'red', QQ: 'navy', QJs: 'red', QTs: 'red', Q9s: 'yellow', Q8s: 'green', Q7s: 'blue', Q6s: 'blue', Q5s: 'white', Q4s: 'white',
AJo: 'red', KJo: 'yellow', QJo: 'green', JJ: 'red', JTs: 'red', J9s: 'yellow', J8s: 'green', J7s: 'blue', J6s: 'white',
ATo: 'yellow', KTo: 'green', QTo: 'blue', JTo: 'blue', TT: 'red', T9s: 'red', T8s: 'yellow', T7s: 'blue', T6s: 'white',
A9o: 'green', K9o: 'blue', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'red', '97s': 'green', '96s': 'blue', '95s': 'white',
A8o: 'green', K8o: 'white', '98o': 'white', 88: 'red', '87s': 'yellow', '86s': 'blue', '85s': 'white',
A7o: 'blue', 77: 'red', '76s': 'green', '75s': 'white',
A6o: 'blue', 66: 'red', '65s': 'green', '64s': 'white',
A5o: 'blue', 55: 'red', '54s': 'green',
A4o: 'white', 44: 'red',
A3o: 'white', 33: 'red',
A2o: 'white', 22: 'red',
};
// レイズを受けたときの対応(同じレンジ表の「CO のオープンに対する BTN の対応」)。レンジ表では枠で示す。
// 載っていないハンドはフォールド
const FACING_ACTIONS = [
{ id: '3bet', name: '水色枠', label: '3bet' },
{ id: 'call', name: '黄枠', label: 'コール' },
];
const FACING = {
AA: '3bet', KK: '3bet', QQ: '3bet', JJ: '3bet', AKs: '3bet', AKo: '3bet', A5s: '3bet', A4s: '3bet', A3s: '3bet', A2s: '3bet',
KQs: '3bet', KJs: '3bet', '76s': '3bet', '65s': '3bet', '54s': '3bet',
TT: 'call', 99: 'call', 88: 'call', 77: 'call', 66: 'call', 55: 'call', 44: 'call', 33: 'call', 22: 'call',
AQs: 'call', AJs: 'call', ATs: 'call', A9s: 'call', A8s: 'call', A7s: 'call', A6s: 'call', KTs: 'call', K9s: 'call',
QJs: 'call', QTs: 'call', Q9s: 'call', JTs: 'call', J9s: 'call', T9s: 'call', T8s: 'call', '98s': 'call', '87s': 'call',
AQo: 'call', AJo: 'call', KQo: 'call', KJo: 'call', QJo: 'call',
};
const BY_ID = new Map(CATEGORIES.map((c) => [c.id, c]));
// 13×13 の表の (行, 列) のハンド。右上がスーテッド、左下がオフスート
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const category = (id) => BY_ID.get(id) || BY_ID.get('gray');
const labelOf = (id) => category(id).label;
const tierOf = (hand) => category(categoryOf(hand)).tier;
const categoryOfTier = (tier) => CATEGORIES.find((c) => c.tier === tier) || BY_ID.get('gray');
const facingOf = (hand) => FACING[hand] || null; // '3bet' / 'call' / null(フォールド)
const facingAction = (id) => FACING_ACTIONS.find((a) => a.id === id) || null;
// ["As", "Kd"] → "AKo"
function handKey(cards) {
const [a, b] = cards;
const ia = RANKS.indexOf(a[0]);
const ib = RANKS.indexOf(b[0]);
const hi = ia <= ib ? a : b;
const lo = ia <= ib ? b : a;
if (hi[0] === lo[0]) return hi[0] + lo[0];
return hi[0] + lo[0] + (hi[1] === lo[1] ? 's' : 'o');
}
// 自分より前に誰もコール・レイズしていないとき、後ろの人数(自分を含めず、まだ行動していない人)で
// 参加してよい一番弱い段階。6-max の席に当てはめて、5人以上は UTG の赤(紺は赤の一つ上)、4人は HJ の黄、
// 3人は CO の緑、2人は BTN の白、1人は SB の青。0人(全員降りてBBに回った)は無し
function requiredTier(behind) {
if (behind >= 5) return 5;
if (behind === 4) return 4;
if (behind === 3) return 3;
if (behind === 2) return 1;
if (behind === 1) return 2;
return 0;
}
return {
RANKS, CATEGORIES, RANGE, FACING_ACTIONS, FACING,
handAt, handKey, categoryOf, category, labelOf, tierOf, categoryOfTier, requiredTier, facingOf, facingAction,
};
});
+213
View File
@@ -0,0 +1,213 @@
/* ハンドレンジ学習(管理者専用)。基本のデザインとセグメントは /assets/style.css を使う */
.range-tabs {
margin: 0 0 16px;
}
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
.cat-navy { --c: #20365d; --fg: #fff; }
.cat-red { --c: #9e342e; --fg: #fff; }
.cat-yellow { --c: #ddb356; --fg: #14191f; }
.cat-green { --c: #4e8051; --fg: #101910; }
.cat-blue { --c: #6aa5c9; --fg: #14191f; }
.cat-white { --c: #f7f7f7; --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 の表 */
.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 {
position: relative;
min-width: 0;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 4px;
background: var(--c);
color: var(--fg);
font-size: clamp(8px, 2.4vw, 12px);
font-weight: 700;
letter-spacing: -0.04em;
cursor: pointer;
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-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin: 12px 8px 0;
color: var(--label-2);
font-size: 0.75rem;
}
.range-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.swatch-dot {
width: 14px;
height: 14px;
flex: none;
border-radius: 4px;
background: var(--c);
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2);
}
.swatch-dot.swatch-frame {
background: transparent;
box-shadow: inset 0 0 0 3px var(--frame);
}
/* 選択したハンド */
.selected-card {
margin-top: 16px;
}
.sel-hand {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
}
.sel-label {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-weight: 600;
}
.selected-card .hint {
margin-top: 6px;
}
/* クイズ */
.quiz-progress {
height: 6px;
margin: 18px 0 8px;
overflow: hidden;
border-radius: 999px;
background: var(--fill-2);
}
.quiz-progress i {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--tint);
transition: width 0.5s var(--ease-spring);
}
.quiz-stats {
display: flex;
justify-content: center;
gap: 14px;
color: var(--label-2);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.quiz-hand {
margin: 14px 0;
padding: 20px 12px;
text-align: center;
}
.quiz-cards {
display: flex;
justify-content: center;
gap: 10px;
}
.quiz-cards img {
width: min(32%, 120px);
height: auto;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3));
animation: card-in 0.55s var(--ease-spring) both;
}
.quiz-cards img + img {
animation-delay: 70ms;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(-14px) scale(0.92) rotate(-4deg);
}
}
.quiz-hand-name {
margin-top: 10px;
font-size: 2.5rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.025em;
}
.quiz-answers {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.quiz-answers .btn {
justify-content: flex-start;
padding: 12px 12px;
background: var(--bg-2);
color: var(--label);
font-size: 0.9375rem;
box-shadow: inset 0 0 0 0 transparent;
transition: transform 0.45s var(--ease-spring), box-shadow 0.25s ease, background-color 0.25s ease;
}
.quiz-answers .btn:disabled {
opacity: 1;
}
.quiz-answers .btn.correct {
background: var(--tint-soft);
box-shadow: inset 0 0 0 2px var(--tint);
}
.quiz-answers .btn.wrong {
background: rgba(255, 59, 48, 0.12);
box-shadow: inset 0 0 0 2px var(--red);
}
.quiz-feedback {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
animation: fade-up 0.45s var(--ease-spring) both;
}
.quiz-feedback.good {
box-shadow: inset 0 0 0 2px var(--tint);
}
.quiz-feedback.bad {
box-shadow: inset 0 0 0 2px var(--red);
}
@media (prefers-reduced-motion: reduce) {
.quiz-cards img,
.quiz-feedback {
animation: fade-in 0.2s ease both;
}
.range-cell.selected {
transform: none;
}
}
+84
View File
@@ -0,0 +1,84 @@
<!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">
<link rel="stylesheet" href="/range/files/range.css">
</head>
<body class="page-range">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/" aria-label="ロビーへ戻る"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap range-wrap">
<h1 class="large-title" id="large-title">ハンドレンジ</h1>
<div class="segmented range-tabs" id="tabs">
<button type="button" class="seg-btn active" data-view="range">レンジ表</button>
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
</div>
<!-- レンジ表 -->
<section id="view-range">
<div class="matrix-card">
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
</div>
<div class="range-legend" id="legend"></div>
<div class="card selected-card" id="selected">
<div class="sel-hand">—</div>
<div class="hint">セルをタップすると分類を確認できます</div>
</div>
<button type="button" class="btn btn-tint btn-lg btn-block mt-12" id="start-from-range">クイズを始める</button>
</section>
<!-- クイズ -->
<section id="view-quiz" hidden>
<div class="label">問題数(全169ハンドからランダム)</div>
<div class="segmented" id="count">
<button type="button" class="seg-btn active" data-count="10">10問</button>
<button type="button" class="seg-btn" data-count="20">20問</button>
<button type="button" class="seg-btn" data-count="0">全問</button>
</div>
<div class="quiz-progress"><i id="progress-bar"></i></div>
<div class="quiz-stats">
<span id="q-count">開始前</span>
<span id="q-score">正解 0 / 0</span>
<span id="q-accuracy">正答率 --</span>
</div>
<div class="card quiz-hand">
<div class="quiz-cards">
<img id="card-1" alt="" hidden>
<img id="card-2" alt="" hidden>
</div>
<div class="quiz-hand-name" id="q-hand">?</div>
<div class="hint" id="q-prompt">「開始」を押してください</div>
</div>
<div class="quiz-answers" id="answers"></div>
<div class="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
<div class="row mt-12">
<button type="button" class="btn btn-lg" id="start-quiz">開始</button>
<button type="button" class="btn btn-tint btn-lg" id="next-question" disabled>次の問題</button>
</div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/range/files/range-data.js"></script>
<script src="/range/files/range.js"></script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
// ハンドレンジ学習(管理者専用): レンジ表と色・分類クイズ
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const { RANKS, CATEGORIES, FACING_ACTIONS, handAt, categoryOf, labelOf, facingOf, facingAction } = window.HandRange;
Fluid.navBar($('nav'), $('large-title'));
const tabsSeg = Fluid.Segmented($('tabs'));
Fluid.Segmented($('count'));
function swatch(id) {
const s = document.createElement('span');
s.className = `swatch-dot cat-${id}`;
return s;
}
// レイズを受けたときの対応の枠(3bet は水色、コールは黄)
function frameSwatch(id) {
const s = document.createElement('span');
s.className = `swatch-dot swatch-frame facing-${id}`;
return s;
}
// ---------- タブ ----------
function showView(name) {
$('view-range').hidden = name !== 'range';
$('view-quiz').hidden = name !== 'quiz';
for (const b of $('tabs').querySelectorAll('[data-view]')) b.classList.toggle('active', b.dataset.view === name);
tabsSeg.update();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-view]');
if (b) showView(b.dataset.view);
});
// ---------- レンジ表 ----------
for (const c of CATEGORIES) {
const item = document.createElement('span');
item.append(swatch(c.id), c.label);
$('legend').append(item);
}
for (const a of FACING_ACTIONS) {
const item = document.createElement('span');
item.append(frameSwatch(a.id), `レイズを受けたら${a.label}`);
$('legend').append(item);
}
for (let r = 0; r < 13; r++) {
for (let c = 0; c < 13; c++) {
const hand = handAt(r, c);
const cat = categoryOf(hand);
const b = document.createElement('button');
b.type = 'button';
const facing = facingOf(hand);
b.className = `range-cell cat-${cat}${facing ? ` facing-${facing}` : ''}`;
b.textContent = hand;
b.title = `${hand}:${labelOf(cat)}${facing ? `/レイズを受けたら${facingAction(facing).label}` : ''}`;
b.addEventListener('click', () => select(b, hand, cat));
$('matrix').append(b);
}
}
function select(cell, hand, cat) {
document.querySelectorAll('.range-cell.selected').forEach((x) => x.classList.remove('selected'));
cell.classList.add('selected');
const box = $('selected');
box.replaceChildren();
const name = document.createElement('div');
name.className = 'sel-hand';
name.textContent = hand;
const label = document.createElement('div');
label.className = 'sel-label';
label.append(swatch(cat), labelOf(cat));
const facing = facingOf(hand);
const vs = document.createElement('div');
vs.className = 'sel-label';
if (facing) vs.append(frameSwatch(facing), `レイズを受けたら${facingAction(facing).label}`);
else vs.append('レイズを受けたらフォールド');
const note = document.createElement('div');
note.className = 'hint';
note.textContent =
(cat === 'gray' ? 'どのポジションからもオープンしない手です。' : `${labelOf(cat)} からオープンできる一番狭い分類です。`) +
'枠は CO のオープンを BTN で受けたときの対応です。';
box.append(name, label, vs, note);
}
$('start-from-range').addEventListener('click', () => {
showView('quiz');
startQuiz();
});
// ---------- クイズ ----------
const SUITS = ['hearts', 'clubs', 'diamonds', 'spades'];
const RANK_FILE = { A: 'ace', K: 'king', Q: 'queen', J: 'jack', T: '10' };
let quizCount = 10;
let deck = [];
let index = 0;
let correct = 0;
let answered = 0;
let locked = false;
$('count').addEventListener('click', (e) => {
const b = e.target.closest('[data-count]');
if (!b) return;
quizCount = Number(b.dataset.count);
for (const x of $('count').querySelectorAll('[data-count]')) x.classList.toggle('active', x === b);
});
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function startQuiz() {
const pool = [];
for (let r = 0; r < 13; r++) for (let c = 0; c < 13; c++) pool.push(handAt(r, c));
deck = shuffle(pool);
if (quizCount > 0) deck = deck.slice(0, quizCount);
index = 0;
correct = 0;
answered = 0;
$('start-quiz').textContent = '最初から';
renderQuestion();
}
// スーテッドは同じマーク、それ以外は別々のマーク
function showCards(hand) {
const s1 = SUITS[Math.floor(Math.random() * 4)];
let s2 = s1;
if (hand[2] !== 's') while (s2 === s1) s2 = SUITS[Math.floor(Math.random() * 4)];
[hand[0], hand[1]].forEach((rank, i) => {
const img = $(`card-${i + 1}`);
const suit = i === 0 ? s1 : s2;
img.hidden = true;
img.onload = () => { img.hidden = false; };
img.onerror = () => { img.hidden = true; };
img.alt = `${RANK_FILE[rank] || rank} of ${suit}`;
img.src = `/range/files/cards/${RANK_FILE[rank] || rank}_of_${suit}.png`;
});
}
function updateStats() {
$('q-score').textContent = `正解 ${correct} / ${answered}`;
$('q-accuracy').textContent = `正答率 ${answered ? Math.round((correct / answered) * 100) + '%' : '--'}`;
}
function renderQuestion() {
locked = false;
$('answers').replaceChildren();
$('feedback').hidden = true;
$('next-question').disabled = true;
if (index >= deck.length) return finishQuiz();
const hand = deck[index];
$('q-hand').textContent = hand;
$('q-prompt').textContent = 'このハンドの色・分類は?';
$('q-count').textContent = `問題 ${index + 1} / ${deck.length}`;
$('progress-bar').style.width = `${(index / deck.length) * 100}%`;
updateStats();
showCards(hand);
for (const cat of CATEGORIES) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.dataset.cat = cat.id;
b.append(swatch(cat.id), cat.label);
b.addEventListener('click', () => answer(cat.id, b));
$('answers').append(b);
}
}
function answer(choice, button) {
if (locked) return;
locked = true;
const hand = deck[index];
const right = categoryOf(hand);
const ok = choice === right;
if (ok) correct++;
answered++;
for (const b of $('answers').children) {
if (b.dataset.cat === right) b.classList.add('correct');
else if (b === button) b.classList.add('wrong');
b.disabled = true;
}
const fb = $('feedback');
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
fb.hidden = false;
// 間違えたら選んだボタンが揺れる(振動と同じ瞬間に)
if (!ok) Fluid.shake(button);
Fluid.haptic(ok ? 10 : [18, 50, 18]);
updateStats();
$('next-question').disabled = false;
}
function finishQuiz() {
$('q-hand').textContent = '終了!';
$('q-prompt').textContent = `${deck.length}問中 ${correct}問正解(${deck.length ? Math.round((correct / deck.length) * 100) : 0}%)`;
$('q-count').textContent = '完了';
$('progress-bar').style.width = '100%';
$('card-1').hidden = $('card-2').hidden = true;
const fb = $('feedback');
fb.className = 'quiz-feedback';
fb.textContent = '「最初から」で再挑戦できます。';
fb.hidden = false;
}
$('start-quiz').addEventListener('click', startQuiz);
$('next-question').addEventListener('click', () => {
index++;
renderQuestion();
});
})();
+465
View File
@@ -0,0 +1,465 @@
/* CPU戦のレビュー(管理者専用)。基本のデザイン・セグメント・リストは /assets/style.css、
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
.score-great { color: var(--green); }
.score-good { color: #64b96a; }
.score-warn { color: var(--orange); }
.score-bad { color: var(--red); }
.score-none { color: var(--label-2); }
/* 対局の一覧 */
.rv-sessions {
margin-bottom: 28px;
}
.rv-session {
-webkit-appearance: none;
appearance: none;
width: 100%;
border: 0;
text-align: left;
}
.rv-session-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
/* 概要とタイル(ウィジェットのような小さな面) */
.rv-overview {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.rv-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.rv-tile {
display: grid;
gap: 2px;
min-width: 0;
padding: 12px 14px;
border-radius: 16px;
background: var(--bg-2);
}
.rv-tile span {
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.rv-tile b {
font-family: var(--font-num);
font-size: 1.5rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.rv-tile small {
color: var(--label-2);
font-size: 0.6875rem;
line-height: 1.45;
}
.rv-tabs {
margin-bottom: 14px;
}
.rv-tabs .seg-btn {
padding: 4px 2px;
font-size: 0.8125rem;
}
#tab-body > .hint,
.review-wrap p.hint {
margin: 10px 4px;
}
/* 表 */
.rv-table-wrap {
margin-top: 8px;
overflow-x: auto;
border-radius: 14px;
background: var(--bg-2);
}
.rv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.rv-table th,
.rv-table td {
padding: 9px 8px;
border-bottom: 0.5px solid var(--separator);
text-align: left;
white-space: nowrap;
}
.rv-table th {
color: var(--label-2);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.rv-table .num {
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.rv-table tbody tr[data-hand] {
cursor: pointer;
transition: background-color 0.35s ease;
}
.rv-table tbody tr[data-hand]:active {
background: var(--fill-3);
transition: none;
}
.rv-table tbody tr[data-hand]:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
@media (hover: hover) {
.rv-table tbody tr[data-hand]:hover {
background: var(--fill-3);
}
}
.rv-table tfoot td {
border-bottom: 0;
font-weight: 700;
}
.rv-hand-cell b {
margin-right: 2px;
}
.rv-table .rv-color {
padding: 1px 6px;
font-size: 0.6875rem;
}
.rv-leaks {
display: grid;
gap: 6px;
margin: 0;
padding: 12px 14px 12px 32px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
}
.rv-leaks b {
color: var(--orange);
}
/* 見直したいハンド */
.rv-worst {
overflow: hidden;
border-radius: 14px;
background: var(--bg-2);
}
.rv-worst-item {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
display: grid;
grid-template-columns: 32px 1fr;
gap: 2px 10px;
align-items: center;
padding: 11px 14px;
border: 0;
background: transparent;
color: var(--label);
text-align: left;
cursor: pointer;
transition: background-color 0.35s ease;
}
.rv-worst-item + .rv-worst-item::before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 56px;
border-top: 0.5px solid var(--separator);
}
.rv-worst-item:active {
background: var(--fill-3);
transition: none;
}
.rv-rank {
grid-row: span 2;
color: var(--orange);
font-family: var(--font-num);
font-size: 1.375rem;
font-weight: 700;
text-align: center;
}
.rv-worst-meta {
color: var(--label-2);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.rv-worst-item .rv-color {
width: 14px;
height: 14px;
padding: 0;
vertical-align: -2px;
}
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
.rv-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin: 0 4px 8px;
font-size: 0.8125rem;
}
.sw {
display: inline-block;
width: 16px;
height: 0;
margin-right: 6px;
border-top: 2px solid;
vertical-align: middle;
}
.sw.actual {
border-color: var(--orange);
}
.sw.ev {
border-color: var(--blue);
border-top-style: dashed;
}
.rv-chart {
position: relative;
padding: 10px;
border-radius: 16px;
background: var(--bg-2);
}
.rv-chart svg {
display: block;
width: 100%;
height: auto;
touch-action: pan-y;
}
.rv-chart .gl {
stroke: var(--separator);
stroke-width: 0.5;
}
.rv-chart .zero {
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 2 3;
}
.rv-chart .tick {
fill: var(--label-2);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rv-chart .line {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.rv-chart .line.actual {
stroke: var(--orange);
}
.rv-chart .line.ev {
stroke: var(--blue);
stroke-dasharray: 6 4;
}
.rv-chart .cross {
stroke: var(--label-3);
stroke-width: 1;
}
.rv-chart .dot {
stroke: var(--bg-2);
stroke-width: 2;
}
.rv-chart .dot.actual {
fill: var(--orange);
}
.rv-chart .dot.ev {
fill: var(--blue);
}
.rv-chart .hit {
fill: transparent;
cursor: crosshair;
}
.rv-tip {
position: absolute;
top: 10px;
padding: 7px 11px;
border-radius: 12px;
background: var(--material-thick);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow: var(--shadow-m), inset 0 0 0 0.5px var(--material-edge);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
/* リプレイ */
.rv-note {
margin-top: 10px;
padding: 10px 12px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.8125rem;
}
.rv-copy {
margin-top: 10px;
}
.rv-board-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 14px;
padding: 12px 14px;
border-radius: 18px;
background: radial-gradient(120% 140% at 30% 20%, var(--felt-hi), var(--felt) 50%, var(--felt-lo));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25), inset 0 8px 20px rgba(0, 0, 0, 0.25);
}
.rv-board {
display: flex;
gap: 4px;
}
.rv-pot {
color: #fff;
font-family: var(--font-num);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.rv-step {
min-height: 68px;
margin-top: 10px;
padding: 10px 14px;
border-radius: 14px;
background: var(--bg-2);
font-size: 0.9375rem;
}
.rv-step-title {
margin-bottom: 2px;
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
}
.rv-controls {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 8px;
}
.rv-controls .btn {
padding: 10px 0;
}
.rv-controls .btn .i {
width: 20px;
height: 20px;
}
.rv-analysis {
margin-top: 10px;
padding: 4px 14px 12px;
border-radius: 14px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1.5px var(--orange);
}
.rv-ev {
margin-top: 6px;
}
.rv-ev tr.actual td {
background: rgba(255, 149, 0, 0.1);
}
.rv-ev .best {
color: var(--green);
}
.rv-players {
display: grid;
gap: 6px;
margin-top: 12px;
}
.rv-player {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 12px;
border-radius: 14px;
background: var(--bg-2);
box-shadow: inset 0 0 0 0 transparent;
transition: box-shadow 0.3s ease, opacity 0.3s ease;
}
.rv-player.hero {
box-shadow: inset 0 0 0 1.5px var(--tint-soft);
}
.rv-player.acting {
box-shadow: inset 0 0 0 2px var(--orange);
}
.rv-player.folded {
opacity: 0.5;
}
.rv-player-info {
min-width: 0;
font-size: 0.875rem;
}
.rv-player-act {
color: var(--label-2);
font-size: 0.75rem;
font-weight: 700;
text-align: right;
}
.rv-pos {
margin-left: 4px;
color: var(--tint);
font-size: 0.6875rem;
font-weight: 700;
}
.rv-log {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rv-log-street {
width: 100%;
margin-top: 6px;
color: var(--label-2);
font-size: 0.6875rem;
font-weight: 600;
}
.rv-log-item {
-webkit-appearance: none;
appearance: none;
padding: 5px 10px;
border: 0;
border-radius: 999px;
background: var(--bg-2);
color: var(--label);
font-size: 0.75rem;
cursor: pointer;
box-shadow: inset 0 0 0 0.5px var(--separator);
transition: background-color 0.25s ease, color 0.25s ease, transform 0.45s var(--ease-spring);
}
.rv-log-item:active {
transform: scale(0.94);
transition: transform 0.1s ease-out;
}
.rv-log-item .hint {
margin: 0 4px;
font-size: 0.6875rem;
}
.rv-log-item.mine {
box-shadow: inset 0 0 0 1.5px var(--tint);
}
.rv-log-item.current {
background: var(--orange);
color: var(--on-orange);
}
.rv-log-item.current .hint {
color: rgba(0, 0, 0, 0.6);
}
.plus {
color: var(--green);
}
.minus {
color: var(--red);
}
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>CPU戦のレビュー - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/range/files/review.css">
</head>
<body class="page-review">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" id="back" href="/" aria-label="戻る"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg><span id="back-label">ロビー</span></a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap review-wrap">
<h1 class="large-title" id="title">CPU戦のレビュー</h1>
<!-- 対局の一覧 -->
<section id="view-list">
<div class="group-header">レビュー付きCPU戦の対局(新しい順・最大50件)</div>
<div id="sessions" class="list rv-sessions"></div>
</section>
<!-- 1回の対局 -->
<section id="view-session" hidden>
<div class="rv-overview" id="overview"></div>
<div class="segmented rv-tabs" id="tabs">
<button type="button" class="seg-btn active" data-tab="scores">点数表</button>
<button type="button" class="seg-btn" data-tab="stats">成績</button>
<button type="button" class="seg-btn" data-tab="range">色・位置</button>
<button type="button" class="seg-btn" data-tab="worst">見直し</button>
<button type="button" class="seg-btn" data-tab="graph">収支</button>
</div>
<div id="tab-body"></div>
</section>
<!-- 1ハンドのリプレイ -->
<section id="view-replay" hidden>
<div class="rv-replay-head" id="replay-head"></div>
<div class="rv-board-box">
<div class="rv-board" id="replay-board"></div>
<div class="rv-pot" id="replay-pot"></div>
</div>
<div class="rv-step" id="replay-step" aria-live="polite"></div>
<div class="rv-controls">
<button type="button" class="btn" data-step="first" aria-label="最初へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5.5" width="2.4" height="13" rx="1"/><path d="M19 6.6v10.8a.9.9 0 0 1-1.4.75L9.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
<button type="button" class="btn" data-step="prev" aria-label="前へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 6.6v10.8a.9.9 0 0 1-1.4.75L7.6 12.9a1.1 1.1 0 0 1 0-1.8l8-5.25a.9.9 0 0 1 1.4.75z"/></svg></button>
<button type="button" class="btn btn-tint" data-step="next" aria-label="次へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 7 6.6z"/></svg></button>
<button type="button" class="btn" data-step="last" aria-label="最後へ"><svg class="i i-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6.6v10.8a.9.9 0 0 0 1.4.75l8-5.25a1.1 1.1 0 0 0 0-1.8l-8-5.25A.9.9 0 0 0 5 6.6z"/><rect x="16.6" y="5.5" width="2.4" height="13" rx="1"/></svg></button>
</div>
<div id="replay-analysis"></div>
<div class="rv-players" id="replay-players"></div>
<div class="rv-section">アクション一覧</div>
<div class="rv-log" id="replay-log"></div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/range/files/review.js"></script>
</body>
</html>
+596
View File
@@ -0,0 +1,596 @@
// CPU戦のレビュー(管理者専用): 対局ごとの点数表・成績・色とポジションの傾向・見直したいハンド・収支の推移と、
// 1ハンドずつのリプレイ
(function () {
'use strict';
const { esc, chips, api, toast } = App;
const $ = (id) => document.getElementById(id);
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const TYPE_LABEL = { FOLD: 'フォールド', CHECK: 'チェック', CALL: 'コール', BET: 'ベット', RAISE: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
let session = null;
let tab = 'scores';
let replay = null; // { hand, step }
const CHEVRON = '<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>';
const SPARKLES =
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>';
Fluid.navBar($('nav'), $('title'));
const tabsSeg = Fluid.Segmented($('tabs'));
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : '');
const pctText = (v) => (v == null ? '—' : `${v}%`);
const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const colorChip = (h) => `<span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span>`;
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard slot ${extra}"></span>`;
const rank = c[0] === 'T' ? '10' : c[0];
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号&t=テーブルID) ----------
function show(view) {
for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view;
window.scrollTo(0, 0);
}
// テーブルのレビューから開いたときのテーブルID。どの画面に移っても引き継ぎ、対局の画面からテーブルへ戻れるようにする
function fromTable() {
const t = new URLSearchParams(location.search).get('t');
return t && /^[0-9a-f]{8}$/.test(t) ? t : null;
}
function setBack(href, label) {
$('back').href = href;
$('back-label').textContent = label;
}
function go(params, push = true) {
const table = fromTable();
const q = new URLSearchParams(table ? { ...params, t: table } : params).toString();
const url = location.pathname + (q ? `?${q}` : '');
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
route();
}
async function route() {
const p = new URLSearchParams(location.search);
const id = p.get('s');
const hand = Number(p.get('h'));
const table = fromTable();
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
if (table) setBack(`/table/${table}`, 'テーブル');
else setBack('/', 'ロビー');
show('list');
return loadList();
}
if (!session || session.id !== id || !hand) {
try {
session = await api('GET', `/api/reviews/${encodeURIComponent(id)}`);
} catch (err) {
toast(err.message);
return go({}, false);
}
}
$('title').textContent = session.tableName;
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
setBack(`?s=${encodeURIComponent(id)}`, '対局');
return openReplay(h);
}
}
if (table) setBack(`/table/${table}`, 'テーブル');
else setBack('/review', '一覧');
show('session');
renderSession();
}
window.addEventListener('popstate', route);
// 戻るボタンは履歴を積まずに一つ上へ(ロビー・テーブルへはそのままページを移る)
$('back').addEventListener('click', (e) => {
const href = $('back').getAttribute('href');
if (href === '/' || href.startsWith('/table/')) return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
});
// ---------- 対局の一覧 ----------
async function loadList() {
let list;
try {
list = await api('GET', '/api/reviews');
} catch (err) {
$('sessions').innerHTML = `<div class="empty">${esc(err.message)}</div>`;
return;
}
if (!list.length) {
$('sessions').innerHTML = '<div class="empty">まだ対局がありません。<br>ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。</div>';
return;
}
$('sessions').innerHTML = list
.map(
(s) => `
<button type="button" class="cell cell-link rv-session" data-id="${esc(s.id)}">
<span class="cell-title">
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
<span class="cell-sub">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
</span>
${CHEVRON}
</button>`
)
.join('');
}
$('sessions').addEventListener('click', (e) => {
const b = e.target.closest('[data-id]');
if (b) go({ s: b.dataset.id });
});
// ---------- 1回の対局 ----------
function renderSession() {
const s = session.summary;
const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0;
$('overview').innerHTML = `
<div class="rv-tile"><span>ハンド数</span><b>${s.stats.hands}</b></div>
<div class="rv-tile"><span>平均点</span><b class="score-${scoreClass(s.total)}">${s.total == null ? '—' : s.total}</b></div>
<div class="rv-tile"><span>収支</span><b class="${signClass(net)}">${signed(net)}</b></div>`;
for (const b of $('tabs').querySelectorAll('[data-tab]')) b.classList.toggle('active', b.dataset.tab === tab);
tabsSeg.update();
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
$('tab-body').innerHTML = '';
render();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-tab]');
if (!b) return;
tab = b.dataset.tab;
renderSession();
});
function scoreCell(v) {
return `<td class="num score-${scoreClass(v)}">${v == null ? '—' : v}</td>`;
}
// 点数表: ハンドごと・ストリートごとの点数
function renderScores() {
const hands = session.hands;
if (!hands.length) {
$('tab-body').innerHTML = '<div class="empty">まだハンドがありません。</div>';
return;
}
const rows = hands
.slice()
.reverse()
.map(
(h) => `
<tr data-hand="${h.number}" tabindex="0">
<td class="num">${h.number}</td>
<td class="rv-hand-cell"><b>${esc(h.hero.key)}</b> ${colorChip(h.hero)}</td>
<td>${esc(h.hero.position)}</td>
${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')}
${scoreCell(h.handScore)}
<td class="num ${signClass(h.hero.net)}">${signed(h.hero.net)}</td>
</tr>`
)
.join('');
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<p class="hint">行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。</p>
<div class="rv-table-wrap">
<table class="rv-table">
<thead><tr><th>#</th><th>ハンド</th><th>位置</th>${STREETS.map((st) => `<th>${STREET_SHORT[st]}</th>`).join('')}<th>平均</th><th>収支</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td></td><td colspan="2">平均</td>${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)}<td></td></tr></tfoot>
</table>
</div>`;
}
$('tab-body').addEventListener('click', (e) => {
const r = e.target.closest('[data-hand]');
if (r) go({ s: session.id, h: r.dataset.hand });
});
$('tab-body').addEventListener('keydown', (e) => {
const r = e.target.closest('[data-hand]');
if (r && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
go({ s: session.id, h: r.dataset.hand });
}
});
// 成績
function renderStats() {
const st = session.summary.stats;
const tiles = [
['VPIP', pctText(st.vpip), '自分から参加した割合'],
['PFR', pctText(st.pfr), 'プリフロップでレイズした割合'],
['3bet', pctText(st.threeBet), `レイズに対してレイズし返した割合(機会 ${st.threeBetOpp}回)`],
['Cベット', pctText(st.cbet), `プリフロップのレイザーがフロップでベットした割合(機会 ${st.cbetOpp}回)`],
['WTSD', pctText(st.wtsd), 'フロップを見てショーダウンまで行った割合'],
['W$SD', pctText(st.wsd), 'ショーダウンで勝った割合'],
['AF', st.af == null ? '∞' : st.af, 'フロップ以降の (ベット+レイズ) ÷ コール'],
];
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<div class="rv-tiles">${tiles.map(([k, v, d]) => `<div class="rv-tile"><span>${k}</span><b>${v}</b><small>${d}</small></div>`).join('')}</div>
<div class="rv-section">ストリートごとの平均点</div>
<div class="rv-streets">${STREETS.map((s) => `<div class="rv-street"><span>${STREET_SHORT[s]}</span><b class="score-${scoreClass(avg[s])}">${avg[s] == null ? '—' : avg[s]}</b></div>`).join('')}</div>
<p class="hint">目安: VPIP 20〜28%・PFR 15〜22%・WTSD 25〜32%・AF 2〜3(ノーリミットのリングゲームで一般的な範囲)。</p>`;
}
// 色・ポジション・クセ
function renderRange() {
const s = session.summary;
const colorRows = s.colors
.map(
(c) => `
<tr>
<td><span class="rv-color cat-${esc(c.id)}">${esc(c.name)}</span> <span class="hint">${esc(c.label)}</span></td>
<td class="num">${c.dealt}</td>
<td class="num">${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'}</td>
<td class="num">${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'}</td>
</tr>`
)
.join('');
const VERDICT = { loose: '参加しすぎ', tight: '降りすぎ', passive: '消極的', aggressive: '攻めすぎ', needless: '不要なフォールド' };
const posRows = s.positions
.map((p) => {
const mistakes = Object.entries(p.verdicts)
.sort((a, b) => b[1] - a[1])
.map(([v, n]) => `${VERDICT[v]} ${n}`)
.join('・');
return `
<tr>
<td>${esc(p.position)}</td>
<td class="num">${p.hands}</td>
<td class="num">${pctText(Math.round((p.played / p.hands) * 1000) / 10)}</td>
<td class="num">${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'}</td>
<td>${mistakes || '—'}</td>
</tr>`;
})
.join('');
const leaks = s.leaks.length
? `<ul class="rv-leaks">${s.leaks.map((l) => `<li>${esc(l.text)} <b>${l.count}回</b>${l.of ? `<span class="hint">(${l.of}回中)</span>` : ''}</li>`).join('')}</ul>`
: '<div class="hint">目立つクセは見つかりませんでした。</div>';
$('tab-body').innerHTML = `
<div class="rv-section">気をつけたいクセ</div>${leaks}
<div class="rv-section">色ごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>色</th><th>配られた</th><th>参加率</th><th>レンジどおり</th></tr></thead>
<tbody>${colorRows}</tbody>
</table></div>
<div class="rv-section">ポジションごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>位置</th><th>ハンド</th><th>参加率</th><th>レンジどおり</th><th>主な間違い</th></tr></thead>
<tbody>${posRows}</tbody>
</table></div>`;
}
// 見直したいハンド
function renderWorst() {
const w = session.summary.worst;
if (!w.length) {
$('tab-body').innerHTML = '<div class="empty">見直したいハンドはありません。すべて満点です。</div>';
return;
}
$('tab-body').innerHTML = `
<p class="hint">フロップ以降で失った期待値(BB)が大きい順。同じならプリフロップの判定が悪い順。</p>
<div class="rv-worst">${w
.map(
(h, i) => `
<button type="button" class="rv-worst-item" data-hand="${h.number}">
<span class="rv-rank">${i + 1}</span>
<span><b>#${h.number} ${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}"></span> ${esc(h.position)}</span>
<span class="rv-worst-meta">−${h.lossBB}BB ・ ${h.handScore == null ? '—' : h.handScore}点 ・ <span class="${signClass(h.net)}">${signed(h.net)}</span></span>
</button>`
)
.join('')}</div>`;
}
// 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支
function renderGraph() {
const g = session.summary.graph;
if (g.length < 2) {
$('tab-body').innerHTML = '<div class="empty">2ハンド以上でグラフを表示します。</div>';
return;
}
const W = 360;
const H = 220;
const pad = { l: 48, r: 10, t: 12, b: 22 };
const values = g.flatMap((p) => [p.net, p.evNet]).concat([0]);
// 目盛りは 1・2・5 × 10^n 刻み
const raw = (Math.max(...values) - Math.min(...values)) / 4 || 1;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((x) => x >= raw);
const min = Math.floor(Math.min(...values) / step) * step;
const max = Math.ceil(Math.max(...values) / step) * step || step;
const money = (v) => (v < 0 ? '−' : '') + chips(Math.abs(v));
const x = (i) => pad.l + (i / (g.length - 1)) * (W - pad.l - pad.r);
const y = (v) => pad.t + ((max - v) / (max - min)) * (H - pad.t - pad.b);
const path = (key) => g.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join('');
let grid = '';
for (let v = min; v <= max + step / 2; v += step) {
const yy = y(v).toFixed(1);
grid += `<line class="gl" x1="${pad.l}" x2="${W - pad.r}" y1="${yy}" y2="${yy}"/><text class="tick" x="${pad.l - 6}" y="${yy}" dy="4" text-anchor="end">${esc(money(Math.round(v)))}</text>`;
}
const last = g[g.length - 1];
$('tab-body').innerHTML = `
<div class="rv-legend">
<span><i class="sw actual"></i>実際の収支 <b class="${signClass(last.net)}">${signed(last.net)}</b></span>
<span><i class="sw ev"></i>運を除いた収支 <b class="${signClass(last.evNet)}">${signed(last.evNet)}</b></span>
</div>
<div class="rv-chart" id="chart">
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="ハンドごとの累計収支">
${grid}
<line class="zero" x1="${pad.l}" x2="${W - pad.r}" y1="${y(0).toFixed(1)}" y2="${y(0).toFixed(1)}"/>
<text class="tick" x="${pad.l}" y="${H - 6}">#${g[0].number}</text>
<text class="tick" x="${W - pad.r}" y="${H - 6}" text-anchor="end">#${last.number}</text>
<path class="line actual" d="${path('net')}"/>
<path class="line ev" d="${path('evNet')}"/>
<line class="cross" id="cross" y1="${pad.t}" y2="${H - pad.b}" visibility="hidden"/>
<circle class="dot actual" id="dot-net" r="4" visibility="hidden"/>
<circle class="dot ev" id="dot-ev" r="4" visibility="hidden"/>
<rect class="hit" x="${pad.l}" y="${pad.t}" width="${W - pad.l - pad.r}" height="${H - pad.t - pad.b}"/>
</svg>
<div class="rv-tip" id="tip" hidden></div>
</div>
<p class="hint">運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。</p>`;
const svg = $('chart').querySelector('svg');
const tip = $('tip');
const move = (clientX) => {
const rect = svg.getBoundingClientRect();
const sx = ((clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1))));
const p = g[i];
const cx = x(i).toFixed(1);
$('cross').setAttribute('x1', cx);
$('cross').setAttribute('x2', cx);
$('cross').setAttribute('visibility', 'visible');
for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) {
$(id).setAttribute('cx', cx);
$(id).setAttribute('cy', y(p[key]).toFixed(1));
$(id).setAttribute('visibility', 'visible');
}
tip.innerHTML = `<b>ハンド #${p.number}</b><div><i class="sw actual"></i>実際 ${signed(p.net)}</div><div><i class="sw ev"></i>運を除く ${signed(p.evNet)}</div>`;
tip.hidden = false;
const left = (x(i) / W) * rect.width;
tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`;
};
const hide = () => {
tip.hidden = true;
for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden');
};
svg.addEventListener('pointermove', (e) => move(e.clientX));
svg.addEventListener('pointerdown', (e) => move(e.clientX));
svg.addEventListener('pointerleave', hide);
}
// ---------- リプレイ ----------
function openReplay(h) {
replay = { hand: h, step: firstHeroStep(h) };
show('replay');
$('replay-head').innerHTML = `
<div class="rv-head">
<div class="rv-cards">${h.hero.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>#${h.number} ${esc(h.hero.key)}</b> ${colorChip(h.hero)}</div>
<div class="rv-sub">${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 <span class="${signClass(h.hero.net)}">${signed(h.hero.net)}</span></div>
</div>
</div>
<div class="rv-streets">${STREETS.map((st) => `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b class="score-${scoreClass(h.streetScores[st])}">${h.streetScores[st] == null ? '—' : h.streetScores[st]}</b></div>`).join('')}</div>
${allInHtml(h)}
<button type="button" class="btn btn-block rv-copy" id="replay-copy">${SPARKLES}AIに質問する用にコピー</button>`;
$('replay-copy').addEventListener('click', () => copyHandPrompt(h));
$('replay-log').innerHTML = logHtml(h);
renderStep();
}
// ハンドの状況を、AI に貼り付けて「自分の判断は良かったか」を質問できる文章にしてコピーする
async function copyHandPrompt(h) {
const url = `/api/reviews/${encodeURIComponent(session.id)}/hands/${h.number}/prompt`;
try {
if (await App.copyText(() => api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます');
} catch (err) {
toast(err.message);
}
}
function allInHtml(h) {
const a = h.hero.allIn;
if (!a) return '';
return `<div class="rv-note">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 <span class="${signClass(a.won - a.ev)}">${signed(a.won - a.ev)}</span>)</div>`;
}
// 最初に見せる場面: hero の最初の判断(無ければ最初)
function firstHeroStep(h) {
const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto);
return i >= 0 ? i : -1;
}
const nameOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.name : `席${seat + 1}`;
};
const posOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.position : '';
};
function actionText(h, a) {
const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : '';
return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`;
}
function logHtml(h) {
let html = '';
let street = null;
h.actions.forEach((a, i) => {
if (a.street !== street) {
street = a.street;
html += `<div class="rv-log-street">${STREET_LABEL[street]}</div>`;
}
const mine = a.seat === h.heroSeat;
html += `<button type="button" class="rv-log-item${mine ? ' mine' : ''}" data-step="${i}">${esc(nameOf(h, a.seat))}<span class="hint">${esc(posOf(h, a.seat))}</span> ${esc(actionText(h, a))}</button>`;
});
html += `<button type="button" class="rv-log-item end" data-step="${h.actions.length}">結果</button>`;
return html;
}
// step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果)
function stateAt(h, step) {
const invested = new Map(h.players.map((p) => [p.seat, 0]));
const post = (seat, amount) => {
const p = h.players.find((x) => x.seat === seat);
if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start));
};
post(h.sbSeat, h.sb);
post(h.bbSeat, h.bb);
const folded = new Set();
const last = new Map();
let pot = h.sb + h.bb;
let street = 'PREFLOP';
const upto = Math.min(step, h.actions.length - 1);
for (let i = 0; i <= upto; i++) {
const a = h.actions[i];
if (a.street !== street) {
street = a.street;
last.clear();
}
invested.set(a.seat, invested.get(a.seat) + a.added);
if (a.type === 'FOLD') folded.add(a.seat);
last.set(a.seat, a);
pot = a.pot + a.added;
}
return { invested, folded, last, pot, street };
}
function renderStep() {
const h = replay.hand;
const step = replay.step;
const end = step >= h.actions.length;
const st = stateAt(h, step);
const a = step >= 0 && !end ? h.actions[step] : null;
const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP'];
let board = '';
for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : '<span class="pcard slot"></span>';
$('replay-board').innerHTML = board;
$('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`;
if (end) {
const lines = (h.results || []).map((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : '';
return `<div>${tag}<b>${r.winners.map((w) => esc(nameOf(h, w))).join('・')}</b> +${chips(r.amount)}${r.handName ? ` <span class="hint">${esc(r.handName)}</span>` : ''}</div>`;
});
$('replay-step').innerHTML = `<div class="rv-step-title">結果</div>${lines.join('')}`;
} else if (a) {
$('replay-step').innerHTML = `<div class="rv-step-title">${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}</div>
<div><b>${esc(nameOf(h, a.seat))}</b><span class="hint">(${esc(posOf(h, a.seat))})</span> ${esc(actionText(h, a))}</div>
<div class="hint">ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}</div>`;
} else {
$('replay-step').innerHTML = `<div class="rv-step-title">ハンド開始</div><div class="hint">SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}</div>`;
}
$('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : '';
const order = h.players.slice();
$('replay-players').innerHTML = order
.map((p) => {
const folded = st.folded.has(p.seat);
const acting = a && a.seat === p.seat;
const l = st.last.get(p.seat);
const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' ');
const won = end && p.won > 0 ? `<span class="plus">+${chips(p.won)}</span>` : '';
return `
<div class="${cls}">
<div class="rv-cards">${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}</div>
<div class="rv-player-info">
<div><b>${esc(p.name)}</b>${p.bot ? ' <span class="hint">CPU</span>' : ''} <span class="rv-pos">${esc(p.position)}</span></div>
<div class="hint">残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}</div>
</div>
<div class="rv-player-act">${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}</div>
</div>`;
})
.join('');
for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step);
const atStart = step <= -1;
const atEnd = end;
$('view-replay').querySelector('[data-step="first"]').disabled = atStart;
$('view-replay').querySelector('[data-step="prev"]').disabled = atStart;
$('view-replay').querySelector('[data-step="next"]').disabled = atEnd;
$('view-replay').querySelector('[data-step="last"]').disabled = atEnd;
}
// hero の判断の詳しい判定
function analysisHtml(h, index) {
const j = h.preflop.find((x) => x.index === index);
if (j) {
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[j.mark]}">${j.mark}</span><div>
<div><b>ハンドレンジ</b> ${colorChip(h.hero)} <span class="rv-score">${j.score}点</span></div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div></div>`;
}
const d = h.decisions.find((x) => x.index === index);
if (!d) return '';
const best = Math.max(...d.options.map((o) => o.evBB));
const rows = d.options
.map((o) => {
const isActual = o.kind === d.actual.kind && o.to === d.actual.to;
const isBest = o.evBB === best;
return `<tr class="${isActual ? 'actual' : ''}"><td>${optionLabel(o)}${isActual ? ' <span class="hint">← あなた</span>' : ''}</td><td class="num ${isBest ? 'best' : ''}">${o.evBB > 0 ? '+' : ''}${o.evBB}BB</td></tr>`;
})
.join('');
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[d.mark]}">${d.mark}</span><div>
<div><b>${STREET_LABEL[d.street]}の判断</b> <span class="rv-score">${d.score}点</span>${d.lossBB > 0 ? ` <span class="hint">(${d.lossBB}BB 損)</span>` : ''}</div>
<div class="rv-sub">相手の推定レンジに対する勝率 <b>${d.equity}%</b>${d.need != null ? ` / コールに必要な勝率 <b>${d.need}%</b> → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}</div>
</div></div>
<table class="rv-table rv-ev"><thead><tr><th>選択肢</th><th>期待値の目安</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
function stepTo(target) {
if (!replay) return;
replay.step = Math.max(-1, Math.min(replay.hand.actions.length, target));
renderStep();
}
$('view-replay').addEventListener('click', (e) => {
const b = e.target.closest('[data-step]');
if (!b || !replay) return;
const v = b.dataset.step;
const n = replay.hand.actions.length;
if (v === 'first') stepTo(-1);
else if (v === 'prev') stepTo(replay.step - 1);
else if (v === 'next') stepTo(replay.step + 1);
else if (v === 'last') stepTo(n);
else stepTo(Number(v));
});
document.addEventListener('keydown', (e) => {
if ($('view-replay').hidden || e.target.closest('input, textarea')) return;
if (e.key === 'ArrowLeft') stepTo(replay.step - 1);
if (e.key === 'ArrowRight') stepTo(replay.step + 1);
});
route();
})();
+97 -1
View File
@@ -8,11 +8,57 @@
"name": "friends-holdem",
"version": "0.1.0",
"dependencies": {
"@pokertools/engine": "2.1.0",
"@pokertools/evaluator": "2.1.0",
"express": "^4.22.3",
"poker-calculations": "4.0.3",
"poker-utils": "14.0.1",
"pokersolver": "2.1.4",
"socket.io": "^4.8.4"
},
"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": {
@@ -631,6 +677,17 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -679,6 +736,36 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"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": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1078,6 +1165,15 @@
"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
View File
@@ -10,10 +10,15 @@
"test": "node --test"
},
"engines": {
"node": ">=18"
"node": ">=24"
},
"dependencies": {
"@pokertools/engine": "2.1.0",
"@pokertools/evaluator": "2.1.0",
"express": "^4.22.3",
"poker-calculations": "4.0.3",
"poker-utils": "14.0.1",
"pokersolver": "2.1.4",
"socket.io": "^4.8.4"
}
}
+178 -3
View File
@@ -28,8 +28,46 @@
}
return t;
},
// テーブルで自分の収支を表示するか(設定画面で切り替える。既定は表示)
get showNet() { return lsGet('poker.showNet') !== '0'; },
set showNet(v) { lsSet('poker.showNet', v ? '1' : '0'); },
// テーブル作成画面の開始スタックの初期値(設定画面で変えられ、テーブルを作るたびに前回値で上書きする)
get defaultStack() {
const n = Number(lsGet('poker.defaultStack'));
return Number.isInteger(n) && n >= 10 && n <= 100000000 ? n : 1000;
},
set defaultStack(v) { lsSet('poker.defaultStack', String(v)); },
// 前回テーブルを作ったときの SB/BB と、そのときの開始スタック { stack, sb, bb }
get lastBlinds() {
try {
const b = JSON.parse(lsGet('poker.lastBlinds'));
return b && [b.stack, b.sb, b.bb].every(Number.isInteger) && b.sb >= 1 && b.bb > b.sb && b.bb <= b.stack ? b : null;
} catch {
return null;
}
},
set lastBlinds(v) { lsSet('poker.lastBlinds', JSON.stringify(v)); },
};
// 数値の入力欄: フォーカスしたら全体を選択して、そのまま打ち替えられるようにする
function selectOnFocus(el) {
let skipMouseUp = false;
el.addEventListener('focus', () => {
el.select();
skipMouseUp = true;
// iOS Safari はタップの処理の後で選択が外れるので、もう一度選ぶ
setTimeout(() => {
if (document.activeElement === el) el.select();
}, 0);
});
// クリックで入ったときは、離した位置にカーソルが置かれて選択が外れるのを防ぐ
el.addEventListener('mouseup', (e) => {
if (skipMouseUp) e.preventDefault();
skipMouseUp = false;
});
el.addEventListener('keydown', () => { skipMouseUp = false; });
}
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
@@ -38,18 +76,109 @@
return '$' + Number(n).toLocaleString('en-US');
}
// 上から降りてきて、少ししたら同じ道を上へ戻る通知
let toastTimer;
function toast(msg) {
let el = document.querySelector('.toast');
if (!el) {
el = document.createElement('div');
el.className = 'toast';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
document.body.appendChild(el);
void el.offsetWidth; // 最初の1回も画面の外から動かす
}
el.textContent = msg;
el.hidden = false;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
toastTimer = setTimeout(() => el.classList.remove('show'), 2600);
}
// ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。OK なら true で解決する。
// opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) }
// 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる
let closeConfirm = null;
function confirmDialog(opts) {
const o = typeof opts === 'string' ? { message: opts } : opts;
if (closeConfirm) closeConfirm(false);
const backdrop = document.createElement('div');
backdrop.className = 'alert-backdrop';
backdrop.innerHTML =
'<div class="alert" role="alertdialog" aria-modal="true">' +
'<div class="alert-body">' +
(o.title ? '<h2 class="alert-title"></h2>' : '') +
'<p class="alert-message"></p>' +
'</div>' +
'<div class="alert-buttons">' +
'<button type="button" class="alert-btn" data-confirm="cancel"></button>' +
`<button type="button" class="alert-btn ${o.danger ? 'destructive' : 'default'}" data-confirm="ok"></button>` +
'</div></div>';
const dialog = backdrop.firstChild;
const msgEl = dialog.querySelector('.alert-message');
msgEl.textContent = o.message || '';
msgEl.id = 'confirm-message-' + randomHex(4);
dialog.setAttribute('aria-describedby', msgEl.id);
if (o.title) {
const titleEl = dialog.querySelector('.alert-title');
titleEl.textContent = o.title;
titleEl.id = 'confirm-title-' + randomHex(4);
dialog.setAttribute('aria-labelledby', titleEl.id);
}
const okBtn = dialog.querySelector('[data-confirm="ok"]');
const cancelBtn = dialog.querySelector('[data-confirm="cancel"]');
okBtn.textContent = o.ok || 'OK';
cancelBtn.textContent = o.cancel || 'キャンセル';
// 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる
let closing = false;
const appear = new Fluid.Spring(0, { precision: 0.002 });
appear.onUpdate = (v) => {
backdrop.style.setProperty('--appear', v.toFixed(3));
dialog.style.opacity = v >= 1 ? '' : v.toFixed(3);
dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`;
};
const prevFocus = document.activeElement;
return new Promise((resolve) => {
function close(result) {
if (closeConfirm !== close) return;
closeConfirm = null;
closing = true;
document.removeEventListener('keydown', onKey, true);
backdrop.style.pointerEvents = 'none';
appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => backdrop.remove() });
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus();
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
close(false);
} else if (e.key === 'Tab') {
// フォーカスをダイアログの2つのボタンの中に閉じ込める
e.preventDefault();
(document.activeElement === okBtn ? cancelBtn : okBtn).focus();
}
}
okBtn.addEventListener('click', () => close(true));
cancelBtn.addEventListener('click', () => close(false));
// 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く)
let downOnBackdrop = false;
backdrop.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === backdrop;
});
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop && downOnBackdrop) close(false);
});
document.addEventListener('keydown', onKey, true);
closeConfirm = close;
document.body.appendChild(backdrop);
appear.set(0);
appear.to(1, { damping: 1, response: 0.3, allowReduced: true });
// 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする
(o.danger ? cancelBtn : okBtn).focus();
});
}
async function api(method, url, body) {
@@ -68,6 +197,52 @@
return data;
}
// 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。
// サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に
// Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう
async function copyText(load) {
const pending = load();
try {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
const blob = pending.then((t) => new Blob([t], { type: 'text/plain' }));
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
return true;
}
} catch { /* 次の方法へ */ }
const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す
try {
await navigator.clipboard.writeText(text);
return true;
} catch { /* 手でコピーしてもらう */ }
showCopyDialog(text);
return false;
}
function showCopyDialog(text) {
const root = document.createElement('div');
root.className = 'sheet-backdrop';
root.hidden = true;
root.innerHTML =
'<div class="sheet" role="dialog" aria-modal="true" aria-label="コピーしてください" tabindex="-1">' +
'<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span>' +
'<h2 class="sheet-title">コピーしてください</h2></div>' +
'<div class="sheet-scroll">' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'</div>' +
'<div class="sheet-footer"><button type="button" class="btn btn-lg btn-block copy-close">閉じる</button></div>' +
'</div>';
const area = root.querySelector('textarea');
area.value = text;
document.body.appendChild(root);
const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() });
root.querySelector('.copy-close').addEventListener('click', () => sheet.close());
sheet.open();
area.focus({ preventScroll: true });
area.select();
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
}
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
function connectSocket() {
const socket = io({ transports: ['websocket'] });
@@ -96,5 +271,5 @@
FINISHING: 'FINISHING',
};
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
window.App = { Store, esc, chips, toast, selectOnFocus, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
})();
+43 -4
View File
@@ -1,7 +1,7 @@
(function () {
'use strict';
const { Store, api, chips } = App;
const { Store, api, chips, selectOnFocus } = App;
if (!Store.name) {
location.replace('/');
return;
@@ -16,6 +16,8 @@
const timerInput = $('timer');
const intervalInput = $('interval');
const scheduleEl = $('schedule');
const cpuOptions = $('cpu-options');
const intervalRow = $('interval-row');
const blindHint = $('blind-hint');
const errorEl = $('error');
const submitBtn = $('submit');
@@ -47,7 +49,7 @@
const stack = int(stackInput);
const bb = int(bbInput);
if (blindsTouched && stack > 0 && bb > 0) {
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link">おすすめに戻す</a>`;
} else {
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
}
@@ -58,7 +60,8 @@
const bb = int(bbInput);
const on = timerInput.checked;
intervalInput.disabled = !on;
scheduleEl.hidden = !on || !(sb > 0 && bb >= sb);
setOpen(intervalRow, on);
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
if (scheduleEl.hidden) return;
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
@@ -66,9 +69,23 @@
.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() {
updateHint();
updateSchedule();
updateMode();
}
stackInput.addEventListener('input', () => {
@@ -91,7 +108,11 @@
});
timerInput.addEventListener('change', refresh);
intervalInput.addEventListener('input', refresh);
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
[stackInput, sbInput, bbInput, intervalInput].forEach(selectOnFocus);
$('reroll').addEventListener('click', suggestName);
Fluid.Segmented(form.querySelector('.segmented.numbers'));
Fluid.navBar($('nav'), $('large-title'));
function validate() {
const name = nameInput.value.trim();
@@ -102,7 +123,7 @@
if (!name) return [nameInput, 'テーブル名を入力してください'];
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
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 (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
return null;
@@ -128,8 +149,13 @@
bigBlind: int(bbInput),
blindTimerEnabled: timerInput.checked,
blindIntervalMin: int(intervalInput),
mode: radioValue('mode'),
cpuCount: Number(radioValue('cpu-count')),
token: Store.token,
});
// 次にテーブルを作るときの初期値にする
Store.defaultStack = int(stackInput);
Store.lastBlinds = { stack: int(stackInput), sb: int(sbInput), bb: int(bbInput) };
location.href = '/table/' + encodeURIComponent(id);
} catch (err) {
errorEl.textContent = err.message;
@@ -137,7 +163,20 @@
}
});
// レビュー付きCPU戦は管理者だけが作れる(サーバでも確認する)
api('GET', '/api/me')
.then((me) => { $('review-mode').hidden = !me.admin; })
.catch(() => {});
// 開始スタックは設定(=前回値)。SB/BB は前回と同じ開始スタックなら前回値を引き継ぐ
stackInput.value = Store.defaultStack;
applyDefaultBlinds();
const last = Store.lastBlinds;
if (last && last.stack === Store.defaultStack && (last.sb !== int(sbInput) || last.bb !== int(bbInput))) {
sbInput.value = last.sb;
bbInput.value = last.bb;
blindsTouched = true;
}
refresh();
suggestName();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+640
View File
@@ -0,0 +1,640 @@
// 動きと手ざわりの共通部品。
// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
// - Popup: 画面の中央に出るポップアップ(背景を押す・Esc で閉じる)
// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
(function () {
'use strict';
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const reducedMotion = () => motionQuery.matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
// ---------- ばね ----------
const running = new Set();
let frame = 0;
let lastTime = 0;
function tick(now) {
// タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
const dt = clamp((now - lastTime) / 1000, 0, 0.05);
lastTime = now;
for (const s of [...running]) s.step(dt);
frame = running.size ? requestAnimationFrame(tick) : 0;
}
class Spring {
// opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
constructor(value = 0, opts = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.precision = opts.precision || 0.01;
this.onUpdate = opts.onUpdate || null;
this.onRest = null;
this.configure(opts.damping ?? 1, opts.response ?? 0.4);
}
// 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
// damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
configure(damping, response) {
this.damping = damping;
this.response = response;
this.stiffness = ((2 * Math.PI) / response) ** 2;
this.friction = (4 * Math.PI * damping) / response;
}
get animating() {
return running.has(this);
}
// 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
// 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
to(target, opts = {}) {
if (opts.damping != null || opts.response != null) {
this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
}
if (opts.velocity != null) this.velocity = opts.velocity;
this.target = target;
this.onRest = opts.onRest || null;
if (reducedMotion() && !opts.allowReduced) {
this.set(target);
this.rest();
return this;
}
running.add(this);
if (!frame) {
lastTime = performance.now();
frame = requestAnimationFrame(tick);
}
return this;
}
// すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
set(value) {
running.delete(this);
this.value = this.target = value;
this.velocity = 0;
if (this.onUpdate) this.onUpdate(value);
return this;
}
// 画面上のいまの位置で止める(動いている最中につかんだとき)
stop() {
running.delete(this);
this.target = this.value;
this.velocity = 0;
this.onRest = null;
return this;
}
step(dt) {
// 硬いばねでも発散しないよう 1/240 秒刻みで積分する
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i++) {
const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
this.velocity += force * h;
this.value += this.velocity * h;
}
const settled =
Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
if (settled) {
this.value = this.target;
this.velocity = 0;
running.delete(this);
}
if (this.onUpdate) this.onUpdate(this.value);
if (settled) this.rest();
}
rest() {
const fn = this.onRest;
this.onRest = null;
if (fn) fn();
}
}
// ---------- 指の動き ----------
// 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
class VelocityTracker {
constructor() {
this.samples = [];
}
reset() {
this.samples.length = 0;
}
add(x, y, t = performance.now()) {
this.samples.push({ x, y, t });
while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
}
velocity() {
const s = this.samples;
if (s.length < 2) return { x: 0, y: 0 };
const a = s[0];
const b = s[s.length - 1];
const dt = (b.t - a.t) / 1000;
return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
}
}
// 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
function project(velocity, decelerationRate = 0.998) {
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
}
// 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
function trapFocus(box, e) {
const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (!box.contains(document.activeElement)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ---------- シート ----------
// root は背景(.sheet-backdrop)、中に .sheet。
// [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
// 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
// opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Sheet(root, opts = {}) {
const sheet = root.querySelector('.sheet');
const dismissible = opts.dismissible !== false;
const y = new Spring(0, { precision: 0.5 });
const tracker = new VelocityTracker();
let height = 0;
let isOpen = false;
let prevFocus = null;
let drag = null;
let downOnBackdrop = false;
const measure = () => {
height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
};
y.onUpdate = (v) => {
sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
};
function onKey(e) {
if (e.key === 'Escape' && dismissible) {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(sheet, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
if (root.hidden) {
root.hidden = false;
measure();
y.set(height);
}
y.to(0, { damping: 1, response: 0.38 });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? sheet.querySelector(opts.focus) : null;
(target || sheet).focus({ preventScroll: true });
}
function close(velocity) {
if (!isOpen) return;
isOpen = false;
document.removeEventListener('keydown', onKey);
measure();
y.to(height, {
damping: 1,
response: 0.3,
velocity: typeof velocity === 'number' ? velocity : undefined,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (dismissible && e.target === root && downOnBackdrop) close();
});
sheet.addEventListener('pointerdown', (e) => {
if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
if (!e.target.closest('[data-sheet-drag]')) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, e.clientY, e.timeStamp);
});
sheet.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
if (!drag.active) {
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (Math.hypot(dx, dy) < 8) return;
if (Math.abs(dx) > Math.abs(dy)) {
drag = null;
return;
}
// ここで初めてつかむ。動いている最中なら画面上のいまの位置から
drag.active = true;
drag.y = e.clientY;
y.stop();
drag.origin = y.value;
measure();
sheet.setPointerCapture(e.pointerId);
sheet.classList.add('dragging');
if (!isOpen) {
isOpen = true;
document.addEventListener('keydown', onKey);
}
}
const raw = drag.origin + (e.clientY - drag.y);
y.set(raw < 0 ? -rubberband(-raw, height) : raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
sheet.classList.remove('dragging');
if (!wasActive) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
const v = tracker.velocity().y;
if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
// 払った勢いがあるので少しだけ弾ませて戻す
else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
};
sheet.addEventListener('pointerup', end);
sheet.addEventListener('pointercancel', end);
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- ポップアップ ----------
// root は背景(.popup-backdrop)、中に .popup。画面の中央に少し大きい所から縮んで現れ、閉じるときはその場で薄くなる
// (App.confirm のアラートと同じ動き)。背景を押す・Esc で閉じる。
// opts: { focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Popup(root, opts = {}) {
const box = root.querySelector('.popup');
const appear = new Spring(0, { precision: 0.002 });
let isOpen = false;
let closing = false;
let prevFocus = null;
let downOnBackdrop = false;
appear.onUpdate = (v) => {
root.style.setProperty('--appear', v.toFixed(3));
box.style.opacity = v >= 1 ? '' : v.toFixed(3);
box.style.transform = closing || v >= 1 || reducedMotion() ? '' : `scale(${(1.08 - 0.08 * v).toFixed(4)})`;
};
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(box, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
closing = false;
if (root.hidden) {
root.hidden = false;
appear.set(0);
}
root.style.pointerEvents = '';
appear.to(1, { damping: 1, response: 0.32, allowReduced: true });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? box.querySelector(opts.focus) : null;
(target || box).focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
closing = true;
document.removeEventListener('keydown', onKey);
root.style.pointerEvents = 'none'; // 消えている途中の背景で次の操作を止めない
appear.to(0, {
damping: 1,
response: 0.2,
allowReduced: true,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
// 背景を押したら閉じる(ポップアップの中から背景へのドラッグは除く)
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (e.target === root && downOnBackdrop) close();
});
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- セグメント ----------
// container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
// 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
// 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
function Segmented(container) {
const thumb = document.createElement('span');
thumb.className = 'segmented-thumb';
thumb.setAttribute('aria-hidden', 'true');
container.prepend(thumb);
container.classList.add('has-thumb');
const x = new Spring(0, { precision: 0.1 });
const w = new Spring(0, { precision: 0.1 });
const press = new Spring(1, { precision: 0.001 });
const tracker = new VelocityTracker();
let placed = false;
let drag = null;
let suppressClick = false;
const paint = () => {
thumb.style.width = `${w.value.toFixed(2)}px`;
thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
};
x.onUpdate = w.onUpdate = press.onUpdate = paint;
const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
const isSelected = (el) =>
el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
const center = (el) => el.offsetLeft + el.offsetWidth / 2;
function sync(animate = true) {
if (drag && drag.active) return;
const el = items().find(isSelected);
thumb.hidden = !el;
if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
if (animate && placed) {
if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
} else {
x.set(el.offsetLeft);
w.set(el.offsetWidth);
}
placed = true;
}
function select(el) {
if (isSelected(el)) return;
if (el.matches('label')) {
const input = el.querySelector('input');
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.click();
}
}
container.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const el = items().find((i) => i.contains(e.target));
if (!el || !isSelected(el)) return;
drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, 0, e.timeStamp);
press.to(0.95, { damping: 1, response: 0.2 });
});
container.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, 0, e.timeStamp);
if (!drag.active) {
if (Math.abs(e.clientX - drag.x) < 6) return;
drag.active = true;
drag.x = e.clientX;
x.stop();
drag.origin = x.value;
container.setPointerCapture(e.pointerId);
}
const list = items();
const last = list[list.length - 1];
const min = list[0].offsetLeft;
const max = last.offsetLeft + last.offsetWidth - w.value;
let raw = drag.origin + (e.clientX - drag.x);
if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
x.set(raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
press.to(1, { damping: 1, response: 0.25 });
if (!wasActive) return;
suppressClick = true;
setTimeout(() => {
suppressClick = false;
}, 0);
tracker.add(e.clientX, 0, e.timeStamp);
const v = tracker.velocity().x;
const aim = x.value + w.value / 2 + project(v, 0.99);
const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
w.to(best.offsetWidth, { damping: 1, response: 0.3 });
select(best);
};
container.addEventListener('pointerup', end);
container.addEventListener('pointercancel', end);
// ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
container.addEventListener(
'click',
(e) => {
if (!suppressClick) return;
e.preventDefault();
e.stopPropagation();
},
true
);
container.addEventListener('change', () => sync());
// ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
container.addEventListener('click', () => requestAnimationFrame(() => sync()));
if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
sync(false);
return { update: sync };
}
// ---------- 出し入れ ----------
// 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
// from: { scale, y }。視差効果を減らす設定では透明度だけ変える
function presence(el, show, from = {}) {
let p = el._presence;
if (!p) {
p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
el.hidden = !show;
}
const scale = from.scale ?? 0.96;
const dy = from.y ?? 8;
p.onUpdate = (v) => {
el.style.opacity = v >= 1 ? '' : v.toFixed(3);
el.style.transform =
v >= 1 || reducedMotion()
? ''
: `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
};
if (show) {
if (el.hidden) {
el.hidden = false;
p.set(0);
}
if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
} else if (!el.hidden && p.target !== 0) {
p.to(0, {
damping: 1,
response: 0.22,
allowReduced: true,
onRest: () => {
el.hidden = true;
},
});
}
}
// 数字をばねで変える(ポット・スタックなど)
function countTo(el, value, format) {
const show = (v) => {
el.textContent = format(Math.round(v));
};
let s = el._count;
if (!s) {
s = el._count = new Spring(value, { precision: 0.5 });
s.onUpdate = show;
show(value);
return;
}
s.onUpdate = show;
if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
}
// 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
function shake(el) {
if (reducedMotion()) return;
let s = el._shake;
if (!s) {
s = el._shake = new Spring(0, { precision: 0.05 });
s.onUpdate = (v) => {
el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
};
}
s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
}
// 振動(対応している端末だけ。画面に触れる前は振動できない)
function haptic(pattern = 10) {
try {
const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
if (navigator.vibrate && touched) navigator.vibrate(pattern);
} catch {
/* 振動できない環境 */
}
}
// ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
// 大きな見出しが隠れたらバーの中に小さな見出しを出す
function navBar(nav, largeTitle) {
if (!nav) return;
const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
update();
window.addEventListener('scroll', update, { passive: true });
const inline = nav.querySelector('.nav-title');
if (!largeTitle || !inline) return;
const mirror = () => {
inline.textContent = largeTitle.textContent;
};
mirror();
new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
if ('IntersectionObserver' in window) {
new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
}).observe(largeTitle);
}
}
// ---------- 拡大させない ----------
// iOS Safari は viewport の user-scalable=no を無視するので、ピンチは gesture* イベントと
// 2本指の touchmove を止めて防ぐ。ダブルタップの拡大は CSS の touch-action で止めている
// (JS で 2回目のタップを止めると、素早い連打のクリックまで消えてしまうため)
const stopZoom = (e) => e.preventDefault();
for (const type of ['gesturestart', 'gesturechange', 'gestureend']) {
document.addEventListener(type, stopZoom, { passive: false });
}
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length > 1) e.preventDefault();
},
{ passive: false },
);
window.Fluid = {
Spring,
VelocityTracker,
project,
rubberband,
clamp,
reducedMotion,
Sheet,
Popup,
Segmented,
presence,
countTo,
shake,
haptic,
navBar,
};
})();
+18 -3
View File
@@ -8,14 +8,26 @@
const submitBtn = document.getElementById('submit');
let busy = false;
// /key?admin … 入室済みの人が管理者キーを入れるための画面
if (new URLSearchParams(location.search).has('admin')) {
document.getElementById('key-title').textContent = '管理者キーを入力';
document.getElementById('key-hint').textContent = '管理者用の6桁の数字';
}
const value = () => inputs.map((i) => i.value).join('');
// 数字が入った枠は小さく弾む(空に戻ったら印を外す)
function markFilled() {
for (const i of inputs) i.classList.toggle('filled', Boolean(i.value));
}
function fill(digits, from) {
let i = from;
for (const d of digits) {
if (i >= inputs.length) break;
inputs[i++].value = d;
}
markFilled();
inputs[Math.min(i, inputs.length - 1)].focus();
if (value().length === 6) submit();
}
@@ -25,11 +37,13 @@
const digits = input.value.replace(/\D/g, '');
input.value = '';
if (digits) fill(digits, idx);
else markFilled();
errorEl.textContent = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && idx > 0) {
inputs[idx - 1].value = '';
markFilled();
inputs[idx - 1].focus();
e.preventDefault();
} else if (e.key === 'ArrowLeft' && idx > 0) {
@@ -62,10 +76,11 @@
location.replace(App.safeNext(next));
} catch (err) {
errorEl.textContent = err.message;
box.classList.remove('shake');
void box.offsetWidth;
box.classList.add('shake');
// 揺れと振動は同じ瞬間に(どちらも「違う」の合図)
Fluid.shake(box);
Fluid.haptic([18, 50, 18]);
inputs.forEach((i) => { i.value = ''; });
markFilled();
inputs[0].focus();
} finally {
busy = false;
+221 -26
View File
@@ -6,6 +6,9 @@
const nameError = document.getElementById('name-error');
const listEl = document.getElementById('list');
const countEl = document.getElementById('count');
const footerEl = document.getElementById('list-footer');
Fluid.navBar(document.getElementById('nav'), document.getElementById('large-title'));
nameInput.value = Store.name;
nameInput.addEventListener('input', () => {
@@ -22,48 +25,240 @@
return false;
}
let admin = false;
let tables = null;
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
// (ページ・API 自体もサーバ側で管理者以外は 404)
App.api('GET', '/api/me')
.then((me) => {
admin = Boolean(me.admin);
document.getElementById('admin-group').hidden = !admin;
if (tables) render(tables);
})
.catch(() => {});
document.getElementById('create').addEventListener('click', () => {
if (requireName()) location.href = '/create';
});
listEl.addEventListener('click', (e) => {
const del = e.target.closest('[data-delete]');
if (del) return deleteTable(del.dataset.delete, del);
const btn = e.target.closest('[data-join]');
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
function phaseBadge(t) {
if (t.paused) return '<span class="badge paused">PAUSED</span>';
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
return '<span class="badge live">PLAYING</span>';
async function deleteTable(id, btn) {
const t = tables && tables.find((x) => x.id === id);
if (!t) return;
const players = t.playerCount ? `
着席中の ${t.playerCount} 人はテーブルから退出させられます。` : '';
const ok = await App.confirm({
title: 'テーブルを削除',
message: `「${t.name}」を削除しますか?${players}`,
ok: '削除する',
danger: true,
});
const row = btn.closest('.swipe-row');
if (!ok || !document.contains(btn)) {
if (row && row.swipe) row.swipe.close();
return;
}
btn.disabled = true;
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
.then(() => App.toast('テーブルを削除しました'))
.catch((err) => {
btn.disabled = false;
if (row && row.swipe) row.swipe.close();
App.toast(err.message);
});
}
function render(tables) {
const ICON = {
chips:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
cards:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg>',
cpu:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg>',
review:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
person: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0"/></svg>',
clock: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
trash:
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 7h15M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12a2 2 0 0 0 2 1.8h5a2 2 0 0 0 2-1.8l1-12M10.5 11v6M13.5 11v6"/></svg>',
};
const MODE = {
chips: { label: 'キャッシュオンリー', icon: 'chips', tone: 'green' },
friends: { label: '友人戦', icon: 'cards', tone: 'blue' },
cpu: { label: 'CPU戦', icon: 'cpu', tone: 'indigo' },
review: { label: 'レビュー付きCPU戦', icon: 'review', tone: 'orange' },
};
function statusHtml(t) {
if (t.paused) return '<span class="status paused">一時停止中</span>';
if (t.phase === 'WAITING') return '<span class="status waiting">待機中</span>';
return '<span class="status live">プレイ中</span>';
}
function rowHtml(t) {
const m = MODE[t.mode] || MODE.chips;
const full = t.playerCount >= t.maxSeats;
const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし';
return `
<span class="cell-icon tone-${m.tone}">${ICON[m.icon]}</span>
<span class="table-row-main">
<span class="name">${esc(t.name)}</span>
<span class="table-row-meta">${statusHtml(t)}<span>${m.label}</span></span>
<span class="table-row-meta">
<span class="meta-item">${ICON.person}${t.playerCount}/${t.maxSeats}</span>
<span>Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}</span>
<span>開始 ${chips(t.startingStack)}</span>
<span class="meta-item">${ICON.clock}${timer}</span>
</span>
</span>
<button type="button" class="btn-get" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>${full ? '観戦' : '参加'}</button>`;
}
// ---------- 左へスワイプして削除(管理者) ----------
// 行は指に 1:1 で付いてきて、削除ボタンの幅を越えるとゴムのように抵抗する。
// 離したときは速度から止まる位置を先読みし、半分を越えていれば開いたまま止める
const ACTION_WIDTH = 88;
let openRow = null;
function attachSwipe(row) {
const content = row.querySelector('.table-row');
const action = row.querySelector('.swipe-action');
const x = new Fluid.Spring(0, { precision: 0.2 });
const tracker = new Fluid.VelocityTracker();
let drag = null;
let suppressClick = false;
x.onUpdate = (v) => {
content.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
row.style.setProperty('--reveal', Fluid.clamp(-v / ACTION_WIDTH, 0, 1).toFixed(3));
};
const settle = (open, velocity) => {
x.to(open ? -ACTION_WIDTH : 0, { damping: velocity ? 0.8 : 1, response: 0.3, velocity });
if (open) openRow = row;
else if (openRow === row) openRow = null;
};
row.swipe = { close: () => settle(false) };
content.addEventListener('pointerdown', (e) => {
if (!admin || (e.pointerType === 'mouse' && e.button !== 0)) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, e.clientY, e.timeStamp);
});
content.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
if (!drag.active) {
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (Math.hypot(dx, dy) < 10) return;
// 縦の動きはページのスクロールに任せる
if (Math.abs(dy) > Math.abs(dx)) {
drag = null;
return;
}
drag.active = true;
drag.x = e.clientX;
x.stop();
drag.origin = x.value;
content.setPointerCapture(e.pointerId);
if (openRow && openRow !== row) openRow.swipe.close();
}
let raw = drag.origin + (e.clientX - drag.x);
if (raw > 0) raw = Fluid.rubberband(raw, row.offsetWidth);
else if (raw < -ACTION_WIDTH) raw = -ACTION_WIDTH - Fluid.rubberband(-ACTION_WIDTH - raw, row.offsetWidth);
x.set(raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
if (!wasActive) return;
suppressClick = true;
setTimeout(() => {
suppressClick = false;
}, 0);
tracker.add(e.clientX, e.clientY, e.timeStamp);
const v = tracker.velocity().x;
settle(x.value + Fluid.project(v) < -ACTION_WIDTH / 2, v);
};
content.addEventListener('pointerup', end);
content.addEventListener('pointercancel', end);
content.addEventListener(
'click',
(e) => {
// スワイプの後のクリックや、開いている行のタップ(参加ボタン以外)は「閉じる」だけにする
const tapOnOpenRow = openRow === row && !e.target.closest('[data-join]');
if (!suppressClick && !tapOnOpenRow) return;
e.preventDefault();
e.stopPropagation();
if (!suppressClick) row.swipe.close();
},
true
);
// キーボードでは削除ボタンにフォーカスが来たら開く
action.addEventListener('focus', () => settle(true));
action.addEventListener('blur', () => settle(false));
}
document.addEventListener('pointerdown', (e) => {
if (openRow && !openRow.contains(e.target)) openRow.swipe.close();
});
// ---------- 一覧(行は使い回す。スワイプの途中で作り直さない) ----------
const rows = new Map();
function rowEl(id) {
let row = rows.get(id);
if (row) return row;
row = document.createElement('div');
row.className = 'swipe-row';
row.innerHTML = `<button type="button" class="swipe-action" data-delete="${esc(id)}">${ICON.trash}<span>削除</span></button><div class="table-row"></div>`;
attachSwipe(row);
rows.set(id, row);
return row;
}
function render(list) {
tables = list;
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
footerEl.hidden = !admin || !tables.length;
if (!tables.length) {
rows.clear();
openRow = null;
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
return;
}
listEl.innerHTML = tables
.map((t) => {
const full = t.playerCount >= t.maxSeats;
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
return `
<div class="table-item">
<div class="info">
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
<div class="meta">
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
<span>開始 <b>${chips(t.startingStack)}</b></span>
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
<span>${timer}</span>
</div>
</div>
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
${full ? '観戦' : '参加'}
</button>
</div>`;
})
.join('');
const empty = listEl.querySelector('.empty');
if (empty) empty.remove();
const ids = new Set(tables.map((t) => t.id));
for (const [id, row] of rows) {
if (ids.has(id)) continue;
if (openRow === row) openRow = null;
row.remove();
rows.delete(id);
}
tables.forEach((t, i) => {
const row = rowEl(t.id);
const html = rowHtml(t);
const content = row.querySelector('.table-row');
if (content.html !== html) {
content.html = html;
content.innerHTML = html;
}
const action = row.querySelector('.swipe-action');
action.hidden = !admin;
action.tabIndex = admin ? 0 : -1;
if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
});
}
const socket = App.connectSocket();
+34
View File
@@ -0,0 +1,34 @@
// 設定: この端末だけに保存する(localStorage)
(function () {
'use strict';
const { Store, selectOnFocus } = App;
const $ = (id) => document.getElementById(id);
Fluid.navBar($('nav'), $('large-title'));
const showNet = $('show-net');
showNet.checked = Store.showNet;
showNet.addEventListener('change', () => {
Store.showNet = showNet.checked;
Fluid.haptic(6);
});
const stack = $('default-stack');
stack.value = Store.defaultStack;
selectOnFocus(stack);
// 正しい値のときだけ保存する。外したときに不正なら保存済みの値に戻す
stack.addEventListener('input', () => {
const n = Number(stack.value);
const ok = Number.isInteger(n) && n >= 10 && n <= 100000000;
stack.classList.toggle('invalid', stack.value !== '' && !ok);
if (ok) Store.defaultStack = n;
});
stack.addEventListener('blur', () => {
stack.classList.remove('invalid');
stack.value = Store.defaultStack;
});
stack.addEventListener('keydown', (e) => {
if (e.key === 'Enter') stack.blur();
});
})();
+612
View File
@@ -0,0 +1,612 @@
// 統計: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブ)と、VPIP・PFR などの数字。
// まとめの画面と、テーブルの種類ごとにすべての数字を出す画面(?v=all)。数字の「?」で段階と説明のシートを開く
(function () {
'use strict';
const { Store, esc, api, toast } = App;
const $ = (id) => document.getElementById(id);
const clamp = Fluid.clamp;
Fluid.navBar($('nav'), $('title'));
let data = null; // { since, modes: { total, chips, friends, cpu } }
let mode = 'total';
// ---------- 段階と色 ----------
// upper: 各段階の上限(整数の %)。最後の段階は上限なし。tone: 段階の色の並び
const STYLE = ['ニット', 'タイト', 'バランス', 'ルーズ', 'フィッシュ'];
const AGGR = ['マウス', 'パッシブ', 'バランス', 'アグレッシブ', 'マニアック'];
const QUALITY = ['低い', '平均', '良い', '高い'];
const QUALITY_TOP = ['低い', '平均', '良い', '優秀'];
const ICON = {
vpip: '✌️',
pfr: '🤞',
ats: '🫳',
threeBet: '☝️',
wtsd: '🤘',
wsd: '🫰',
wwsf: '👌',
fold: '🙅',
afq: '👊',
hands: '✋',
net: '💰',
time: '⏱️',
};
const STATS = {
vpip: {
short: 'VPIP',
title: '自発的な参加率(VPIP)',
upper: [10, 20, 30, 40],
labels: STYLE,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップで自分からチップを入れた(コール・レイズした)ハンドの割合です。BB のチェックは含みません。いちばん基本の数字で、たくさんの手で参加するか(ルーズ)、強い手だけで参加するか(タイト)が分かります。',
},
pfr: {
short: 'PFR',
title: 'プリフロップレイズ(PFR)',
upper: [5, 12, 20, 30],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップでコールではなくレイズしたハンドの割合です。VPIP と組み合わせると打ち方が分かります。よくレイズする人は相手に圧力をかけ、ハンドの主導権を握ります。',
},
ats: {
short: 'ATS',
title: 'スチールの試み(ATS)',
upper: [15, 25, 40, 55],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.atsOpp}回`,
desc: '全員が降りて CO・BTN・SB の自分に回ってきたときに、レイズした割合です。後ろの位置の有利を使ってブラインドを奪いにいけているかが分かります。',
},
threeBet: {
short: '3BET',
title: 'スリーベット(3BET)',
upper: [2, 4, 7, 12],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.threeBetOpp}回`,
desc: 'プリフロップで誰かのレイズに直面したときに、リレイズした割合です。強い手でポットを大きくしたり、ブラフで圧力をかけたりする積極的なプレイで、相手のレイズへの向き合い方が分かります。',
},
wtsd: {
short: 'WTSD',
title: 'ショーダウン到達率(WTSD)',
upper: [20, 24, 28, 32],
labels: STYLE,
tone: 'style',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ショーダウンまで行った割合です。最後まで手を見に行くか、途中で降りるかの傾向が分かります。高すぎるとコールしすぎ、低すぎると降りすぎの目安です。',
},
wsd: {
short: 'W$SD',
title: 'ショーダウン勝率(W$SD)',
upper: [40, 45, 56],
labels: QUALITY,
tone: 'quality',
sample: (m) => `ショーダウン ${m.showdowns}回`,
desc: 'ショーダウンまで行ったハンドのうち、ポットを取った(山分けを含む)割合です。ショーダウンに持っていく手の質が分かります。高いほど、強い手でバリューを取り、弱い手でのショーダウンを避けられています。',
},
wwsf: {
short: 'WWSF',
title: 'フロップ後の勝率(WWSF)',
upper: [37, 42, 47],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ポットを取った割合です。相手を降ろして取った場合もショーダウンで勝った場合も含みます。フロップ以降の上手さを総合的に表す数字です。',
},
afq: {
short: 'AFq',
title: 'アグレッション頻度(AFq)',
upper: [20, 40, 60, 80],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: '自分の行動(フォールド・チェック・コール・ベット・レイズ)のうち、ベット・レイズの割合です。積極的に打つと相手に圧力がかかり、ショーダウンなしで取れるポットが増え、強い手でポットを大きくできます。',
},
hands: {
short: 'ハンド',
title: 'ハンドの勝率',
upper: [8, 14, 20],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `${m.hands}ハンド`,
desc: '配られたハンドのうち、ポットを取った(山分けを含む)割合と、プレイしたハンド数・勝ったハンド数です。プレイの量と結果の移り変わりを追えます。',
},
fold: {
short: 'フォールド率',
title: 'フォールド率',
sample: (m) => `${m.hands}ハンド`,
desc: '各ストリート(プリフロップ・フロップ・ターン・リバー)で自分が判断したハンドのうち、降りた割合です。すぐにあきらめすぎていないか、逆にコールしすぎていないかが分かります。ストリートごとのバランスを見てみましょう。',
},
net: {
short: '収支',
title: '収支(BB)',
sample: (m) => `${m.hands}ハンド`,
desc: 'チップの増減をそのハンドの BB の何倍かに直して合計したものです。ブラインドの大きさが違うテーブルの結果もまとめて比べられます。「100ハンドあたり」は勝ちの速さの目安です(BB/100)。',
},
time: {
short: 'プレイ時間',
title: 'プレイ時間',
sample: (m) => `${m.hands}ハンド`,
desc: 'ハンドを遊んでいた時間の合計です。続けて遊んでいるあいだ(前のハンドから3分以内)は、ハンドの間の待ち時間も含めます。',
},
};
const MODE_LABEL = { total: '合計', chips: 'キャッシュ', friends: '友人戦', cpu: 'CPU戦' };
const STREETS = ['プリフロップ', 'フロップ', 'ターン', 'リバー'];
// 値(%)がどの段階か。端数は四捨五入してから比べる
function bandOf(def, v) {
const r = Math.round(v);
const i = def.upper.findIndex((u) => r <= u);
return i < 0 ? def.upper.length : i;
}
const rangeText = (def, i) => {
const lo = i === 0 ? 0 : def.upper[i - 1] + 1;
return i < def.upper.length ? `${lo}–${def.upper[i]}` : `${lo}+`;
};
const toneClass = (def, i) => `tone-${def.tone}-${i}`;
const pctNum = (v) => (v == null ? '—' : String(Math.round(v)));
const bb = (v) => `${v > 0 ? '+' : v < 0 ? '−' : '±'}${Math.abs(Math.round(v * 10) / 10).toLocaleString('ja-JP')}`;
// 段階のバー(カード用): 今の段階まで塗り、今の段階は中の位置まで塗る
function bandBar(def, v) {
const n = def.upper.length + 1;
if (v == null) return `<div class="st-bar">${'<span class="st-seg"></span>'.repeat(n)}</div>`;
const band = bandOf(def, v);
const segs = [];
for (let i = 0; i < n; i++) {
let fill = 0;
if (i < band) fill = 1;
else if (i === band) {
const lo = i === 0 ? 0 : def.upper[i - 1];
const width = i < def.upper.length ? def.upper[i] - lo : def.upper[i - 1] - (i >= 2 ? def.upper[i - 2] : 0);
fill = v > 0 ? clamp((v - lo) / width, 0.12, 1) : 0;
}
segs.push(`<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${fill.toFixed(3)}"></span></span>`);
}
return `<div class="st-bar">${segs.join('')}</div>`;
}
// 段階の一覧(説明シート用): 名前・バー・範囲。今の段階だけ塗る
function bandScale(def, v) {
const band = v == null ? -1 : bandOf(def, v);
const cols = def.labels
.map(
(label, i) => `
<div class="st-scale-col${i === band ? ' on' : ''}">
<span class="st-scale-label">${label}</span>
<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${i === band ? 1 : 0}"></span></span>
<span class="st-scale-range">${rangeText(def, i)}</span>
</div>`
)
.join('');
return `<div class="st-scale st-scale-${def.labels.length}">${cols}</div>`;
}
// ---------- 図形(SVG) ----------
// 角度は真上を 0 度として時計回り
function arcPath(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.sin((a * Math.PI) / 180), cy - r * Math.cos((a * Math.PI) / 180)];
const [x0, y0] = p(a0);
const [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
// 小さなメーター(フォールド率): 270度の弧
function miniGauge(v) {
const track = arcPath(40, 40, 32, -135, 135);
const value = v ? `<path class="st-gauge-val" d="${arcPath(40, 40, 32, -135, -135 + (270 * clamp(v, 0, 100)) / 100)}"/>` : '';
return `<svg class="st-gauge" viewBox="0 0 80 80" aria-hidden="true"><path class="st-gauge-track" d="${track}"/>${value}</svg>`;
}
// 区切りのある輪(アグレッション頻度・勝率)。値の段階の色で塗る
function ring(v, def) {
const SEGS = 6;
const START = -150;
const SPAN = 300;
const GAP = 7;
const size = SPAN / SEGS;
const tone = v == null ? '' : toneClass(def, bandOf(def, v));
let out = '';
for (let i = 0; i < SEGS; i++) {
const a0 = START + i * size + GAP / 2;
const a1 = START + (i + 1) * size - GAP / 2;
out += `<path class="st-ring-track" d="${arcPath(60, 60, 50, a0, a1)}"/>`;
if (v == null) continue;
const f = clamp(((v / 100) * SPAN - i * size) / size, 0, 1);
if (f > 0) out += `<path class="st-ring-val ${tone}" d="${arcPath(60, 60, 50, a0, a0 + (a1 - a0) * f)}"/>`;
}
return `<svg class="st-ring" viewBox="0 0 120 120" aria-hidden="true">${out}</svg>`;
}
// ---------- プレイスタイル ----------
// ルーズさ = VPIP(25% でちょうど真ん中)、積極性 = PFR ÷ VPIP(半分でちょうど真ん中)
const TYPES = [
{
id: 'TAG',
chip: 'TAG',
emoji: '🦅',
name: 'TAG',
full: 'タイト・アグレッシブ',
desc: '参加するハンドは少ないものの、参加したら強気にベット・レイズする打ち方です。強い手をじっくり待ち、来たら攻めます。いちばん勝ちやすいスタイルの一つとされますが、手の選び方が硬すぎると相手に読まれやすくなります。',
},
{
id: 'LAG',
chip: 'LAG',
emoji: '🐒',
name: 'LAG',
full: 'ルーズ・アグレッシブ',
desc: '広い範囲のハンドで参加し、大きなベットもためらわない打ち方です。何を持っているか読みにくく、良い手でもタイミングの良いブラフでもポットを取れます。大きく勝てる可能性がある一方、結果の振れ幅が大きく、高い技術が必要です。',
},
{
id: 'ROCK',
chip: 'ロック',
emoji: '🦥',
name: 'ロック',
full: 'タイト・パッシブ',
desc: 'めったに参加せず、ベットよりコールを好むとても慎重な打ち方です。ブラフで降ろすのは難しい相手ですが、強い手で取りきるチャンスを逃しがちです。負けは小さく抑えられますが勝ちも伸びにくく、とてもルーズな卓でいちばん効果を発揮します。',
},
{
id: 'FISH',
chip: 'フィッシュ',
emoji: '🐟',
name: 'フィッシュ',
full: 'ルーズ・パッシブ',
desc: 'たくさんのハンドに参加するものの、自分からベット・レイズすることは少ない打ち方です。微妙な手でもコールし、ドローを追いかけがちです。たまに大きな手を当てますが、長い目で見ると負けやすいスタイルです。ブラフが効きにくいので、相手にすると厄介でもあります。',
},
];
function playStyle(m) {
if (!m || m.vpip == null || m.pfr == null) return null;
const loose = clamp(m.vpip / 50, 0.12, 0.92);
const aggr = clamp(m.vpip > 0 ? m.pfr / m.vpip : 0, 0.12, 0.92);
const id = loose >= 0.5 ? (aggr >= 0.5 ? 'LAG' : 'FISH') : aggr >= 0.5 ? 'TAG' : 'ROCK';
return { loose, aggr, id };
}
// 菱形のレーダー(上: アグレッシブ、右: ルーズ、下: パッシブ、左: タイト)と、四隅のスタイル
function styleCardHtml(m) {
const st = playStyle(m);
const W = 360;
const H = 300;
const cx = W / 2;
const cy = 150;
const R = 92;
const pt = (dir, f) => {
const d = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[dir];
return [cx + d[0] * R * f, cy + d[1] * R * f];
};
const diamond = (f) => ['top', 'right', 'bottom', 'left'].map((d) => pt(d, f).map((x) => x.toFixed(1)).join(',')).join(' ');
const grid = [1 / 3, 2 / 3]
.map((f) => `<polygon class="st-radar-ring" points="${diamond(f)}"/>`)
.concat(`<polygon class="st-radar-outer" points="${diamond(1)}"/>`)
.join('');
const axes = `<path class="st-radar-axis" d="M${cx} ${cy - R}V${cy + R}M${cx - R} ${cy}H${cx + R}"/>`;
// 四隅のスタイル
const CHIP_W = 74;
const CHIP_H = 34;
const corners = { TAG: [14, 16], LAG: [W - 14 - CHIP_W, 16], ROCK: [14, H - 16 - CHIP_H], FISH: [W - 14 - CHIP_W, H - 16 - CHIP_H] };
const chips = TYPES.map((t) => {
const [x, y] = corners[t.id];
const on = st && st.id === t.id;
return `<g class="st-chip${on ? ' on' : ''}" data-type="${t.id}">
<rect x="${x}" y="${y}" width="${CHIP_W}" height="${CHIP_H}" rx="12"/>
<text x="${x + CHIP_W / 2}" y="${y + CHIP_H / 2 + 5}" text-anchor="middle">${esc(t.chip)}</text></g>`;
}).join('');
let shape = '';
let link = '';
if (st) {
const poly = [pt('top', st.aggr), pt('right', st.loose), pt('bottom', 1 - st.aggr), pt('left', 1 - st.loose)];
const points = poly.map((p) => p.map((x) => x.toFixed(1)).join(',')).join(' ');
shape = `<polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-v)"/><polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-h)"/>`;
// 自分のスタイルの隅へ線を引く(菱形の、その隅の側の辺の中点から)
const side = { TAG: [0, 3], LAG: [0, 1], ROCK: [2, 3], FISH: [1, 2] }[st.id];
const from = [(poly[side[0]][0] + poly[side[1]][0]) / 2, (poly[side[0]][1] + poly[side[1]][1]) / 2];
const [x, y] = corners[st.id];
const to = [st.id === 'TAG' || st.id === 'ROCK' ? x + CHIP_W - 8 : x + 8, st.id === 'TAG' || st.id === 'LAG' ? y + CHIP_H - 6 : y + 6];
link = `<path class="st-radar-link" d="M${from[0].toFixed(1)} ${from[1].toFixed(1)}L${to[0]} ${to[1]}"/>`;
}
const label = (x, y, cls, a, b) =>
`<text class="st-axis ${cls}" x="${x}" y="${y}" text-anchor="middle">${a}</text><text class="st-axis-sub" x="${x}" y="${y + 15}" text-anchor="middle">${b}</text>`;
return `
<svg class="st-radar" viewBox="0 0 ${W} ${H}" role="img" aria-label="${st ? `プレイスタイル: ${esc(TYPES.find((t) => t.id === st.id).full)}` : 'プレイスタイル: データなし'}">
<defs>
<linearGradient id="st-grad-v" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff4f9a"/><stop offset="1" stop-color="#3fc8f4"/>
</linearGradient>
<linearGradient id="st-grad-h" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b45cf5" stop-opacity="0.95"/><stop offset="0.5" stop-color="#b45cf5" stop-opacity="0"/>
<stop offset="0.5" stop-color="#ffab3d" stop-opacity="0"/><stop offset="1" stop-color="#ffab3d" stop-opacity="0.95"/>
</linearGradient>
</defs>
${grid}${axes}${link}${shape}${chips}
${label(cx, 36, 'aggr', 'アグレッシブ', 'ベット・レイズが多い')}
${label(cx, H - 22, 'passive', 'パッシブ', 'コールが多い')}
${label(46, cy - 2, 'tight', 'タイト', '参加が少ない')}
${label(W - 46, cy - 2, 'loose', 'ルーズ', '参加が多い')}
</svg>
${st ? '' : '<div class="st-empty-note">まだデータがありません</div>'}`;
}
// ---------- カード ----------
const helpBtn = (key) =>
`<button type="button" class="st-help" data-info="${key}" aria-label="${esc(STATS[key].title)}の説明"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.6.3-1 .8-1 1.5v.3M12 16.6v.01"/></svg></button>`;
const head = (key, label = STATS[key].short) =>
`<div class="st-head"><span class="st-icon" aria-hidden="true">${ICON[key]}</span><span class="st-name">${esc(label)}</span>${helpBtn(key)}</div>`;
const value = (v, unit = '%') => `<div class="st-value">${pctNum(v)}${v == null ? '' : `<small>${unit}</small>`}</div>`;
function metricCard(key, m, compact = false) {
const def = STATS[key];
const v = m[key];
return `<div class="st-card st-metric${compact ? ' compact' : ''}">${head(key)}${value(v)}${bandBar(def, v)}
<div class="st-band">${v == null ? 'データなし' : def.labels[bandOf(def, v)]}</div></div>`;
}
function liquidCard(key, m) {
const v = m[key];
return `<div class="st-card st-liquid-card"><div class="st-liquid" data-level="${v == null ? 0 : clamp(v, 0, 100)}"></div>${head(key)}${value(v)}</div>`;
}
// ストリートごとのフォールド率(4つのメーター)
const foldMinis = (m) =>
`<div class="st-minis">${m.fold
.map(
(v, i) =>
`<div class="st-mini"><div class="st-mini-gauge">${miniGauge(v)}<span><span>${pctNum(v)}<small>${v == null ? '' : '%'}</small></span></span></div><span class="st-mini-label">${STREETS[i]}</span></div>`
)
.join('')}</div>`;
function foldCard(m) {
return `<div class="st-card">${head('fold')}${foldMinis(m)}</div>`;
}
function ringCard(key, m, v, sub, rows) {
const def = STATS[key];
const list = rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('');
return `<div class="st-card">${head(key)}
<div class="st-split">
<div class="st-ring-box">${ring(v, def)}<div class="st-ring-text">${value(v)}<span>${esc(sub)}</span></div></div>
<div class="st-rows">${list}</div>
</div></div>`;
}
function netCard(m) {
const rows = [
['100ハンドあたり', m.bb100 == null ? '—' : `${bb(m.bb100)} BB`],
['プレイ時間', `${m.hours} 時間`],
];
return `<div class="st-card">${head('net')}
<div class="st-split">
<div class="st-net"><b class="${m.netBB > 0 ? 'plus' : m.netBB < 0 ? 'minus' : ''}">${m.hands ? bb(m.netBB) : '—'}</b><span>BB</span></div>
<div class="st-rows">${rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('')}</div>
</div></div>`;
}
// 塗る量・水位は CSSOM で入れる(CSP でインラインスタイルを禁止しているため)。
// 入れた直後は 0 から伸ばす
function paintFills(root) {
const fills = root.querySelectorAll('[data-fill]');
const levels = root.querySelectorAll('[data-level]');
requestAnimationFrame(() =>
requestAnimationFrame(() => {
for (const el of fills) el.style.transform = `scaleX(${el.dataset.fill})`;
for (const el of levels) el.style.setProperty('--level', `${el.dataset.level}%`);
})
);
}
// ---------- まとめ ----------
function renderSummary() {
const m = data.modes.total;
$('style-card').innerHTML = styleCardHtml(m);
const since = data.since ? `${new Date(data.since).toLocaleDateString('ja-JP')} から` : 'まだ記録がありません';
$('summary-tiles').innerHTML = `
${metricCard('vpip', m, true)}
<div class="st-card st-metric compact">${head('time', 'プレイ時間')}<div class="st-value">${m.hours}<small>時間</small></div><div class="st-band">${since}</div></div>
${metricCard('pfr', m, true)}
${metricCard('afq', m, true)}`;
paintFills($('summary-tiles'));
}
// ---------- すべての統計 ----------
function renderAll() {
const m = data.modes[mode];
for (const b of $('mode-tabs').querySelectorAll('[data-mode]')) b.classList.toggle('active', b.dataset.mode === mode);
modeSeg.update();
const afqRows = STREETS.map((s, i) => [s, m.afqStreets[i] == null ? '—' : `${pctNum(m.afqStreets[i])}%`]);
$('all-body').innerHTML = `
${m.hands ? '' : `<div class="st-card st-empty">${MODE_LABEL[mode] === '合計' ? 'まだ' : `${MODE_LABEL[mode]}では`}記録がありません。テーブルで遊ぶとハンドごとに記録されます。</div>`}
<div class="st-grid st-grid-2">
${metricCard('vpip', m)}${metricCard('pfr', m)}${metricCard('ats', m)}${metricCard('threeBet', m)}
</div>
<div class="st-grid st-grid-3">${liquidCard('wtsd', m)}${liquidCard('wsd', m)}${liquidCard('wwsf', m)}</div>
${foldCard(m)}
${ringCard('afq', m, m.afq, m.afq == null ? 'データなし' : STATS.afq.labels[bandOf(STATS.afq, m.afq)], afqRows)}
${ringCard('hands', m, m.winRate, '勝率', [['プレイ', m.hands.toLocaleString('ja-JP')], ['勝ち', m.won.toLocaleString('ja-JP')]])}
${netCard(m)}`;
paintFills($('all-body'));
}
const modeSeg = Fluid.Segmented($('mode-tabs'));
$('mode-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-mode]');
if (!b || b.dataset.mode === mode) return;
mode = b.dataset.mode;
Fluid.haptic(6);
renderAll();
});
// ---------- 説明シート ----------
const infoSheet = Fluid.Sheet($('info-modal'));
let shareText = '';
function openInfo(key) {
if (!data) return;
const def = STATS[key];
const m = data.modes[$('view-all').hidden ? 'total' : mode];
const where = $('view-all').hidden || mode === 'total' ? '' : `(${MODE_LABEL[mode]})`;
let body = '';
let v = null;
if (key === 'fold') {
body = foldMinis(m);
shareText = `Hold'em Night のフォールド率${where}: ${STREETS.map((s, i) => `${s} ${pctNum(m.fold[i])}%`).join(' / ')}`;
} else if (key === 'net') {
body = `<div class="st-info-value">${m.hands ? `${bb(m.netBB)} BB` : '—'}</div>`;
shareText = `Hold'em Night の収支${where}: ${bb(m.netBB)} BB(${m.hands}ハンド)`;
} else if (key === 'time') {
body = `<div class="st-info-value">${m.hours} 時間</div>`;
shareText = `Hold'em Night のプレイ時間: ${m.hours} 時間(${m.hands}ハンド)`;
} else {
v = key === 'hands' ? m.winRate : m[key];
body = `<div class="st-info-value">${pctNum(v)}${v == null ? '' : '<small>%</small>'}</div>${bandScale(def, v)}`;
shareText =
v == null
? `Hold'em Night の ${def.title}${where}: まだデータがありません`
: `Hold'em Night の ${def.title}${where}: ${Math.round(v)}%(${def.labels[bandOf(def, v)]})`;
}
$('info-body').innerHTML = `
<div class="st-info-icon" aria-hidden="true">${ICON[key]}</div>
<h2 class="st-info-title" id="info-title">${esc(def.title)}</h2>
${body}
<p class="st-info-desc">${esc(def.desc)}</p>
<p class="st-info-sample">${esc(MODE_LABEL[$('view-all').hidden ? 'total' : mode])} ・ ${esc(def.sample(m))}</p>`;
paintFills($('info-body'));
infoSheet.open();
}
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-info]');
if (b) openInfo(b.dataset.info);
});
async function share(text) {
if (!text) return;
if (navigator.share) {
try {
await navigator.share({ text });
return;
} catch (err) {
if (err && err.name === 'AbortError') return; // 共有シートを閉じた
}
}
try {
if (await App.copyText(() => Promise.resolve(text))) toast('コピーしました。友達に送れます');
} catch {
toast('コピーできませんでした');
}
}
$('info-share').addEventListener('click', () => share(shareText));
// ---------- プレイスタイルのシート(横に送る) ----------
const typeSheet = Fluid.Sheet($('type-modal'));
$('type-carousel').innerHTML = TYPES.map(
(t) => `
<article class="st-type" data-type="${t.id}">
<div class="st-type-emoji" aria-hidden="true">${t.emoji}</div>
<h2 class="st-type-name">${esc(t.name)}</h2>
<div class="st-type-full">(${esc(t.full)})</div>
<p class="st-type-desc">${esc(t.desc)}</p>
<p class="st-type-mine" hidden>あなたのいまのスタイル</p>
</article>`
).join('');
$('type-dots').innerHTML = TYPES.map(() => '<span></span>').join('');
const typeCards = [...$('type-carousel').children];
function currentType() {
const c = $('type-carousel');
return clamp(Math.round(c.scrollLeft / (typeCards[1].offsetLeft - typeCards[0].offsetLeft || 1)), 0, TYPES.length - 1);
}
function syncDots() {
const i = currentType();
[...$('type-dots').children].forEach((d, k) => d.classList.toggle('on', k === i));
}
$('type-carousel').addEventListener('scroll', syncDots, { passive: true });
function openTypes(id) {
const st = data && playStyle(data.modes.total);
for (const el of typeCards) el.querySelector('.st-type-mine').hidden = !(st && st.id === el.dataset.type);
typeSheet.open();
const i = Math.max(0, TYPES.findIndex((t) => t.id === (id || (st && st.id))));
const c = $('type-carousel');
c.scrollLeft = typeCards[i].offsetLeft - (c.clientWidth - typeCards[i].offsetWidth) / 2;
syncDots();
}
$('style-card').addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('[data-type]');
openTypes(chip ? chip.dataset.type : null);
});
$('style-card').addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
openTypes(null);
});
$('type-share').addEventListener('click', () => {
const t = TYPES[currentType()];
const st = data && playStyle(data.modes.total);
share(
st && st.id === t.id
? `Hold'em Night での私のプレイスタイルは「${t.name}(${t.full})」${t.emoji} VPIP ${pctNum(data.modes.total.vpip)}% / PFR ${pctNum(data.modes.total.pfr)}%`
: `${t.emoji} ${t.name}(${t.full}): ${t.desc}`
);
});
// ---------- 画面の切り替え(?v=all ですべての統計) ----------
function route() {
const all = new URLSearchParams(location.search).get('v') === 'all';
$('view-summary').hidden = all;
$('view-all').hidden = !all;
$('title').textContent = all ? 'すべての統計' : '統計';
$('back').href = all ? '/stats' : '/';
$('back-label').textContent = all ? '統計' : 'ロビー';
if (!data) return;
if (all) renderAll();
else renderSummary();
}
window.addEventListener('popstate', () => {
route();
window.scrollTo(0, 0);
});
$('see-all').addEventListener('click', () => {
history.pushState({ fromSummary: true }, '', '/stats?v=all');
route();
window.scrollTo(0, 0);
});
$('back').addEventListener('click', (e) => {
if ($('view-all').hidden) return; // ロビーへはそのまま移る
e.preventDefault();
if (history.state && history.state.fromSummary) history.back();
else {
history.replaceState(null, '', '/stats');
route();
}
window.scrollTo(0, 0);
});
// ---------- 読み込み ----------
async function load() {
const btn = $('refresh');
btn.classList.add('spinning');
btn.disabled = true;
try {
data = await api('POST', '/api/stats', { token: Store.token });
route();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
btn.classList.remove('spinning');
}
}
$('refresh').addEventListener('click', load);
route();
load();
})();
+3839 -492
View File
File diff suppressed because it is too large. Load diff
+1114 -132
View File
File diff suppressed because it is too large. Load diff
+117 -37
View File
@@ -2,57 +2,137 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>テーブル作成 - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<main class="wrap">
<div class="topbar">
<a class="icon-btn" href="/" aria-label="戻る">←</a>
<h1>テーブルを作成</h1>
<body class="page-create">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap">
<h1 class="large-title" id="large-title">テーブルを作成</h1>
<form id="form" novalidate>
<label for="table-name">テーブル名</label>
<div class="row">
<input id="table-name" type="text" maxlength="24" required>
<button type="button" class="icon-btn" id="reroll" aria-label="別の名前にする">🎲</button>
<section class="group">
<div class="group-header"><label for="table-name">テーブル名</label></div>
<div class="list">
<div class="cell field-cell">
<input id="table-name" type="text" maxlength="24" required>
<button type="button" class="icon-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>
</section>
<section class="group">
<div class="group-header" id="mode-label">テーブルの種類</div>
<div class="list" role="radiogroup" aria-labelledby="mode-label">
<label class="cell choice">
<input type="radio" name="mode" value="chips" checked>
<span class="cell-icon tone-green"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg></span>
<span class="choice-text"><span class="cell-title">キャッシュオンリー</span><span class="cell-sub">実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<label class="cell choice">
<input type="radio" name="mode" value="friends">
<span class="cell-icon tone-blue"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg></span>
<span class="choice-text"><span class="cell-title">友人戦</span><span class="cell-sub">各自のスマホに手札が配られ、勝敗とポットの分配は自動</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<label class="cell choice">
<input type="radio" name="mode" value="cpu">
<span class="cell-icon tone-indigo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg></span>
<span class="choice-text"><span class="cell-title">CPU戦</span><span class="cell-sub">CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
<!-- 管理者だけに表示 -->
<label class="cell choice" id="review-mode" hidden>
<input type="radio" name="mode" value="review">
<span class="cell-icon tone-orange"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg></span>
<span class="choice-text"><span class="cell-title">レビュー付きCPU戦</span><span class="cell-sub">あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる</span></span>
<svg class="i check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</label>
</div>
</section>
<div class="collapsible collapsed" id="cpu-options" inert>
<div class="collapse-inner">
<section class="group">
<div class="group-header" id="cpu-label">CPU の人数</div>
<div class="segmented numbers" role="radiogroup" aria-labelledby="cpu-label">
<label><input type="radio" name="cpu-count" value="1"><span>1</span></label>
<label><input type="radio" name="cpu-count" value="2"><span>2</span></label>
<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>
<label for="stack">開始スタック</label>
<input id="stack" type="number" inputmode="numeric" min="10" step="1" value="1000">
<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>
<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="ビッグブラインド">
<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>
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
<span class="unit">分</span>
</div>
</div>
</div>
</div>
<div class="schedule" id="schedule" hidden></div>
</section>
<div class="submit-area">
<div class="error-text" id="error" role="alert"></div>
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">テーブルを作成</button>
</div>
<div class="hint" id="blind-hint">スタックに合わせて自動設定(BB ≒ スタックの1/100)</div>
<div class="switch-row">
<span>ブラインドタイマー</span>
<label class="switch">
<input id="timer" type="checkbox" aria-label="ブラインドタイマー">
<span></span>
</label>
</div>
<label for="interval">インターバル</label>
<div class="unit-input">
<input id="interval" type="number" inputmode="numeric" min="1" max="240" step="1" value="10" disabled>
<span class="unit">min</span>
</div>
<div class="schedule" id="schedule" hidden></div>
<div class="error-text" id="error"></div>
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">テーブルを作成</button>
</form>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/blinds.js"></script>
<script src="/assets/create.js"></script>
+17 -10
View File
@@ -2,19 +2,25 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<main class="wrap">
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
<body class="page-key">
<main class="key-screen">
<div class="app-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
</div>
<h1 class="brand">Hold'em Night</h1>
<p class="brand-sub">PRIVATE CLUB</p>
<form id="form" class="card" autocomplete="off">
<h2 class="key-title">参加キーを入力</h2>
<p class="hint key-hint">オーナーから教えてもらった6桁の数字</p>
<form id="form" class="key-form" autocomplete="off">
<h2 class="key-title" id="key-title">参加キーを入力</h2>
<p class="key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
<div class="key-inputs" id="digits">
<input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code">
<input type="tel" inputmode="numeric" aria-label="2桁目">
@@ -23,10 +29,11 @@
<input type="tel" inputmode="numeric" aria-label="5桁目">
<input type="tel" inputmode="numeric" aria-label="6桁目">
</div>
<div class="error-text text-center" id="error"></div>
<button class="btn btn-orange btn-block mt-8" id="submit" type="submit">入室する</button>
<p class="error-text" id="error" role="alert"></p>
<button class="btn btn-tint btn-lg btn-block" id="submit" type="submit">入室する</button>
</form>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/key.js"></script>
</body>
+72 -16
View File
@@ -2,32 +2,88 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<body class="page-lobby">
<header class="nav-bar" id="nav">
<div class="nav-inner"><span class="nav-title"></span></div>
</header>
<main class="wrap">
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
<label for="name">あなたの名前</label>
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
<div class="error-text" id="name-error"></div>
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
<div class="section-title">
<span>開催中のテーブル</span>
<span class="sub" id="count"></span>
<div class="hero">
<div class="app-mark small" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2.2c-2.3 3.1-7.6 6.3-7.6 10.4a4.1 4.1 0 0 0 7 2.9c-.1 2.3-1 4-2.6 5.3h6.4c-1.6-1.3-2.5-3-2.6-5.3a4.1 4.1 0 0 0 7-2.9c0-4.1-5.3-7.3-7.6-10.4z"/></svg>
</div>
<div>
<h1 class="large-title" id="large-title">Hold'em Night</h1>
<p class="brand-sub">PRIVATE CLUB</p>
</div>
</div>
<div class="table-list" id="list">
<div class="empty">読み込み中…</div>
<section class="group">
<div class="group-header">プロフィール</div>
<div class="list">
<div class="cell field-cell">
<label for="name">名前</label>
<input id="name" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname">
</div>
<a class="cell cell-link" href="/stats">
<span class="cell-icon tone-blue"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5l8 8.5-8 8.5-8-8.5z"/><path d="M12 8l4 4-4 4-4-4z"/></svg></span>
<span class="cell-title">統計<span class="cell-sub">プレイスタイル・VPIP・PFR など</span></span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
<a class="cell cell-link" href="/settings">
<span class="cell-icon tone-gray"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg></span>
<span class="cell-title">設定<span class="cell-sub">収支の表示・開始スタック</span></span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
<div class="error-text" id="name-error" role="alert"></div>
</section>
<div class="lobby-actions">
<button id="create" class="btn btn-tint btn-lg btn-block" type="button">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>テーブルを作成
</button>
</div>
<!-- 管理者だけに表示 -->
<section class="group" id="admin-group" hidden>
<div class="group-header">管理者</div>
<div class="list">
<a id="range-link" class="cell cell-link" href="/range">
<span class="cell-icon tone-purple"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/></svg></span>
<span class="cell-title">ハンドレンジ学習</span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
<a id="review-link" class="cell cell-link" href="/review">
<span class="cell-icon tone-orange"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20v-8M12 20V5M19 20v-9"/></svg></span>
<span class="cell-title">CPU戦のレビュー</span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
</section>
<section class="group">
<div class="group-header">
<span>開催中のテーブル</span>
<span id="count"></span>
</div>
<div class="list" id="list">
<div class="empty">読み込み中…</div>
</div>
<div class="group-footer" id="list-footer" hidden>行を左へスワイプすると削除できます(管理者)。</div>
</section>
</main>
<script src="/socket.io/socket.io.js"></script>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/lobby.js"></script>
</body>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>設定 - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="page-settings">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>ロビー</a>
<span class="nav-title"></span>
</div>
</header>
<main class="wrap">
<h1 class="large-title" id="large-title">設定</h1>
<section class="group">
<div class="group-header">テーブル</div>
<div class="list">
<div class="cell">
<span class="cell-title" id="show-net-label">収支を表示</span>
<span class="switch">
<input id="show-net" type="checkbox" aria-labelledby="show-net-label">
<span></span>
</span>
</div>
</div>
<div class="group-footer">オフにすると、テーブルで自分の収支と、ランキングの「収支」が表示されなくなります。この端末だけの設定で、他の人の画面は変わりません。</div>
</section>
<section class="group">
<div class="group-header">テーブルの作成</div>
<div class="list">
<div class="cell field-cell value-right">
<label for="default-stack">開始スタック</label>
<input id="default-stack" type="number" inputmode="numeric" min="10" step="1">
</div>
</div>
<div class="group-footer" id="stack-hint">テーブルを作るときの初期値です。テーブルを作ると、そのときの値(SB・BB も)が次回の初期値になります。</div>
</section>
</main>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/settings.js"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f2f7">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">
<meta name="robots" content="noindex, nofollow">
<title>統計 - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="page-stats">
<header class="nav-bar" id="nav">
<div class="nav-inner">
<a class="nav-back" id="back" href="/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg><span id="back-label">ロビー</span></a>
<span class="nav-title"></span>
<button type="button" class="nav-action" id="refresh" aria-label="更新" title="更新">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4.2h-4.2"/></svg>
</button>
</div>
</header>
<main class="wrap stats-wrap">
<h1 class="large-title" id="title">統計</h1>
<!-- まとめ: プレイスタイルと主な数字 -->
<section id="view-summary">
<div class="st-card st-style" id="style-card" role="button" tabindex="0" aria-label="プレイスタイルの説明を開く"></div>
<div class="st-grid st-grid-2 mt-12" id="summary-tiles"></div>
<button type="button" class="btn btn-lg btn-block mt-12" id="see-all">すべての統計を見る</button>
</section>
<!-- すべての統計(テーブルの種類ごと) -->
<section id="view-all" hidden>
<div class="segmented st-tabs" id="mode-tabs" aria-label="テーブルの種類">
<button type="button" class="seg-btn active" data-mode="total">合計</button>
<button type="button" class="seg-btn" data-mode="chips">キャッシュ</button>
<button type="button" class="seg-btn" data-mode="friends">友人戦</button>
<button type="button" class="seg-btn" data-mode="cpu">CPU戦</button>
</div>
<div id="all-body"></div>
</section>
<p class="hint st-foot">統計はこのブラウザのプレイヤーIDごとに、ハンドが終わるたびに記録しています。別の端末やブラウザで遊んだ分は別に数えます。CPU戦にはレビュー付きCPU戦を含みます。</p>
</main>
<!-- 1つの数字の説明(段階・説明・共有) -->
<div class="sheet-backdrop" id="info-modal" hidden>
<div class="sheet st-info-sheet" role="dialog" aria-modal="true" aria-labelledby="info-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span></div>
<div class="sheet-scroll" id="info-body"></div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block st-share" id="info-share">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5v11M8 7l4-3.5L16 7"/><path d="M8.5 10.5H7A2.5 2.5 0 0 0 4.5 13v5A2.5 2.5 0 0 0 7 20.5h10a2.5 2.5 0 0 0 2.5-2.5v-5a2.5 2.5 0 0 0-2.5-2.5h-1.5"/></svg>友達に共有
</button>
</div>
</div>
</div>
<!-- プレイスタイル4種類(横に送って見る) -->
<div class="sheet-backdrop" id="type-modal" hidden>
<div class="sheet st-info-sheet" role="dialog" aria-modal="true" aria-label="プレイスタイル" tabindex="-1">
<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span></div>
<div class="st-carousel" id="type-carousel"></div>
<div class="st-dots" id="type-dots" aria-hidden="true"></div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block st-share" id="type-share">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5v11M8 7l4-3.5L16 7"/><path d="M8.5 10.5H7A2.5 2.5 0 0 0 4.5 13v5A2.5 2.5 0 0 0 7 20.5h10a2.5 2.5 0 0 0 2.5-2.5v-5a2.5 2.5 0 0 0-2.5-2.5h-1.5"/></svg>友達に共有
</button>
</div>
</div>
</div>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/stats.js"></script>
</body>
</html>
+187 -56
View File
@@ -2,27 +2,47 @@
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#eceef1">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#070c0a">
<meta name="robots" content="noindex, nofollow">
<title>テーブル - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="table-page">
<div class="conn-lost" id="conn-lost" hidden>接続が切れました。再接続中…</div>
<body class="page-table">
<div class="conn-lost" id="conn-lost" role="status" hidden><span class="spinner" aria-hidden="true"></span>接続が切れました。再接続中…</div>
<main class="wrap" id="main" hidden>
<div class="table-header">
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
<main class="table-main" id="main" hidden>
<header class="table-bar">
<button class="glass-btn" id="leave" aria-label="退席する" title="退席する">
<svg class="i icon-leave" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h3.5A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5H14"/><path d="M10 8l-4 4 4 4M6 12h9"/></svg>
<svg class="i icon-back" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<h1 id="table-name"></h1>
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
</div>
<div class="host-controls" id="host-controls" hidden>
<button class="btn btn-green" id="start">START ▶</button>
<button class="btn btn-dark" id="pause">一時停止</button>
</div>
<div class="host-spacer" id="host-spacer"></div>
<!-- ホストだけに表示 -->
<span class="host-controls" id="host-controls" hidden>
<button class="btn btn-tint btn-sm" id="start">START</button>
<button class="glass-btn" id="pause" aria-label="一時停止" title="一時停止">
<svg class="i i-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3"/><rect x="13.5" y="5" width="4" height="14" rx="1.3"/></svg>
<svg class="i i-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.2-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z"/></svg>
</button>
<button class="glass-btn" id="seating-open" aria-label="席順を変更" title="席順を変更">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M7.5 20V5M4 8.5L7.5 5 11 8.5M16.5 4v15M13 15.5l3.5 3.5 3.5-3.5"/></svg>
<span class="glass-dot" id="seating-dot" hidden></span>
</button>
</span>
<!-- レビュー付きCPU戦のみ -->
<button class="glass-btn" id="review-open" aria-label="直前のハンドのレビュー" title="直前のハンドのレビュー" hidden>
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>
</button>
<button class="glass-btn" id="ranking-open" aria-label="ランキング" title="ランキング">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4h8v5a4 4 0 0 1-8 0z"/><path d="M8 5.5H5.5v1A3 3 0 0 0 8 9.5M16 5.5h2.5v1A3 3 0 0 1 16 9.5M12 13v3.5M8.5 20h7M9.5 20l.5-3.5h4l.5 3.5"/></svg>
</button>
<button class="glass-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/></svg>
</button>
</header>
<div class="level-bar">
<span class="lv" id="level"></span>
@@ -35,50 +55,40 @@
</div>
<div class="felt-area" id="felt-area">
<div class="felt">
<div class="pot-label">POT</div>
<div class="pot" id="pot">$0</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="rail">
<div class="felt">
<div class="pot-label">POT</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="result" id="result" hidden></div>
</div>
</div>
<div id="seats"></div>
</div>
<div class="spectator-note" id="spectator-note" hidden>
<span id="spectator-text">満席のため観戦中です</span>
<button class="btn btn-orange btn-sm" id="take-seat" hidden>空席に座る</button>
<button class="btn btn-tint btn-sm" id="take-seat" hidden>空席に座る</button>
</div>
</main>
<div class="action-bar" id="action-bar" hidden>
<section class="action-bar" id="action-bar" hidden>
<div class="inner">
<div class="me-row">
<div class="my-cards" id="my-cards" hidden></div>
<div class="me-info">
<div class="my-stack"><span class="wallet">👛</span><span id="my-stack">$0</span></div>
<div class="me-sub"><span id="my-net"></span><span class="my-bet" id="my-bet"></span></div>
<div class="my-stack" id="my-stack">$0</div>
<div class="me-sub"><span class="my-hand" id="my-hand"></span><span id="my-net"></span><span class="my-bet" id="my-bet"></span></div>
</div>
<button class="btn btn-ghost btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
<button class="btn btn-sm" id="sit-out" title="次のハンドから参加しません">離席</button>
</div>
<div class="status-line" id="status-line"></div>
<div class="status-line" id="status-line" aria-live="polite"></div>
<div class="award-panel" id="award-panel" hidden>
<div id="award-pots"></div>
<button class="btn btn-orange btn-block" id="award-confirm" disabled>確定</button>
</div>
<div class="raise-panel" id="raise-panel" hidden>
<div class="presets" id="presets">
<button class="btn btn-dark btn-sm" data-preset="min">Min</button>
<button class="btn btn-dark btn-sm" data-preset="half">½</button>
<button class="btn btn-dark btn-sm" data-preset="threeq">¾</button>
<button class="btn btn-dark btn-sm" data-preset="pot">Pot</button>
<button class="btn btn-dark btn-sm" data-preset="max">All-in</button>
</div>
<div class="raise-row">
<input type="range" id="raise-range" aria-label="レイズ額">
<input type="number" id="raise-amount" inputmode="numeric" aria-label="レイズ額">
</div>
<div class="award-pots" id="award-pots"></div>
<button class="btn btn-tint btn-lg btn-block" id="award-confirm" disabled>確定</button>
</div>
<div class="actions" id="actions">
@@ -86,33 +96,154 @@
<button class="btn act-call" id="act-call" disabled>Check</button>
<button class="btn act-raise" id="act-raise" disabled>Raise</button>
</div>
<div class="actions raising" id="raise-actions" hidden>
<button class="btn btn-dark" id="raise-cancel">戻る</button>
<button class="btn act-raise" id="raise-confirm">Raise</button>
<div class="actions single" id="next-actions" hidden>
<button class="btn" id="review-show" hidden>レビュー</button>
<button class="btn btn-tint" id="next-hand">次のハンドへ</button>
</div>
<div class="actions single" id="alt-actions" hidden>
<button class="btn btn-orange" id="sit-in">復帰する</button>
<button class="btn btn-tint" id="sit-in">復帰する</button>
<button class="btn btn-blue" id="rebuy">リバイ</button>
</div>
</div>
</section>
<div class="center-message" id="not-found" hidden>
<div class="badge-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.5v.01"/></svg></div>
<h2 id="not-found-title">テーブルが見つかりません</h2>
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
<a class="btn btn-tint btn-lg" href="/">ロビーへ戻る</a>
</div>
<!-- ベット/レイズ額(Bet・Raise ボタンで下から出るシート。つまみを下へ払うと閉じる)。
候補はプリフロップのオープン・3bet・4bet、フロップ以降のベット・レイズで入れ替わる -->
<div class="sheet-backdrop bet-backdrop" id="bet-modal" hidden>
<div class="sheet bet-sheet" role="dialog" aria-modal="true" aria-labelledby="bet-title" tabindex="-1">
<div class="sheet-header bet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<div class="bet-heading">
<h2 class="sheet-title" id="bet-title">Raise</h2>
<span class="bet-context" id="bet-context"></span>
</div>
</div>
<div class="bet-body">
<div class="bet-amount-row">
<label class="raise-value"><span class="cur" aria-hidden="true">$</span><input type="number" class="raise-amount" id="raise-amount" inputmode="numeric" aria-label="ベット額"></label>
<div class="raise-meta"><b id="raise-bb"></b><span id="raise-ratio"></span></div>
</div>
<div class="bet-presets" id="presets"></div>
<div class="bet-adjust">
<button type="button" class="step-btn" id="raise-minus" aria-label="1BB 減らす">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12"/></svg>
</button>
<div class="bet-slider" id="raise-range" role="slider" tabindex="0" aria-label="ベット額">
<div class="bet-track"><div class="bet-fill"></div></div>
<div class="bet-thumb"></div>
</div>
<button type="button" class="step-btn" id="raise-plus" aria-label="1BB 増やす">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 6v12M6 12h12"/></svg>
</button>
</div>
<div class="bet-ends">
<button type="button" class="end-btn" id="raise-min">Min <span id="raise-min-amt"></span></button>
<button type="button" class="end-btn allin" id="raise-max">All-in <span id="raise-max-amt"></span></button>
</div>
</div>
<div class="sheet-footer">
<div class="btn-row">
<button type="button" class="btn btn-lg" id="raise-cancel">戻る</button>
<button type="button" class="btn btn-lg act-raise" id="raise-confirm">Raise</button>
</div>
</div>
</div>
</div>
<div class="center-message" id="not-found" hidden>
<h2>テーブルが見つかりません</h2>
<p>終了したか、URLが間違っている可能性があります。</p>
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
<!-- 席順の変更(ホストのみ・画面の中央に出るポップアップ)。自分から時計回りの並びを、行をつかんで並べ替える -->
<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="modal-backdrop" id="name-modal" hidden>
<form class="modal" id="name-form">
<h2>名前を入力</h2>
<p class="hint">テーブルで表示される名前です</p>
<input id="name-input" type="text" maxlength="12" placeholder="例: とらのすけ" class="mt-12" autocomplete="nickname">
<button class="btn btn-orange btn-block mt-12" type="submit">参加する</button>
<!-- テーブル内のランキング(スタック順・収支順を切り替える) -->
<div class="sheet-backdrop" id="ranking-modal" hidden>
<div class="sheet ranking-sheet" role="dialog" aria-modal="true" aria-labelledby="ranking-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<h2 class="sheet-title" id="ranking-title">ランキング</h2>
</div>
<div class="sheet-scroll">
<div class="segmented" id="ranking-tabs" aria-label="並べ替え">
<button type="button" class="seg-btn active" data-rank="stack" aria-pressed="true">スタック</button>
<button type="button" class="seg-btn" data-rank="net" aria-pressed="false">収支</button>
</div>
<ol class="list ranking-list mt-12" id="ranking-list"></ol>
<p class="hint ranking-note" id="ranking-note"></p>
</div>
<div class="sheet-footer">
<button type="button" class="btn btn-lg btn-block" id="ranking-close">閉じる</button>
</div>
</div>
</div>
<!-- 直前のハンドのレビュー(下から出るシート。つまみを下へ払うと閉じる) -->
<div class="sheet-backdrop" id="review-modal" hidden>
<div class="sheet review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-title" tabindex="-1">
<div class="sheet-header" data-sheet-drag>
<span class="sheet-grabber" aria-hidden="true"></span>
<h2 class="sheet-title" id="review-title">ハンドのレビュー</h2>
</div>
<div class="sheet-scroll">
<div id="review-body"></div>
<button type="button" class="btn btn-block mt-12" id="review-copy">
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>AIに質問する用にコピー
</button>
<div class="list mt-12">
<a class="cell cell-link" id="review-link" href="/review">
<span class="cell-title">対局全体のレビュー<span class="cell-sub">点数表・成績・リプレイ</span></span>
<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
</div>
<div class="sheet-footer">
<div class="btn-row">
<button type="button" class="btn btn-lg" id="review-close">閉じる</button>
<button type="button" class="btn btn-tint btn-lg" id="review-modal-next">次のハンドへ</button>
</div>
</div>
</div>
</div>
<!-- 名前の入力(閉じられないシート) -->
<div class="sheet-backdrop" id="name-modal" hidden>
<form class="sheet sheet-form" id="name-form" role="dialog" aria-modal="true" aria-labelledby="name-title" tabindex="-1">
<h2 class="sheet-title" id="name-title">名前を入力</h2>
<p class="hint mt-8">テーブルで表示される名前です</p>
<input id="name-input" class="field mt-12" type="text" maxlength="12" placeholder="例: とらのすけ" autocomplete="nickname" aria-labelledby="name-title">
<button class="btn btn-tint btn-lg btn-block mt-12" type="submit">参加する</button>
</form>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/assets/fluid.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/table.js"></script>
</body>
+65
View File
@@ -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 };
+49 -15
View File
@@ -25,11 +25,13 @@ function loadSecret() {
return secret;
}
// 鍵を変えると既存の Cookie はすべて無効になる
const AUTH_TOKEN = crypto
.createHmac('sha256', loadSecret())
.update('access:' + config.ACCESS_KEY)
.digest('base64url');
// Cookie の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる
const SECRET = loadSecret();
const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url');
const TOKENS = {
member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効)
admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null,
};
function parseCookies(header) {
const out = {};
@@ -51,18 +53,35 @@ function safeEqual(a, b) {
return crypto.timingSafeEqual(ha, hb);
}
function isAuthedCookieHeader(cookieHeader) {
// 'admin' / 'member' / null
function roleOf(cookieHeader) {
const value = parseCookies(cookieHeader)[COOKIE_NAME];
return Boolean(value) && safeEqual(value, AUTH_TOKEN);
if (!value) return null;
// どちらも必ず比較する(どちらに一致したかを処理時間から推測させない)
const admin = TOKENS.admin ? safeEqual(value, TOKENS.admin) : false;
const member = safeEqual(value, TOKENS.member);
return admin ? 'admin' : member ? 'member' : null;
}
function isAuthedCookieHeader(cookieHeader) {
return roleOf(cookieHeader) !== null;
}
function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res) {
function isAdminCookieHeader(cookieHeader) {
return roleOf(cookieHeader) === 'admin';
}
function isAdmin(req) {
return isAdminCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res, role) {
const parts = [
`${COOKIE_NAME}=${AUTH_TOKEN}`,
`${COOKIE_NAME}=${TOKENS[role]}`,
'Path=/',
`Max-Age=${COOKIE_MAX_AGE_SEC}`,
'HttpOnly',
@@ -163,7 +182,10 @@ function handleLogin(req, res) {
if (!/^\d{6}$/.test(input)) {
return res.status(400).json({ error: '6桁の数字を入力してください。' });
}
if (!safeEqual(input, config.ACCESS_KEY)) {
// どちらも必ず比較する
const adminMatch = config.ADMIN_KEY ? safeEqual(input, config.ADMIN_KEY) : false;
const memberMatch = safeEqual(input, config.ACCESS_KEY);
if (!adminMatch && !memberMatch) {
w.count++;
global.count++;
console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`);
@@ -174,15 +196,18 @@ function handleLogin(req, res) {
return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
}
perClient.delete(key);
console.log(`[auth] 入室 from ${key}`);
setAuthCookie(req, res);
res.json({ ok: true });
// 管理者がメンバー用のキーを入れ直しても管理者のまま
const role = adminMatch || isAdmin(req) ? 'admin' : 'member';
console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`);
setAuthCookie(req, res, role);
res.json({ ok: true, admin: role === 'admin' });
}
// /key 以外のページ・API を保護する
function requireAuth(req, res, next) {
if (isAuthed(req)) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res);
const role = roleOf(req.headers.cookie);
if (role) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role);
return next();
}
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
@@ -190,6 +215,12 @@ function requireAuth(req, res, next) {
res.status(401).end();
}
// 管理者専用。管理者以外にはページがあること自体を知らせない(404)
function requireAdmin(req, res, next) {
if (isAdmin(req)) return next();
res.status(404).type('text/plain').send('Not Found');
}
// 状態を変える API は同じサイトからのみ受け付ける
function requireSameOrigin(req, res, next) {
if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next();
@@ -203,12 +234,15 @@ function safeNext(next) {
module.exports = {
isAuthed,
isAdmin,
isAuthedCookieHeader,
isAdminCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
handleLogin,
requireAuth,
requireAdmin,
requireSameOrigin,
safeNext,
};
+86
View File
@@ -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 };
+87
View File
@@ -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,
};
+97
View File
@@ -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 };
+118
View File
@@ -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 };
+130
View File
@@ -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 };
+50
View File
@@ -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 };
+20 -1
View File
@@ -30,6 +30,13 @@ if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) {
process.exit(1);
}
// 管理者キー(任意)。入力すると管理者として入室でき、ハンドレンジ学習ページが見える
const ADMIN_KEY = process.env.ADMIN_KEY || null;
if (ADMIN_KEY && (!/^\d{6}$/.test(ADMIN_KEY) || ADMIN_KEY === ACCESS_KEY)) {
console.error('[config] ADMIN_KEY は ACCESS_KEY と異なる6桁の数字にしてください。');
process.exit(1);
}
module.exports = {
PORT: Number(process.env.PORT) || 4671,
@@ -43,6 +50,7 @@ module.exports = {
// 参加キー(6桁の数字)。.env の ACCESS_KEY
ACCESS_KEY,
ADMIN_KEY,
// 1テーブルの最大着席数
MAX_SEATS: 9,
@@ -53,9 +61,20 @@ module.exports = {
// 誰も接続していないテーブルを削除するまでの時間
EMPTY_TABLE_TTL_MS: 30 * 60 * 1000,
// ハンド終了後、次のハンドまで結果を表示する時間
// オールインで残りの場札をめくる間隔(カード機能あり)
RUNOUT_STEP_MS: 1500,
// ハンド終了後、次のハンドまで結果を表示する時間(カードありは手札を見比べられるよう長め)
SHOWDOWN_MS: 4000,
CARD_SHOWDOWN_MS: 6000,
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人揃ってから次のハンドを始めるまで
NEXT_HAND_DELAY_MS: 2000,
+307 -153
View File
@@ -1,245 +1,361 @@
'use strict';
// 1ハンド分のチップの動き(ブラインド・ベッティング・ポット分配)。
// カードは実物で配るので扱わない。ショーダウンでは勝者をプレイヤーが選ぶ。
// seats は Table.seats への参照で、スタックはここで直接増減する。
const path = require('path');
const { PokerEngine, ActionType, PlayerStatus } = require('@pokertools/engine');
// サイドポットの計算はエンジン内部の関数を使う(パッケージの公開 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 側で予約したまま)。
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const RUNOUT_STREET = { 3: 'FLOP', 4: 'TURN', 5: 'RIVER' };
class Hand {
constructor({ number, seats, order, button, sb, bb }) {
constructor({ number, seats, order, button, sb, bb, cards = false }) {
this.number = number;
this.seats = seats;
this.order = order; // 参加者の席番号。ボタンの次から時計回り
this.button = button;
this.sb = sb;
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.pot = 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.finished = false;
this.showdown = false;
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();
for (const i of order) {
this.players.set(i, {
seat: i,
token: seats[i].token,
cards: [],
best: null, // ショーダウンでの最強の5枚
bet: 0, // このストリートで出した額
committed: 0, // このハンドで出した合計
folded: false,
allIn: false,
acted: false,
last: null, // 直前のアクション(表示用)
won: 0,
});
}
// ヘッズアップはボタンが SB
const headsUp = order.length === 2;
// ヘッズアップはボタンが SB(エンジンも同じ配置にする)
const headsUp = n === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
this.post(this.sbSeat, sb);
this.post(this.bbSeat, bb);
this.currentBet = bb; // BB がショートでもコール額は BB
this.setNextActor(this.bbSeat);
// プリフロップに行動する順(UTG から BB まで)
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
this.engine.deal();
this.sync();
}
get state() {
return this.engine.state;
}
get list() {
return this.order.map((i) => this.players.get(i));
}
// 勝者の選択待ち(カードありのときは自動で判定するので来ない)
get awaitingWinners() {
return this.street === 'SHOWDOWN' && !this.finished;
return !this.cards && this.street === 'SHOWDOWN' && !this.finished;
}
live() {
return this.list.filter((p) => !p.folded);
}
put(p, amount) {
const s = this.seats[p.seat];
s.stack -= amount;
p.bet += amount;
p.committed += amount;
if (s.stack === 0) p.allIn = true;
// 前のストリートまでのポット + このストリートのベット
get potTotal() {
return this.pot + this.list.reduce((sum, p) => sum + p.bet, 0);
}
post(seat, amount) {
const p = this.players.get(seat);
this.put(p, Math.min(amount, this.seats[seat].stack));
}
// エンジンの状態を写す。ハンドが決着していたら結果を作る
sync() {
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) {
return !p.folded && !p.allIn && (!p.acted || p.bet < this.currentBet);
}
const prevStreet = this.street;
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;
this.prevAggressor = this.aggressor;
this.aggressor = null;
}
// 誰もコールしなかった分が返ってオールインでなくなった
for (const p of this.list) if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null;
bettingClosed() {
const live = this.live();
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));
}
if (ended) return this.settle();
setNextActor(fromSeat) {
this.toAct = null;
if (this.bettingClosed()) return this.endStreet();
const n = this.order.length;
const start = this.order.indexOf(fromSeat);
for (let k = 1; k <= n; k++) {
const p = this.players.get(this.order[(start + k) % n]);
if (!this.needsToAct(p)) continue;
this.toAct = p.seat;
// 離席中・退席済みの人は自動でチェック/フォールド
const s = this.seats[p.seat];
this.street = st.street;
if (this.cards) this.board = st.board.slice();
this.pot = st.pots.reduce((sum, pot) => sum + pot.amount, 0);
// コール額(プリフロップは BB がショートでも BB)
this.currentBet = Math.max(0, ...st.currentBets.values(), st.street === 'PREFLOP' ? st.bigBlind : 0);
this.toAct = st.actionTo == null ? null : this.fromEngine[st.actionTo];
// 離席中・退席済みの人は自動でチェック/フォールド
if (this.toAct != null) {
const s = this.seats[this.toAct];
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;
}
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)
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 stack = this.seats[seat].stack;
const toCall = Math.min(this.currentBet - p.bet, stack);
const maxTo = p.bet + stack;
// 他の全員がオールインならレイズしても意味がない
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 {
toCall,
canCheck: p.bet >= this.currentBet,
canRaise: !p.acted && stack > toCall && opponentCanAct,
minRaiseTo: Math.min(this.currentBet + this.minRaise, maxTo),
canRaise,
minRaiseTo: Math.min(this.state.minRaise, maxTo),
maxRaiseTo: maxTo,
isBet: this.currentBet === 0,
};
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
let last;
if (type === 'fold') {
p.folded = true;
p.last = { type: 'FOLD' };
last = { type: 'FOLD' };
} else if (type === 'check') {
if (!o.canCheck) return 'チェックできません';
p.last = { type: 'CHECK' };
last = { type: 'CHECK' };
} else if (type === 'call') {
if (o.canCheck || o.toCall <= 0) return 'コールする額がありません';
this.put(p, o.toCall);
p.last = { type: 'CALL', amount: p.bet };
last = { type: 'CALL', amount: p.bet + o.toCall };
} else if (type === 'raise') {
if (!o.canRaise) return 'レイズできません';
if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) {
return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`;
}
const raiseSize = amount - this.currentBet;
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;
last = { type: o.isBet ? 'BET' : 'RAISE', amount };
} else {
return '不正なアクションです';
}
if (p.allIn) p.last = { type: 'ALLIN', amount: p.bet };
p.acted = true;
this.setNextActor(seat);
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
const entry = {
street: this.street,
seat,
type: last.type,
amount: last.amount || 0, // このストリートでの合計額
added: type === 'call' ? o.toCall : type === 'raise' ? amount - p.bet : 0, // この行動で出した額
allIn,
auto,
pot: this.potTotal, // 行動する前のポット(このストリートのベットを含む)
toCall: o.toCall,
currentBet: this.currentBet,
bet: p.bet,
stack: this.seats[seat].stack,
board: this.board.slice(),
canCheck: o.canCheck,
canRaise: o.canRaise,
minRaiseTo: o.minRaiseTo,
maxRaiseTo: o.maxRaiseTo,
live: this.live().length,
};
try {
this.engine.act(this.engineAction(seat, type, amount));
} catch {
// エンジンが受け付けない(チェックできるのに降りてサイドポットの対象者がいなくなる場合など)
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;
}
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
forceFold(seat) {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) return this.act(seat, 'fold', 0);
p.folded = true;
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';
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
this.street = STREETS[STREETS.indexOf(this.street) + 1];
this.setNextActor(this.button);
}
// メインポット・サイドポット(同じ参加者のポットはまとめる)
buildPots() {
const all = this.list;
const levels = [...new Set(all.map((p) => p.committed).filter((c) => c > 0))].sort((a, b) => a - b);
const pots = [];
let prev = 0;
for (const level of levels) {
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];
if (eligible.length === 0) eligible = last ? last.eligible : this.live().map((p) => p.seat);
if (last && last.eligible.join() === eligible.join()) last.amount += amount;
else pots.push({ amount, eligible });
if (this.settled) {
// キャッシュオンリーの勝者選び待ち: 選べる人から外す
if (!this.awaitingWinners) return;
p.folded = true;
p.last = { type: 'FOLD' };
if (this.live().length === 1) return this.finishFoldWin();
// 選べる人がいなくなったポットは前のポットにまとめる(メインポットには残った全員が入っている)
const pots = [];
for (const pot of this.pots) {
const eligible = pot.eligible.filter((s) => s !== seat);
const last = pots[pots.length - 1];
if (last && (eligible.length === 0 || last.eligible.join() === eligible.join())) last.amount += pot.amount;
else pots.push({ amount: pot.amount, eligible });
}
this.pots = pots;
return;
}
return pots;
// エンジンは手番以外のフォールドを「席を立つ」として扱う。持ち出したチップは自分で覚えておく。
// オールインの人などエンジンが受け付けないときは、手番が来たら自動でチェック/フォールドする
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() {
const winner = this.live()[0];
winner.won = this.pot;
@@ -250,7 +366,7 @@ class Hand {
// ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数)
award(winners) {
if (!this.awaitingWinners) return 'ショーダウンではありません';
const pots = this.buildPots();
const pots = this.pots;
if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください';
for (let i = 0; i < pots.length; 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 '勝者の指定が不正です';
}
const results = [];
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 });
});
const results = pots.map((pot, i) => ({ amount: pot.amount, winners: this.payout(pot.amount, winners[i]) }));
this.end(true, results);
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) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー・統計用
this.endedAt = Date.now();
this.finished = true;
this.runout = false;
this.showdown = showdown;
this.toAct = null;
this.results = results;
}
view() {
// 閲覧者に見せてよい情報だけを返す(他人の手札は公開時のみ)
view(viewerSeat, viewerToken) {
const players = {};
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 {
number: this.number,
cards: this.cards,
board: this.board,
runout: this.runout,
street: this.street,
pot: this.pot,
potTotal: this.pot + this.list.reduce((sum, p) => sum + p.bet, 0),
potTotal: this.potTotal,
currentBet: this.currentBet,
raises: this.raises, // ベット額の候補を 3bet・4bet などに合わせるため
button: this.button,
sbSeat: this.sbSeat,
bbSeat: this.bbSeat,
toAct: this.toAct,
finished: this.finished,
showdown: this.showdown,
pots: this.awaitingWinners ? this.buildPots() : null,
pots: this.awaitingWinners ? this.pots : null,
results: this.results,
players,
};
+128 -10
View File
@@ -6,10 +6,17 @@ const express = require('express');
const { Server } = require('socket.io');
const config = require('./config');
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 page = (file) => (req, res) => res.sendFile(path.join(PUBLIC, file));
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
const ADMIN = path.join(__dirname, '..', 'admin');
const page = (file, dir = PUBLIC) => (req, res) => res.sendFile(path.join(dir, file));
const app = express();
app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく
@@ -67,9 +74,12 @@ const tables = new TableManager();
// ---------- 認証不要 ----------
app.use('/assets', express.static(path.join(PUBLIC, 'assets'), { index: false, dotfiles: 'deny' }));
app.get('/favicon.ico', (req, res) => res.sendFile(path.join(PUBLIC, 'assets', 'favicon.ico')));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
app.get('/key', (req, res) => {
if (auth.isAuthed(req)) return res.redirect(auth.safeNext(req.query.next));
// 入室済みなら飛ばす。ただし /key?admin は管理者キーを入れるために開ける
const wantsAdmin = req.query.admin !== undefined && !auth.isAdmin(req);
if (auth.isAuthed(req) && !wantsAdmin) return res.redirect(auth.safeNext(req.query.next));
page('key.html')(req, res);
});
app.post('/api/auth', auth.handleLogin);
@@ -81,8 +91,67 @@ app.use(auth.requireSameOrigin);
app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
app.get('/stats', page('stats.html'));
app.get('/settings', page('settings.html'));
app.get('/api/tables', (req, res) => res.json(tables.list()));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
app.use(
'/range/files',
auth.requireAdmin,
express.static(ADMIN, {
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
// (スクリプトと 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) }));
// 自分の統計。プレイヤーID(席のトークン)を URL やアクセスログに残さないよう POST の本文で受け取る
app.post('/api/stats', async (req, res, next) => {
const token = cleanToken(req.body && req.body.token);
if (!token) return res.status(400).json({ error: 'プレイヤーIDがありません' });
try {
res.json(summarizeStats(await stats.get(token)));
} catch (err) {
next(err);
}
});
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -95,6 +164,8 @@ app.post('/api/tables', (req, res) => {
const bigBlind = b.bigBlind;
const blindTimerEnabled = b.blindTimerEnabled === true;
const blindIntervalMin = b.blindIntervalMin;
const mode = b.mode;
const cpuCount = b.cpuCount;
const hostToken = cleanToken(b.token);
if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' });
@@ -102,8 +173,16 @@ app.post('/api/tables', (req, res) => {
if (tables.nameTaken(name)) return res.status(409).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(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分で入力してください' });
// レビュー付き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がありません' });
const table = tables.create({
@@ -113,12 +192,23 @@ app.post('/api/tables', (req, res) => {
bigBlind,
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
mode,
cpuCount: withBots ? cpuCount : 0,
hostToken,
});
broadcastLobby();
res.json({ id: table.id });
});
// 管理者はどのテーブルでも削除できる(着席者・観戦者にはテーブルが閉じられたことを知らせる)
app.delete('/api/tables/:id', auth.requireAdmin, (req, res) => {
const table = tables.get(req.params.id);
if (!table) return res.status(404).json({ error: 'テーブルが見つかりません' });
console.log(`[admin] テーブル「${table.name}」を削除`);
closeTable(table, null, 'deleted');
res.json({ ok: true });
});
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
// 内部情報(スタックトレース等)を返さない
@@ -156,8 +246,10 @@ function broadcastTable(table) {
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
}
// レビュー付きCPU戦は管理者のロビーにだけ出す
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list());
io.to('lobby').emit('lobby:tables', tables.list(false));
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
}
function changed(table) {
@@ -165,6 +257,18 @@ function changed(table) {
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 を通った後も念のため)
io.use((socket, next) => {
if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next();
@@ -173,6 +277,7 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
// イベントの連打を制限
let windowStart = Date.now();
@@ -188,8 +293,8 @@ io.on('connection', (socket) => {
});
socket.on('lobby:subscribe', () => {
socket.join('lobby');
socket.emit('lobby:tables', tables.list());
socket.join(socket.data.admin ? 'lobby:admin' : 'lobby');
socket.emit('lobby:tables', tables.list(socket.data.admin));
});
socket.on('table:join', (p, ack) => {
@@ -200,6 +305,10 @@ io.on('connection', (socket) => {
const name = cleanText(p.name, 12);
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
return reply(ack, { ok: false, error: 'NOT_FOUND' });
}
const prev = socket.data.tableId && tables.get(socket.data.tableId);
if (prev && prev !== table) {
@@ -240,6 +349,13 @@ io.on('connection', (socket) => {
'table:sitOut': (t, token) => t.setSittingOut(token, true),
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.nextHand(token),
'table:seating': (t, token, p) => {
// order: 時計回りに並べた席番号
const order = p && typeof p === 'object' ? p.order : null;
const ok = Array.isArray(order) && order.length <= config.MAX_SEATS && order.every((i) => Number.isInteger(i));
return ok ? t.setSeating(token, order) : '席順の指定が不正です';
},
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {
@@ -247,7 +363,9 @@ io.on('connection', (socket) => {
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
const error = fn(table, socket.data.token, p);
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);
});
}
@@ -257,7 +375,7 @@ io.on('connection', (socket) => {
});
});
// ブラインドタイマー・持ち時間・次のハンドなど時間で進む処理
// ブラインドタイマー・オールイン後の場札・次のハンドなど時間で進む処理
setInterval(() => {
const now = Date.now();
for (const table of tables.tables.values()) {
+275
View File
@@ -0,0 +1,275 @@
'use strict';
// 1ハンドのレビュー。ハンドが終わったら hero(レビューを受ける人)について作る。
// プリフロップ: 登録してあるハンドレンジの色と、そのとおりに行動できたか(preflop.js)
// フロップ以降: 相手の推定レンジに対する勝率から、選択肢ごとの期待値(BB)を見積もり、
// 一番良い選択肢との差(失った期待値)を点数にする。GTO ではなく目安
// オールイン: 実際の手札どうしの勝率から、収支の期待値(運の影響を除いた収支)を出す
const HandRange = require('../../admin/range-data');
const { judgePreflop, positionOf, describeSituation, ACTION_KIND } = require('./preflop');
const { codes, rangeOf, narrow, rangeEquity, showdownShare } = require('./equity');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// レビューで色に添える説明(クイズ用の分類名がそのままでは分かりにくいもの)
const COLOR_LABEL = { gray: 'レンジ外' };
// 失った期待値がポット(コール後)のこの割合までは誤差として満点にする
const TOLERANCE = 0.03;
// 失った期待値がポット(コール後)の 1% 増えるごとに減らす点数
const PENALTY = 2.5;
// 相手が降りる割合: ベットが損をしない最低限のフォールド率(risk / (risk + reward))より少し低めに見て、
// 相手が多いほど全員は降りにくくする(ただのブラフが得にならないように)
const FOLD_REALISM = 0.7;
const FOLD_MULTIWAY = 0.85;
const markOf = (score) => (score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const round1 = (x) => Math.round(x * 10) / 10;
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
// index より前に降りた席
function foldedBefore(log, index) {
const s = new Set();
for (let i = 0; i < index; i++) if (log[i].type === 'FOLD') s.add(log[i].seat);
return s;
}
// 相手の推定レンジ: プリフロップの色(参加しなかった BB などは全部の手)から、
// フロップ以降にベット・レイズするたびに強い手(いまの場札での役)に絞る。コールでも少し絞る
function opponentRanges(hand, hero, index, estimated, board, dead) {
const log = hand.log;
const folded = foldedBefore(log, index);
const ranges = [];
for (const s of hand.order) {
if (s === hero || folded.has(s)) continue;
let aggr = 0;
let calls = 0;
for (let i = 0; i < index; i++) {
const e = log[i];
if (e.seat !== s || e.street === 'PREFLOP') continue;
if (e.type === 'BET' || e.type === 'RAISE') aggr++;
else if (e.type === 'CALL') calls++;
}
const fraction = Math.max(0.08, 0.55 ** aggr * 0.8 ** calls);
ranges.push(narrow(rangeOf(estimated.get(s) || 0, dead), board, fraction));
}
return ranges;
}
// フロップ以降の1回の判断。選択肢ごとの期待値(いま降りたときを 0 とするチップ)
function evaluateDecision(hand, hero, index, estimated, opts) {
const e = hand.log[index];
const heroCards = codes(hand.players.get(hero).cards);
const board = codes(e.board);
const dead = heroCards.concat(board);
const ranges = opponentRanges(hand, hero, index, estimated, board, dead);
const eq = rangeEquity(heroCards, board, ranges, opts);
const P = e.pot;
const t = e.toCall;
const options = [];
if (t > 0) {
options.push({ kind: 'fold', ev: 0 });
options.push({ kind: 'call', ev: eq * (P + t) - t });
}
if (e.canCheck) options.push({ kind: 'check', ev: eq * P });
if (e.canRaise) {
const clamp = (v) => Math.min(e.maxRaiseTo, Math.max(e.minRaiseTo, Math.round(v)));
const sizes = new Set([clamp(e.currentBet === 0 ? P * 0.66 : e.currentBet * 3)]);
if (ACTION_KIND[e.type] === 'raise') sizes.add(e.amount);
for (const to of sizes) {
const r = to - e.bet; // 自分が出す額
const c = to - e.currentBet; // コールする人が出す額(直前のベットからの差)
// コールされたら、降りなかった強い側のレンジと勝負する
const alpha = r / (P + r);
const foldAll = FOLD_REALISM * alpha * FOLD_MULTIWAY ** Math.max(0, ranges.length - 1);
const eqCalled = rangeEquity(heroCards, board, ranges.map((rg) => narrow(rg, board, 1 - alpha)), opts);
options.push({ kind: 'raise', to, ev: foldAll * P + (1 - foldAll) * (eqCalled * (P + r + c) - r) });
}
}
const kind = ACTION_KIND[e.type];
const actual = options.find((o) => o.kind === kind && (kind !== 'raise' || o.to === e.amount)) || options[0];
const best = options.reduce((a, b) => (b.ev > a.ev ? b : a));
const loss = Math.max(0, best.ev - actual.ev);
const base = P + t;
const score = Math.max(0, Math.min(100, Math.round(100 - (PENALTY * 100 * Math.max(0, loss - TOLERANCE * base)) / base)));
const bb = hand.bb;
return {
index,
street: e.street,
equity: round1(eq * 100),
need: t > 0 ? round1((t / base) * 100) : null,
options: options.map((o) => ({ kind: o.kind, to: o.to, evBB: round1(o.ev / bb) })),
best: { kind: best.kind, to: best.to },
actual: { kind: actual.kind, to: actual.to },
lossBB: round1(loss / bb),
score,
mark: markOf(score),
};
}
// プリフロップでコール・レイズに直面したときの参考: 参加者の推定レンジに対する勝率と必要勝率
function preflopOdds(hand, hero, j, estimated, opts) {
const e = hand.log[j.index];
if (!(e.toCall > 0) || j.situation.kind !== 'facing') return {};
const heroCards = codes(hand.players.get(hero).cards);
const folded = foldedBefore(hand.log, j.index);
const ranges = [];
for (let i = 0; i < j.index; i++) {
const x = hand.log[i];
const k = ACTION_KIND[x.type];
if (x.street !== 'PREFLOP' || x.seat === hero || folded.has(x.seat) || (k !== 'call' && k !== 'raise')) continue;
if (ranges.some((r) => r.seat === x.seat)) continue;
ranges.push({ seat: x.seat, range: rangeOf(estimated.get(x.seat) || 0, heroCards) });
}
if (!ranges.length) return {};
const eq = rangeEquity(heroCards, [], ranges.map((r) => r.range), opts);
return { equity: round1(eq * 100), need: round1((e.toCall / (e.pot + e.toCall)) * 100) };
}
// hero の統計用の記録(VPIP・PFR・3bet・Cベットなど)
function statsOf(hand, hero) {
const mine = hand.log.filter((e) => e.seat === hero && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const post = mine.filter((e) => e.street !== 'PREFLOP');
const me = hand.players.get(hero);
const foldedPre = hand.log.some((e) => e.seat === hero && e.street === 'PREFLOP' && e.type === 'FOLD');
const sawFlop = !foldedPre && hand.board.length >= 3;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
let threeBetOpp = false;
let threeBet = false;
let raises = 0;
let lastRaiser = null;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
if (e.seat === hero && !e.auto && raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = ACTION_KIND[e.type] === 'raise';
}
if (ACTION_KIND[e.type] === 'raise') {
raises++;
lastRaiser = e.seat;
}
}
// Cベット: プリフロップの最後のレイザーが、フロップで誰もベットしていないときにベットしたか
const firstFlop = post.find((e) => e.street === 'FLOP');
const cbetOpp = sawFlop && lastRaiser === hero && Boolean(firstFlop) && firstFlop.currentBet === 0 && firstFlop.canRaise;
const showdown = hand.showdown && !me.folded;
return {
vpip: pre.some((e) => e.type === 'CALL' || e.type === 'BET' || e.type === 'RAISE'),
pfr: pre.some((e) => e.type === 'BET' || e.type === 'RAISE'),
threeBetOpp,
threeBet,
sawFlop,
cbetOpp,
cbet: cbetOpp && firstFlop.type === 'BET',
aggressive: post.filter((e) => e.type === 'BET' || e.type === 'RAISE').length,
calls: post.filter((e) => e.type === 'CALL').length,
showdown,
wonShowdown: showdown && me.won > 0,
};
}
// names: 席番号 → 名前、bots: CPU の席の Set
function analyzeHand(hand, hero, { names, bots, iterations = 1200, rng = Math.random } = {}) {
const opts = { iterations, rng };
const nameOf = (s) => (names && names.get(s)) || `席${s + 1}`;
const me = hand.players.get(hero);
const key = HandRange.handKey(me.cards);
const cat = HandRange.category(HandRange.categoryOf(key));
const { judgments, estimated } = judgePreflop(hand, hero);
const preflop = judgments.map((j) => ({
index: j.index,
situation: describeSituation(j.situation, nameOf),
recommended: j.recommended,
actual: j.actual,
verdict: j.verdict,
label: j.label,
mark: j.mark,
score: j.score,
...preflopOdds(hand, hero, j, estimated, opts),
}));
const decisions = [];
hand.log.forEach((e, i) => {
if (e.seat === hero && !e.auto && e.street !== 'PREFLOP') decisions.push(evaluateDecision(hand, hero, i, estimated, opts));
});
const streetScores = {};
for (const st of STREETS) {
const scores = (st === 'PREFLOP' ? preflop : decisions.filter((d) => d.street === st)).map((d) => d.score);
streetScores[st] = scores.length ? Math.round(mean(scores)) : null;
}
const scored = STREETS.map((st) => streetScores[st]).filter((x) => x != null);
// 収支と、オールインで決着したときの期待値
const net = hand.endStacks.get(hero) - hand.startStacks.get(hero);
let allIn = null;
if (hand.allIn && !me.folded) {
const hands = new Map(hand.live().map((p) => [p.seat, p.cards]));
const { ev, equity } = showdownShare(hero, hands, hand.allIn.board, hand.allIn.pots, { rng });
allIn = { street: hand.allIn.board.length === 0 ? 'PREFLOP' : hand.allIn.board.length === 3 ? 'FLOP' : 'TURN', equity: round1(equity * 100), ev: Math.round(ev), won: me.won };
}
const evNet = allIn ? Math.round(net - me.won + allIn.ev) : net;
return {
number: hand.number,
at: Date.now(),
sb: hand.sb,
bb: hand.bb,
button: hand.button,
sbSeat: hand.sbSeat,
bbSeat: hand.bbSeat,
heroSeat: hero,
players: hand.order.map((s) => {
const p = hand.players.get(s);
return {
seat: s,
name: nameOf(s),
bot: Boolean(bots && bots.has(s)),
position: positionOf(hand, s),
cards: p.cards.slice(),
start: hand.startStacks.get(s),
end: hand.endStacks.get(s),
won: p.won,
folded: p.folded,
};
}),
actions: hand.log.map((e) => ({
street: e.street,
seat: e.seat,
type: e.type,
amount: e.amount,
added: e.added,
allIn: Boolean(e.allIn),
auto: Boolean(e.auto),
pot: e.pot,
toCall: e.toCall || 0,
})),
board: hand.board.slice(),
results: hand.results,
showdown: hand.showdown,
hero: {
cards: me.cards.slice(),
key,
color: cat.id,
colorName: cat.name,
colorLabel: COLOR_LABEL[cat.id] || cat.label,
tier: cat.tier,
position: positionOf(hand, hero),
net,
evNet,
allIn,
},
preflop,
decisions,
streetScores,
handScore: scored.length ? Math.round(mean(scored)) : null,
lossBB: round1(decisions.reduce((a, d) => a + d.lossBB, 0)),
stats: statsOf(hand, hero),
};
}
module.exports = { analyzeHand, STREETS, COLOR_LABEL, markOf };
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// レビュー用の勝率計算(@pokertools/evaluator の役判定を使う。役の強さのスコアは小さいほど強い)
// rangeEquity : 相手ごとの推定レンジ(手札の組み合わせの一覧)に対する勝率(モンテカルロ法)
// showdownShare: 実際の手札どうしで、残りの場札をすべて(多いときはサンプリングで)めくったときの取り分
const { evaluate, getCardCode } = require('@pokertools/evaluator');
const HandRange = require('../../admin/range-data');
const codes = (cards) => cards.map(getCardCode);
// 1326通りの手札。段階(色)ごとに使う
const COMBOS = [];
{
const ranks = 'AKQJT98765432';
const suits = 'shdc';
const deck = [];
for (const r of ranks) for (const s of suits) deck.push(r + s);
for (let i = 0; i < deck.length; i++) {
for (let j = i + 1; j < deck.length; j++) {
const cards = [deck[i], deck[j]];
COMBOS.push({ c: codes(cards), tier: HandRange.tierOf(HandRange.handKey(cards)) });
}
}
}
// minTier 以上の色の手(0 ならすべて)から、dead のカードを含むものを除く
function rangeOf(minTier, dead) {
const d = new Set(dead);
return COMBOS.filter((x) => x.tier >= minTier && !d.has(x.c[0]) && !d.has(x.c[1])).map((x) => x.c);
}
// いまの場札での役の強さの上位 fraction だけ残す(ベット・レイズされるほど強い手に絞る)
function narrow(range, board, fraction) {
if (fraction >= 1 || board.length < 3 || range.length === 0) return range;
const scored = range.map((c) => ({ c, v: evaluate(c.concat(board)) }));
scored.sort((a, b) => a.v - b.v);
const keep = Math.max(1, Math.ceil(scored.length * fraction));
return scored.slice(0, keep).map((x) => x.c);
}
// hero の手札 vs 相手ごとのレンジ。引き分けは等分。ranges が空なら 1
function rangeEquity(hero, board, ranges, { iterations = 1200, rng = Math.random } = {}) {
const live = ranges.filter((r) => r.length > 0);
if (live.length === 0) return 1;
const fixed = hero.concat(board);
let score = 0;
let n = 0;
const used = new Uint8Array(52);
for (let it = 0; it < iterations; it++) {
used.fill(0);
for (const c of fixed) used[c] = 1;
const opp = [];
let ok = true;
for (const r of live) {
let pick = null;
for (let tries = 0; tries < 30 && !pick; tries++) {
const c = r[Math.floor(rng() * r.length)];
if (!used[c[0]] && !used[c[1]]) pick = c;
}
if (!pick) {
ok = false;
break;
}
used[pick[0]] = used[pick[1]] = 1;
opp.push(pick);
}
if (!ok) continue;
const full = board.slice();
while (full.length < 5) {
const c = Math.floor(rng() * 52);
if (!used[c]) {
used[c] = 1;
full.push(c);
}
}
const me = evaluate(hero.concat(full));
let ties = 0;
let lost = false;
for (const o of opp) {
const v = evaluate(o.concat(full));
if (v < me) {
lost = true;
break;
}
if (v === me) ties++;
}
if (!lost) score += 1 / (ties + 1);
n++;
}
return n ? score / n : 0.5;
}
// 残りの場札の組み合わせ。2枚以下ならすべて、3枚以上はサンプリング
function* runouts(board, dead, samples, rng) {
const need = 5 - board.length;
const used = new Set(board.concat(dead));
const deck = [];
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
if (need === 0) {
yield board;
} else if (need === 1) {
for (const a of deck) yield board.concat([a]);
} else if (need === 2) {
for (let i = 0; i < deck.length; i++) for (let j = i + 1; j < deck.length; j++) yield board.concat([deck[i], deck[j]]);
} else {
for (let k = 0; k < samples; k++) {
for (let i = 0; i < need; i++) {
const j = i + Math.floor(rng() * (deck.length - i));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
yield board.concat(deck.slice(0, need));
}
}
}
// オールインの時点で、hero がポットごとに受け取る額の期待値と勝率
// hands: 席番号 → 手札("As" 形式)。pots: [{ amount, eligible: [席番号] }]
function showdownShare(heroSeat, hands, boardCards, pots, { samples = 4000, rng = Math.random } = {}) {
const board = codes(boardCards);
const seats = [...hands.keys()];
const hole = new Map(seats.map((s) => [s, codes(hands.get(s))]));
const dead = seats.flatMap((s) => hole.get(s));
let ev = 0;
let equity = 0; // メインポット(全員が対象)での取り分の割合
let n = 0;
for (const full of runouts(board, dead, samples, rng)) {
const score = new Map(seats.map((s) => [s, evaluate(hole.get(s).concat(full))]));
pots.forEach((pot, i) => {
if (!pot.eligible.includes(heroSeat)) return;
const best = Math.min(...pot.eligible.map((s) => score.get(s)));
if (score.get(heroSeat) !== best) return;
const share = 1 / pot.eligible.filter((s) => score.get(s) === best).length;
ev += pot.amount * share;
if (i === 0) equity += share;
});
n++;
}
return { ev: ev / n, equity: equity / n };
}
module.exports = { codes, rangeOf, narrow, rangeEquity, showdownShare };
+165
View File
@@ -0,0 +1,165 @@
'use strict';
// プリフロップの判定: 配られた手が登録してあるハンドレンジ(6-max のオープンレンジ)の何色で、そのとおりに行動できたか。
// 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)に対応する色
// 以上ならレイズ(オープン)、それより弱ければフォールド
// 自分より前にコール・レイズした人がいる: その人のレンジを色で推定し(その人の後ろの人数に対応する色)、
// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド
// 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ
// BTN で CO のオープン(ほかに参加者なし)に直面: レンジ表の枠のとおり。水色枠は 3bet、黄枠はコール、
// 枠なしはフォールド
// BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。緑(CO)以上でコール
// (CO のオープンなら青、BTN・SB なら白以上。相手の色の一つ下まで)、三つ上ならレイズ
// 2人目以降に参加した人のレンジも同じ決まりで、コール・レイズできる一番弱い色と推定する
// (ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。
const HandRange = require('../../admin/range-data');
const TOP_TIER = 6; // 紺
const BB_CALL_TIER = 3; // 緑: BB はどこからのオープンにもこの色以上でコールできる
const BB_DEFENSE_MAX_OPEN = 4; // BB で広く守るのはオープンがこの BB 数まで
const ACTION_KIND = { FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'raise', RAISE: 'raise' };
const ACTION_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const VERDICTS = {
good: { mark: '◎', score: 100, label: 'レンジどおり' },
passive: { mark: '△', score: 50, label: 'レイズできる手でコール(チェック)した' },
aggressive: { mark: '△', score: 40, label: 'コールまでの手でレイズした' },
loose: { mark: '✕', score: 0, label: 'レンジ外の手で参加した' },
tight: { mark: '✕', score: 0, label: '参加できる手で降りた' },
needless: { mark: '✕', score: 0, label: 'チェックできるのに降りた' },
};
// 9人のテーブルの呼び方。UTG から CO までは人数に合わせて後ろから当てはめる
const MIDDLE_POSITIONS = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO'];
function positionOf(hand, seat) {
const n = hand.order.length;
if (seat === hand.button) return n === 2 ? 'BTN/SB' : 'BTN';
if (seat === hand.sbSeat) return 'SB';
if (seat === hand.bbSeat) return 'BB';
const middle = hand.preflopOrder.slice(0, n - 3);
const names = MIDDLE_POSITIONS.slice(-middle.length);
return names[middle.indexOf(seat)] || '?';
}
const tierName = (tier) => (tier > TOP_TIER ? 'なし' : HandRange.categoryOfTier(Math.max(0, tier)).name);
// 前に参加した人がいるときに、コール・レイズできる一番弱い色の段階。rule は
// 'normal': 一つ上でコール、二つ上でレイズ(紺は紺に対してもコールできる)
// 'reraised': 自分のレイズにリレイズされた。同じ色でコール、二つ上でレイズ
// 'bb': BB でオープンに直面。相手の色の一つ下(緑まで)でコール、三つ上でレイズ(白より下は参加しない)
// 'vsCO': BTN で CO のオープンに直面。判定はレンジ表の枠で行い、ここでの段階は推定レンジにだけ使う('normal' と同じ)
function thresholds(rule, ref) {
if (rule === 'reraised') return { call: Math.min(TOP_TIER, ref), raise: ref + 2 };
if (rule === 'bb') return { call: Math.max(1, Math.min(BB_CALL_TIER, ref - 1)), raise: ref + 3 };
return { call: Math.min(TOP_TIER, ref + 1), raise: ref + 2 };
}
// 推奨: 'raise' / 'call' / 'fold'(チェックできるときの 'fold' と 'call' は 'check')
function recommend(tier, situation, entry) {
let rec;
if (situation.kind === 'open') {
rec = situation.behind === 0 ? 'check' : tier >= situation.required ? 'raise' : 'fold';
} else if (situation.rule === 'vsCO') {
rec = situation.facing === '3bet' ? 'raise' : situation.facing === 'call' ? 'call' : 'fold';
} else {
rec = tier >= situation.raise ? 'raise' : tier >= situation.call ? 'call' : 'fold';
}
if (rec === 'raise' && !entry.canRaise) rec = 'call';
if ((rec === 'call' || rec === 'fold') && entry.canCheck) rec = 'check';
return rec;
}
function verdictOf(rec, actual) {
if (rec === actual) return 'good';
if (actual === 'fold') return rec === 'check' ? 'needless' : 'tight';
if (rec === 'raise') return 'passive'; // コール・チェックした
if (rec === 'call') return 'aggressive'; // レイズした
return 'loose'; // 降りる(チェックする)べき手で参加した
}
// プリフロップの各行動を順に見て、hero の判断ごとに判定する。あわせて各席の推定レンジ(色の段階)を返す
function judgePreflop(hand, hero) {
const key = HandRange.handKey(hand.players.get(hero).cards);
const tier = HandRange.tierOf(key);
const order = hand.preflopOrder;
const acted = new Set();
const estimated = new Map(); // 席 → 推定レンジの一番弱い色の段階
let maxTier = null;
let maxSeat = null;
let raises = 0;
let entrants = 0; // プリフロップでコール・レイズした回数
const raisers = new Set(); // プリフロップでレイズした席
const judgments = [];
// まだ行動していない後ろの人数(自分を含めない)
const behindOf = (seat) => order.slice(order.indexOf(seat) + 1).filter((s) => !acted.has(s)).length;
// 前に参加した人がいるときの決まり(thresholds を参照)
const ruleOf = (e) => {
if (raisers.has(e.seat)) return 'reraised';
if (entrants === 1 && raises === 1 && positionOf(hand, e.seat) === 'BTN' && positionOf(hand, maxSeat) === 'CO') return 'vsCO';
if (e.seat === hand.bbSeat && raises === 1 && e.currentBet <= BB_DEFENSE_MAX_OPEN * hand.bb) return 'bb';
return 'normal';
};
hand.log.forEach((e, index) => {
if (e.street !== 'PREFLOP') return;
const kind = ACTION_KIND[e.type];
const rule = maxTier == null ? null : ruleOf(e);
const limits = rule && thresholds(rule, maxTier);
if (e.seat === hero && !e.auto) {
const situation =
maxTier == null
? { kind: 'open', behind: behindOf(hero), required: HandRange.requiredTier(behindOf(hero)) }
: { kind: 'facing', rule, refTier: maxTier, refSeat: maxSeat, ...limits, ...(rule === 'vsCO' && { facing: HandRange.facingOf(key) }) };
const rec = recommend(tier, situation, e);
const verdict = verdictOf(rec, kind);
judgments.push({ index, situation, recommended: rec, actual: kind, verdict, ...VERDICTS[verdict] });
}
if (kind === 'call' || kind === 'raise') {
const est =
maxTier == null
? HandRange.requiredTier(behindOf(e.seat))
: Math.min(TOP_TIER, kind === 'raise' ? limits.raise : limits.call);
estimated.set(e.seat, Math.max(estimated.get(e.seat) || 0, est));
// 一番強いレンジ(同じならレイズした人)を、後の人の判断の基準にする
if (maxTier == null || est > maxTier || (est === maxTier && kind === 'raise')) {
maxTier = est;
maxSeat = e.seat;
}
}
if (kind === 'call' || kind === 'raise') entrants++;
if (kind === 'raise') {
raises++;
raisers.add(e.seat);
}
acted.add(e.seat);
});
return { tier, judgments, estimated };
}
// 状況の説明(画面表示用)
function describeSituation(situation, nameOf) {
if (situation.kind === 'open') {
if (situation.behind === 0) return '全員が降りてBBに回ってきた';
return `前に参加者なし・後ろに${situation.behind}人 → ${tierName(situation.required)}以上でオープン`;
}
const who = nameOf(situation.refSeat);
if (situation.rule === 'vsCO') {
const action = HandRange.facingAction(situation.facing);
return `BTNで ${who}(CO)のオープンに直面 → レンジ表の枠(水色枠は3bet、黄枠はコール)。この手は${action ? `${action.name}で${action.label}` : '枠なしでフォールド'}`;
}
const ref = tierName(situation.refTier);
const head =
situation.rule === 'reraised'
? `自分のレイズに ${who} がリレイズ(推定レンジ ${ref})`
: situation.rule === 'bb'
? `BBで ${who} のオープン(推定レンジ ${ref})に直面`
: `${who} の推定レンジ ${ref}`;
const raise = situation.raise > TOP_TIER ? 'レイズはなし' : `${tierName(situation.raise)}以上でレイズ`;
return `${head} → ${tierName(situation.call)}以上でコール、${raise}`;
}
module.exports = { judgePreflop, positionOf, describeSituation, tierName, ACTION_KIND, ACTION_LABEL, VERDICTS };
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// 1ハンドの記録(analyze.js の analyzeHand の結果)を、AI に貼り付けて「自分の判断は良かったか」を
// 質問できる文章にする。そのとき Hero に見えていた情報(自分の手札・場札・全員の行動とスタック・
// ショーダウンで公開された手札)だけを書き、公開されなかった相手の手札は書かない
const { STREETS } = require('./analyze');
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const SUIT = { s: '♠', h: '♥', d: '♦', c: '♣' };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
// プリフロップの行動順
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN/SB', 'BTN', 'SB', 'BB'];
const money = (n) => '$' + Number(n).toLocaleString('en-US');
const round1 = (x) => Math.round(x * 10) / 10;
const signedMoney = (n) => (n > 0 ? '+' : n < 0 ? '-' : '±') + money(Math.abs(n));
const signedNum = (n) => (n > 0 ? '+' : '') + n;
const cardText = (c) => (typeof c === 'string' && c.length === 2 ? c[0] + (SUIT[c[1]] || c[1]) : '??');
const cardsText = (cs) => cs.map(cardText).join(' ');
function buildHandPrompt(h) {
const bbOf = (n) => `${round1(n / h.bb)}BB`;
const amount = (n) => `${money(n)}(${bbOf(n)})`;
const player = (seat) => h.players.find((p) => p.seat === seat);
const who = (seat) => {
const p = player(seat);
if (!p) return `席${seat + 1}`;
return seat === h.heroSeat ? `${p.position}(Hero・私)` : `${p.position}(${p.name})`;
};
const players = h.players
.slice()
.sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
const hero = player(h.heroSeat);
const out = [];
const line = (s = '') => out.push(s);
line('以下は、ノーリミット・テキサスホールデムのキャッシュゲームで私(Hero)がプレイした1ハンドの記録です。');
line('私の各アクションが良かったか悪かったかを評価してください。');
line('評価は結果論ではなく、それぞれの判断の時点で私に見えていた情報(自分の手札・場札・相手の行動とスタック)をもとにしてください。');
line();
line('## ゲームの条件');
line(`- ノーリミット・テキサスホールデム、キャッシュゲーム(${h.players.length}人でプレイ。チップは開始スタックで何度でもリバイ可)`);
line(`- ブラインド: SB ${money(h.sb)} / BB ${money(h.bb)}(アンティなし・レーキなし)`);
line('- 相手はすべて CPU。CPU ごとに打ち方(タイト/ルーズ、攻撃的/受動的、ブラフの頻度)が違い、どの CPU がどんな打ち方かはわからない');
line('- 金額は $ で書き、()内に BB 換算を添える');
line();
line('## 席とスタック(ハンド開始時、プリフロップの行動順)');
for (const p of players) {
line(`- ${who(p.seat)}: ${amount(p.start)}${p.seat === h.heroSeat ? ' ← 私' : ''}`);
}
const others = h.players.filter((p) => p.seat !== h.heroSeat).map((p) => p.start);
if (others.length) line(`- 私と一番深い相手との有効スタック: ${amount(Math.min(hero.start, Math.max(...others)))}`);
line();
line('## 私の手札');
line(`${cardsText(h.hero.cards)}(${h.hero.key})`);
line();
// アクション。ポット・各自の残りスタックをたどりながら書く
line('## アクション');
line('(「ベット/レイズ to」はそのストリートでの合計額、「コール」はその行動で出した額)');
const stack = new Map(h.players.map((p) => [p.seat, p.start]));
const folded = new Set();
let pot = 0;
const post = (seat, blind) => {
const p = player(seat);
if (!p) return 0;
const paid = Math.min(blind, p.start);
stack.set(seat, stack.get(seat) - paid);
pot += paid;
return paid;
};
const sbPaid = post(h.sbSeat, h.sb);
const bbPaid = post(h.bbSeat, h.bb);
const actionsOf = (st) => h.actions.filter((a) => a.street === st);
for (const st of STREETS) {
const acts = actionsOf(st);
const boardShown = h.board.length >= BOARD_COUNT[st];
if (st !== 'PREFLOP' && !boardShown) break;
const alive = h.players.filter((p) => !folded.has(p.seat)).length;
if (alive <= 1) break;
line();
if (st === 'PREFLOP') {
line(`### プリフロップ(${h.players.length}人)`);
line(`- ${who(h.sbSeat)}: SB ${money(sbPaid)} を支払う`);
line(`- ${who(h.bbSeat)}: BB ${money(bbPaid)} を支払う`);
} else {
const board = h.board.slice(0, BOARD_COUNT[st]);
const head = st === 'FLOP' ? cardsText(board) : `${cardText(board[board.length - 1])}(ボード: ${cardsText(board)})`;
line(`### ${STREET_LABEL[st]}: ${head}`);
line(`- ポット ${amount(pot)}、残り${alive}人`);
if (!acts.length) line('- (全員オールインのため行動なし)');
}
for (const a of acts) {
const mine = a.seat === h.heroSeat && !a.auto;
let text;
if (a.type === 'FOLD') text = 'フォールド';
else if (a.type === 'CHECK') text = 'チェック';
else if (a.type === 'CALL') text = `コール ${money(a.added)}`;
else if (a.type === 'BET') text = `ベット ${money(a.amount)}`;
else text = `レイズ to ${money(a.amount)}`;
if (a.allIn) text += '(オールイン)';
if (a.auto) text += '(離席中の自動の行動)';
const before = mine ? ` ← 私の判断(行動前のポット ${amount(a.pot)}${a.toCall ? `、コールに ${money(a.toCall)} 必要` : ''}、私の残りスタック ${amount(stack.get(a.seat))})` : '';
line(`- ${who(a.seat)}: ${text}${before}`);
stack.set(a.seat, stack.get(a.seat) - a.added);
pot += a.added;
if (a.type === 'FOLD') folded.add(a.seat);
}
}
line();
line('## 結果');
const shown = h.showdown ? h.players.filter((p) => !p.folded && p.seat !== h.heroSeat) : [];
if (shown.length) {
line('- ショーダウンで公開された手札:');
for (const p of shown) line(` - ${who(p.seat)}: ${cardsText(p.cards)}`);
} else if (!h.showdown) {
line('- ショーダウンなし(相手の手札は公開されていない)');
}
(h.results || []).forEach((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メインポット' : `サイドポット${i}`) : 'ポット';
const winners = r.winners.map(who).join('・');
line(`- ${tag} ${money(r.amount)} → ${winners}${r.handName ? `(${r.handName})` : ''}`);
});
line(`- 私の収支: ${signedMoney(h.hero.net)}(${signedNum(round1(h.hero.net / h.bb))}BB)`);
line();
// サイトの自動判定(参考)
line('## このサイトの自動判定(参考)');
line('このサイトが簡易的に出した目安です。ソルバー(GTO)ではありません。鵜呑みにせず独立に評価し、おかしいと思う点があれば指摘してください。');
const color = h.hero.colorLabel ? `${h.hero.colorName}(${h.hero.colorLabel})` : h.hero.colorName;
line(`- 登録してあるハンドレンジ(6-max のオープンレンジ表)での私の手の分類: ${color}`);
for (const j of h.preflop) {
line(`- プリフロップ ${j.mark} ${j.score}点: ${j.situation}。推奨 ${KIND_LABEL[j.recommended]}、私は ${KIND_LABEL[j.actual]}(${j.label})`);
if (j.equity != null) line(` - 参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)`);
}
for (const d of h.decisions) {
const opt = (o) => (o.kind === 'raise' ? `ベット/レイズ to ${money(o.to)}` : KIND_LABEL[o.kind]);
const isActual = (o) => o.kind === d.actual.kind && o.to === d.actual.to;
const evs = d.options.map((o) => `${opt(o)} ${signedNum(o.evBB)}BB${isActual(o) ? '(私)' : ''}`).join(' / ');
line(`- ${STREET_LABEL[d.street]} ${d.mark} ${d.score}点: 相手の推定レンジに対する勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}`);
line(` - 選択肢ごとの期待値の目安: ${evs}`);
}
const ai = h.hero.allIn;
if (ai) line(`- オールイン: ${STREET_LABEL[ai.street]}で決着した時点の私の勝率 ${ai.equity}%(期待値 ${money(ai.ev)}、実際に受け取った額 ${money(ai.won)})`);
line();
line('## 質問');
line('1. 私の各アクション(「← 私の判断」の行)は良かったですか? それぞれ「良い/許容範囲/悪い」で評価し、理由を説明してください。');
line('2. 悪かった判断があれば、代わりにどうすべきだったか(アクションとサイズ)と、その理由を教えてください。');
line('3. 相手の行動から考えられる相手のレンジと、このハンドから学べるポイントを教えてください。');
return out.join('\n');
}
module.exports = { buildHandPrompt };
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// レビューの保存先(data/reviews/<id>.json)。テーブルを閉じた後も見返せるようにファイルに残す。
// 対局中のものはメモリにも置き、ハンドが終わるたびに書き出す
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.REVIEW_DIR || path.join(__dirname, '..', '..', 'data', 'reviews');
const MAX_SESSIONS = 50; // これより古い対局は消す
const MAX_HANDS = 1000; // 1回の対局で保存するハンド数の上限
const active = new Map(); // id → 対局(メモリ上)
const writing = new Map(); // id → 書き出し中の Promise(順番に書く)
const validId = (id) => typeof id === 'string' && /^[0-9a-f]{16}$/.test(id);
const fileOf = (id) => path.join(DIR, `${id}.json`);
function create({ tableName, heroName, startingStack }) {
const session = {
id: crypto.randomBytes(8).toString('hex'),
tableName,
heroName,
startingStack,
createdAt: Date.now(),
updatedAt: Date.now(),
hands: [],
};
active.set(session.id, session);
return session;
}
function addHand(session, record) {
if (session.hands.length >= MAX_HANDS) return;
session.hands.push(record);
session.updatedAt = Date.now();
save(session);
}
function save(session) {
const prev = writing.get(session.id) || Promise.resolve();
const next = prev
.then(async () => {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(session.id)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(session), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(session.id));
await prune();
})
.catch((err) => console.error('[review] 保存に失敗しました', err.message));
writing.set(session.id, next);
next.then(() => {
if (writing.get(session.id) === next) writing.delete(session.id);
});
return next;
}
// テーブルが閉じられたらメモリから外す(ファイルには残る)
function close(session) {
active.delete(session.id);
}
async function readAll() {
let files;
try {
files = await fs.promises.readdir(DIR);
} catch {
return [];
}
const out = [];
for (const f of files) {
const id = f.replace(/\.json$/, '');
if (!f.endsWith('.json') || !validId(id)) continue;
const s = await get(id);
if (s) out.push(s);
}
return out;
}
// 古い対局のファイルを消す(更新日時で判断する。中身は読まない)
async function prune() {
let files;
try {
files = (await fs.promises.readdir(DIR)).filter((f) => f.endsWith('.json'));
} catch {
return;
}
if (files.length <= MAX_SESSIONS) return;
const stats = [];
for (const f of files) {
const st = await fs.promises.stat(path.join(DIR, f)).catch(() => null);
if (st) stats.push({ f, mtime: st.mtimeMs });
}
stats.sort((a, b) => b.mtime - a.mtime);
for (const { f } of stats.slice(MAX_SESSIONS)) {
if (active.has(f.replace(/\.json$/, ''))) continue;
await fs.promises.unlink(path.join(DIR, f)).catch(() => {});
}
}
async function get(id) {
if (!validId(id)) return null;
if (active.has(id)) return active.get(id);
try {
return JSON.parse(await fs.promises.readFile(fileOf(id), 'utf8'));
} catch {
return null;
}
}
// 一覧(新しい順)。中身のハンドは含めない
async function list() {
const all = await readAll();
for (const s of active.values()) if (!all.some((x) => x.id === s.id)) all.push(s);
return all
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((s) => ({
id: s.id,
tableName: s.tableName,
heroName: s.heroName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
hands: s.hands.length,
active: active.has(s.id),
}));
}
module.exports = { create, addHand, close, get, list, validId, DIR };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// 1回の対局(レビュー付きCPU戦のテーブル1つ)のハンドのレビューをまとめる
// 成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、ストリートごとの平均点、
// 色ごとの参加率とレンジどおりに打てた割合、ポジションごとの傾向とクセ、見直したいハンド、収支の推移
const HandRange = require('../../admin/range-data');
const { STREETS, COLOR_LABEL } = require('./analyze');
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((x, y) => x + y, 0) / xs.length) : null);
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'BTN/SB', 'SB', 'BB'];
const STREET_LABEL = { FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
// クセとして数える判定と、その説明
const LEAK_TEXT = {
loose: 'レンジ外の手で参加',
tight: '参加できる手で降りた',
passive: 'レイズできる手でコール',
aggressive: 'コールまでの手でレイズ',
needless: 'チェックできるのに降りた',
};
function summarize(hands) {
const n = hands.length;
const count = (f) => hands.filter(f).length;
const stat = (key) => count((h) => h.stats[key]);
const sawFlop = stat('sawFlop');
const showdowns = stat('showdown');
const aggressive = hands.reduce((a, h) => a + h.stats.aggressive, 0);
const calls = hands.reduce((a, h) => a + h.stats.calls, 0);
const stats = {
hands: n,
vpip: pct(stat('vpip'), n),
pfr: pct(stat('pfr'), n),
threeBet: pct(stat('threeBet'), stat('threeBetOpp')),
threeBetOpp: stat('threeBetOpp'),
cbet: pct(stat('cbet'), stat('cbetOpp')),
cbetOpp: stat('cbetOpp'),
wtsd: pct(showdowns, sawFlop),
wsd: pct(stat('wonShowdown'), showdowns),
af: calls ? Math.round((aggressive / calls) * 100) / 100 : aggressive ? null : 0,
};
const streets = {};
for (const st of STREETS) streets[st] = mean(hands.map((h) => h.streetScores[st]).filter((x) => x != null));
const total = mean(hands.map((h) => h.handScore).filter((x) => x != null));
// 色ごと: 配られた回数・参加した回数・プリフロップの判断がレンジどおりだった割合
const colors = HandRange.CATEGORIES.map((c) => {
const hs = hands.filter((h) => h.hero.color === c.id);
const judged = hs.flatMap((h) => h.preflop);
return {
id: c.id,
name: c.name,
label: COLOR_LABEL[c.id] || c.label,
dealt: hs.length,
played: hs.filter((h) => h.stats.vpip).length,
judged: judged.length,
good: judged.filter((j) => j.verdict === 'good').length,
};
});
// ポジションごと
const byPos = new Map();
for (const h of hands) {
const pos = h.hero.position;
if (!byPos.has(pos)) byPos.set(pos, { position: pos, hands: 0, played: 0, judged: 0, good: 0, verdicts: {} });
const p = byPos.get(pos);
p.hands++;
if (h.stats.vpip) p.played++;
for (const j of h.preflop) {
p.judged++;
if (j.verdict === 'good') p.good++;
else p.verdicts[j.verdict] = (p.verdicts[j.verdict] || 0) + 1;
}
}
const positions = [...byPos.values()].sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
// クセ: ポジション × 間違いの種類で多いもの。フロップ以降は失った期待値が大きいストリート × 行動
const leaks = [];
for (const p of positions) {
for (const [v, k] of Object.entries(p.verdicts)) leaks.push({ text: `${p.position}: ${LEAK_TEXT[v]}`, count: k, of: p.judged });
}
const postLeaks = new Map();
for (const h of hands) {
for (const d of h.decisions) {
if (d.score >= 70) continue;
const key = `${d.street}:${d.actual.kind}`;
if (!postLeaks.has(key)) postLeaks.set(key, { street: d.street, kind: d.actual.kind, count: 0, lossBB: 0 });
const x = postLeaks.get(key);
x.count++;
x.lossBB += d.lossBB;
}
}
const KIND = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'ベット・レイズ' };
for (const x of postLeaks.values()) {
leaks.push({ text: `${STREET_LABEL[x.street]}: ${KIND[x.kind]}で期待値を失った(合計 ${Math.round(x.lossBB * 10) / 10}BB)`, count: x.count });
}
leaks.sort((a, b) => b.count - a.count);
// 見直したいハンド: 失った期待値(BB)が大きい順、同じならプリフロップの判定が悪い順
const worst = hands
.filter((h) => h.lossBB > 0 || (h.handScore != null && h.handScore < 100))
.map((h) => ({
number: h.number,
key: h.hero.key,
color: h.hero.color,
position: h.hero.position,
lossBB: h.lossBB,
handScore: h.handScore,
net: h.hero.net,
bb: h.bb,
}))
.sort((a, b) => b.lossBB - a.lossBB || a.handScore - b.handScore)
.slice(0, 5);
// 収支の推移(実際の収支と、オールインの期待値で運を除いた収支)
let net = 0;
let evNet = 0;
const graph = hands.map((h) => {
net += h.hero.net;
evNet += h.hero.evNet;
return { number: h.number, net, evNet };
});
return { stats, streets, total, colors, positions, leaks: leaks.slice(0, 6), worst, graph };
}
module.exports = { summarize };
+90
View File
@@ -0,0 +1,90 @@
'use strict';
// 1ハンドから、1人のプレイヤーの統計の材料(数えるだけ。割合は summary.js で出す)を取り出す。
// 離席・退席などで自動で行った行動は本人の判断として数えない。キャッシュオンリー(場札なし)でも同じように数える
const { positionOf, ACTION_KIND } = require('../review/preflop');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// スチール(ATS)を試みられる位置: 全員が降りて回ってきた CO・BTN・SB
const STEAL_POSITIONS = new Set(['CO', 'BTN', 'SB', 'BTN/SB']);
function handStats(hand, seat) {
const p = hand.players.get(seat);
const mine = hand.log.filter((e) => e.seat === seat && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const kind = (e) => ACTION_KIND[e.type];
// フロップを見た: プリフロップで降りておらず、ハンドがフロップまで進んだ(プリフロップのオールインで
// 残りをめくったときも含む)
const foldedPre = hand.log.some((e) => e.seat === seat && e.street === 'PREFLOP' && e.type === 'FOLD');
const reachedFlop = hand.showdown || hand.board.length >= 3 || hand.log.some((e) => e.street !== 'PREFLOP');
const sawFlop = !foldedPre && reachedFlop;
const showdown = Boolean(hand.showdown && !p.folded);
const won = p.won > 0;
// スチールと 3bet の機会(プリフロップを順に見る)
const position = positionOf(hand, seat);
let entered = false; // 自分より前に誰かがコール・レイズした
let raises = 0;
let first = true;
let atsOpp = false;
let ats = false;
let threeBetOpp = false;
let threeBet = false;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
const k = kind(e);
if (e.seat === seat && !e.auto) {
if (first && !entered && STEAL_POSITIONS.has(position)) {
atsOpp = true;
ats = k === 'raise';
}
first = false;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
if (raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = k === 'raise';
}
}
if (k === 'call' || k === 'raise') entered = true;
if (k === 'raise') raises++;
}
// ストリートごと: 判断した(そのストリートに来た)・降りた・全部の行動のうちベット・レイズ
const seen = [0, 0, 0, 0];
const folds = [0, 0, 0, 0];
const actions = [0, 0, 0, 0];
const aggressive = [0, 0, 0, 0];
for (const e of mine) {
const i = STREETS.indexOf(e.street);
if (i < 0) continue;
seen[i] = 1;
actions[i]++;
if (kind(e) === 'raise') aggressive[i]++;
if (e.type === 'FOLD') folds[i] = 1;
}
const start = hand.startStacks.get(seat);
const end = hand.endStacks ? hand.endStacks.get(seat) : start;
return {
preflop: pre.length > 0, // VPIP・PFR の分母(BB で全員が降りてきたハンドなど、判断の無いハンドは除く)
vpip: pre.some((e) => kind(e) === 'call' || kind(e) === 'raise'),
pfr: pre.some((e) => kind(e) === 'raise'),
atsOpp,
ats,
threeBetOpp,
threeBet,
sawFlop,
showdown,
showdownWon: showdown && won,
wonSawFlop: sawFlop && won,
won,
seen,
folds,
actions,
aggressive,
netBB: hand.bb > 0 ? (end - start) / hand.bb : 0,
};
}
module.exports = { handStats, STREETS };
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// プレイヤーごとの統計の保存先(data/stats/<プレイヤーIDのハッシュ>.json)。
// プレイヤーID(ブラウザの localStorage にある席のトークン)はそのまま残さず、SHA-256 の先頭で名前を付ける。
// 中身はテーブルの種類ごとの回数の合計だけで、ハンドの内容は持たない。
// 同じプレイヤーの読み書きは順番に行う(ハンドが続けて終わっても数え漏らさない)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.STATS_DIR || path.join(__dirname, '..', '..', 'data', 'stats');
const MODES = ['chips', 'friends', 'cpu'];
// 続けて遊んでいるとみなす間隔(前のハンドの終わりからこれ以内に始まったら、間の時間もプレイ時間に入れる)
const CONTINUOUS_MS = 3 * 60 * 1000;
const queue = new Map(); // key → 直前の読み書きの Promise
const cache = new Map(); // key → 中身(読んだもの・書いたもの)
const keyOf = (token) => crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 32);
const fileOf = (key) => path.join(DIR, `${key}.json`);
const streets = () => [0, 0, 0, 0];
function emptyBucket() {
return {
hands: 0,
won: 0,
preflop: 0,
vpip: 0,
pfr: 0,
atsOpp: 0,
ats: 0,
threeBetOpp: 0,
threeBet: 0,
sawFlop: 0,
showdown: 0,
showdownWon: 0,
wonSawFlop: 0,
seen: streets(),
folds: streets(),
actions: streets(),
aggressive: streets(),
netBB: 0,
ms: 0,
};
}
const empty = () => ({ version: 1, firstAt: null, lastAt: null, modes: {} });
async function load(key) {
if (cache.has(key)) return cache.get(key);
let data;
try {
data = JSON.parse(await fs.promises.readFile(fileOf(key), 'utf8'));
if (!data || typeof data !== 'object' || !data.modes) data = empty();
} catch {
data = empty();
}
cache.set(key, data);
return data;
}
async function write(key, data) {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(key)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(key));
}
// 同じ key の処理を順番に行う
function serial(key, fn) {
const prev = queue.get(key) || Promise.resolve();
const next = prev.then(fn);
const settled = next.catch((err) => console.error('[stats] 保存に失敗しました', err.message));
queue.set(key, settled);
settled.then(() => {
if (queue.get(key) === settled) queue.delete(key);
});
return next;
}
function add(bucket, entry, ms) {
bucket.hands++;
for (const k of ['won', 'preflop', 'vpip', 'pfr', 'atsOpp', 'ats', 'threeBetOpp', 'threeBet', 'sawFlop', 'showdown', 'showdownWon', 'wonSawFlop']) {
if (entry[k]) bucket[k]++;
}
for (const k of ['seen', 'folds', 'actions', 'aggressive']) {
for (let i = 0; i < 4; i++) bucket[k][i] += entry[k][i] || 0;
}
bucket.netBB = Math.round((bucket.netBB + entry.netBB) * 100) / 100;
bucket.ms += ms;
}
// 1ハンド分を足す。startedAt / endedAt はハンドの始まりと終わりの時刻
function record(token, mode, entry, { startedAt, endedAt }) {
if (!MODES.includes(mode)) return Promise.resolve();
const key = keyOf(token);
return serial(key, async () => {
const data = await load(key);
// プレイ時間: ハンドの長さ。前のハンドから続けて遊んでいれば、間の待ち時間も入れる
const continuous = data.lastAt != null && startedAt - data.lastAt >= 0 && startedAt - data.lastAt <= CONTINUOUS_MS;
const ms = Math.max(0, endedAt - (continuous ? data.lastAt : startedAt));
if (!data.modes[mode]) data.modes[mode] = emptyBucket();
add(data.modes[mode], entry, ms);
if (data.firstAt == null) data.firstAt = startedAt;
data.lastAt = endedAt;
await write(key, data);
});
}
// 書き込み中のものを待ってから返す
function get(token) {
const key = keyOf(token);
return serial(key, () => load(key));
}
module.exports = { record, get, emptyBucket, MODES, DIR };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// 保存してある回数から、画面に出す割合を作る。テーブルの種類ごとと、すべてを合わせた合計(total)
const { emptyBucket, MODES } = require('./store');
const pct = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
function merge(buckets) {
const out = emptyBucket();
for (const b of buckets) {
for (const [k, v] of Object.entries(b)) {
if (Array.isArray(v)) v.forEach((x, i) => (out[k][i] += x));
else out[k] += v;
}
}
out.netBB = Math.round(out.netBB * 100) / 100;
return out;
}
function view(b) {
return {
hands: b.hands,
won: b.won,
winRate: pct(b.won, b.hands),
vpip: pct(b.vpip, b.preflop),
pfr: pct(b.pfr, b.preflop),
ats: pct(b.ats, b.atsOpp),
atsOpp: b.atsOpp,
threeBet: pct(b.threeBet, b.threeBetOpp),
threeBetOpp: b.threeBetOpp,
sawFlop: b.sawFlop,
showdowns: b.showdown,
wtsd: pct(b.showdown, b.sawFlop),
wsd: pct(b.showdownWon, b.showdown),
wwsf: pct(b.wonSawFlop, b.sawFlop),
// ストリートごとの降りた割合(そのストリートで判断したハンドのうち)
fold: b.folds.map((f, i) => pct(f, b.seen[i])),
// アグレッション頻度: 全部の行動(フォールド・チェック・コール・ベット・レイズ)のうちベット・レイズ
afq: pct(sum(b.aggressive), sum(b.actions)),
afqStreets: b.aggressive.map((a, i) => pct(a, b.actions[i])),
netBB: b.netBB,
bb100: b.hands > 0 ? Math.round((b.netBB / b.hands) * 1000) / 10 : null,
hours: Math.round((b.ms / 3600000) * 10) / 10,
};
}
function summarize(data) {
const modes = {};
for (const m of MODES) modes[m] = view(data.modes[m] || emptyBucket());
modes.total = view(merge(MODES.map((m) => data.modes[m]).filter(Boolean)));
return { since: data.firstAt, modes };
}
module.exports = { summarize };
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more