diff --git a/README.md b/README.md index 70bad38..1ae5fba 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ http://localhost:4671 を開く。 - **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの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ハンドまで)。 - 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。 @@ -110,7 +111,7 @@ http://localhost:4671 を開く。 | `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う | | `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) | | `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し | -| `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `store` 保存) | +| `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `prompt` AI に質問する文章 / `store` 保存) | | `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) | | `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | diff --git a/admin/review.css b/admin/review.css index a5e448a..85a9296 100644 --- a/admin/review.css +++ b/admin/review.css @@ -107,6 +107,7 @@ /* リプレイ */ .rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; } +.rv-copy { width: 100%; margin-top: 10px; } .rv-board-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; background: var(--felt-dark); border-radius: 12px; padding: 10px; } .rv-board { display: flex; gap: 3px; } .rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; } diff --git a/admin/review.js b/admin/review.js index bf33cdf..1eddcf2 100644 --- a/admin/review.js +++ b/admin/review.js @@ -369,11 +369,23 @@
${STREETS.map((st) => `
${STREET_SHORT[st]}${h.streetScores[st] == null ? '—' : h.streetScores[st]}
`).join('')}
- ${allInHtml(h)}`; + ${allInHtml(h)} + `; + $('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 ''; diff --git a/public/assets/common.js b/public/assets/common.js index 7aa16d2..d0ce261 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -139,6 +139,50 @@ 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 backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop confirm-backdrop'; + backdrop.innerHTML = + ''; + const area = backdrop.querySelector('textarea'); + area.value = text; + const close = () => backdrop.remove(); + backdrop.querySelector('.copy-close').addEventListener('click', close); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) close(); + }); + document.body.appendChild(backdrop); + area.focus(); + area.select(); + area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す + } + // サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する function connectSocket() { const socket = io({ transports: ['websocket'] }); @@ -167,5 +211,5 @@ FINISHING: 'FINISHING', }; - window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, connectSocket, safeNext, PHASE_LABELS }; + window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS }; })(); diff --git a/public/assets/style.css b/public/assets/style.css index 8e4c58f..df3b424 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -534,6 +534,9 @@ body.table-page { .confirm-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; } .confirm-buttons .btn { padding: 12px 8px; font-size: 16px; white-space: normal; } .confirm-buttons .btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; } +/* 自動でコピーできなかったときに文章を見せるダイアログ(App.copyText) */ +.copy-modal { max-width: 520px; display: flex; flex-direction: column; gap: 10px; max-height: calc(100dvh - 32px); } +.copy-text { width: 100%; min-height: 240px; flex: 1; resize: vertical; padding: 10px; font: 12px/1.5 ui-monospace, monospace; color: inherit; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--line); border-radius: 8px; } @keyframes confirm-fade { from { opacity: 0; } } @keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } } @media (prefers-reduced-motion: reduce) { @@ -812,6 +815,7 @@ label.switch { margin: 0; display: inline-block; } .review-modal { max-width: 440px; max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; } .review-buttons { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; margin-top: 16px; } .review-buttons:has(> [hidden]) { grid-template-columns: 1fr; } +.review-copy { width: 100%; margin-top: 8px; } .review-link { display: block; margin-top: 12px; font-size: 13px; text-align: center; } .rv-head { display: flex; align-items: center; gap: 12px; margin-top: 10px; } .rv-cards { display: flex; gap: 3px; } diff --git a/public/assets/table.js b/public/assets/table.js index 5d9f6b7..1938b89 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -475,6 +475,17 @@ } } + // 直前のハンドの状況を、AI に貼り付けて質問できる文章にしてコピーする + async function copyHandPrompt(r) { + if (!r || !r.sessionId || !r.last) return; + const url = `/api/reviews/${encodeURIComponent(r.sessionId)}/hands/${r.last.number}/prompt`; + try { + if (await App.copyText(() => App.api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます'); + } catch (err) { + toast(err.message); + } + } + const openReview = () => { $('review-modal').hidden = false; }; $('review-open').addEventListener('click', openReview); $('review-show').addEventListener('click', openReview); @@ -486,6 +497,7 @@ $('review-modal').hidden = true; emit('table:next'); }; + $('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review)); $('review-next').addEventListener('click', nextHand); $('review-modal-next').addEventListener('click', nextHand); diff --git a/public/table.html b/public/table.html index cf61d34..c436b16 100644 --- a/public/table.html +++ b/public/table.html @@ -119,6 +119,7 @@ + 対局全体のレビュー(点数表・成績・リプレイ)→ diff --git a/server/index.js b/server/index.js index d05aa06..5fead59 100644 --- a/server/index.js +++ b/server/index.js @@ -9,6 +9,7 @@ const auth = require('./auth'); const { TableManager, MODES } = require('./tables'); const reviews = require('./review/store'); const { summarize } = require('./review/summary'); +const { buildHandPrompt } = require('./review/prompt'); const PUBLIC = path.join(__dirname, '..', 'public'); // 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す) @@ -122,6 +123,17 @@ app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => { next(err); } }); +// 1ハンドの状況を AI に質問するための文章 +app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req, res, next) => { + try { + const session = await reviews.get(req.params.id); + const hand = session && session.hands.find((h) => String(h.number) === req.params.number); + if (!hand) return res.status(404).json({ error: '見つかりません' }); + res.json({ text: buildHandPrompt(hand) }); + } catch (err) { + next(err); + } +}); app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) })); app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req)))); diff --git a/server/review/prompt.js b/server/review/prompt.js new file mode 100644 index 0000000..1cc9735 --- /dev/null +++ b/server/review/prompt.js @@ -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 }; diff --git a/test/review.test.js b/test/review.test.js index a3e5a4f..f969e9f 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -364,3 +364,47 @@ test('レビュー付きCPU戦: 作成者だけが座れ、ハンドごとにレ assert.equal(saved.hands.length, 1); assert.equal(await reviews.get('../etc/passwd'), null); }); + +test('AI に質問する文章: Hero に見えていた情報だけでハンドの状況を書く', () => { + const { buildHandPrompt } = require('../server/review/prompt'); + const cardText = (c) => c[0] + { s: '♠', h: '♥', d: '♦', c: '♣' }[c[1]]; + + // 全員が降りて終わる(ショーダウンなし): 相手の手札は書かない + { + const t = makeTable(3); + const h = t.hand; + act(t, 'raise', 30); // BTN + act(t, 'fold'); // SB + act(t, 'fold'); // BB + const btn = h.button; + const rec = analyzeHand(h, btn, { iterations: 100 }); + const text = buildHandPrompt(rec); + assert.match(text, new RegExp(rec.hero.cards.map(cardText).join(' '))); + assert.match(text, /BTN(Hero・私): レイズ to \$30/); + assert.match(text, /ショーダウンなし/); + for (const p of rec.players) { + if (p.seat === btn) continue; + assert.ok(!text.includes(p.cards.map(cardText).join(' ')), '公開されていない手札を書かない'); + } + } + + // ショーダウンまで行く: 場札と公開された手札を書く + { + const t = makeTable(2); + const h = t.hand; + for (let g = 0; g < 100 && !h.finished; g++) { + if (h.toAct == null) { + h.runoutStep(); + continue; + } + const o = h.options(h.toAct); + act(t, o.canCheck ? 'check' : 'call'); + } + const rec = analyzeHand(h, 0, { iterations: 100 }); + const text = buildHandPrompt(rec); + assert.ok(text.includes(`リバー: ${cardText(rec.board[4])}`)); + const villain = rec.players.find((p) => p.seat !== 0); + assert.ok(text.includes(villain.cards.map(cardText).join(' '))); + assert.match(text, /## 質問/); + } +});