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>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-20 23:51:41 +09:00
1 parent 3ddb0996e0
commit d5ef2d310f
10 files changed
+294 -3

No files matched your search

+2 -1
View File
@@ -81,6 +81,7 @@ http://localhost:4671 を開く。
- **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。 - **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。
- **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。 - **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。
- **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。 - **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。
- **AIに質問する用にコピー**: テーブルのレビューとリプレイ画面のボタンで、そのハンドの状況(ゲームの条件・席とスタック・自分の手札・ストリートごとの場札とポット・全員のアクション・ショーダウンで公開された手札・結果・サイトの判定・質問文)を、ChatGPT や Claude などに貼り付けてそのまま質問できる文章にしてコピーする。判断の時点で見えていた情報だけを書き、公開されなかった相手の手札は含めない(`server/review/prompt.js`)。自動でコピーできないブラウザでは文章を表示するので手でコピーする。
- レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。 - レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。
- 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。 - 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。
@@ -110,7 +111,7 @@ http://localhost:4671 を開く。
| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う | | `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う |
| `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) | | `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) |
| `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し | | `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`) | | `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) |
| `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) | | `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) |
| `server/config.js` | `.env` の読み込みと各種上限値 | | `server/config.js` | `.env` の読み込みと各種上限値 |
+1
View File
@@ -107,6 +107,7 @@
/* リプレイ */ /* リプレイ */
.rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; } .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-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-board { display: flex; gap: 3px; }
.rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; } .rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; }
+13 -1
View File
@@ -369,11 +369,23 @@
</div> </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> <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)}`; ${allInHtml(h)}
<button type="button" class="btn btn-dark rv-copy" id="replay-copy">🤖 AIに質問する用にコピー</button>`;
$('replay-copy').addEventListener('click', () => copyHandPrompt(h));
$('replay-log').innerHTML = logHtml(h); $('replay-log').innerHTML = logHtml(h);
renderStep(); 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) { function allInHtml(h) {
const a = h.hero.allIn; const a = h.hero.allIn;
if (!a) return ''; if (!a) return '';
+45 -1
View File
@@ -139,6 +139,50 @@
return data; return data;
} }
// 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。
// サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に
// Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう
async function copyText(load) {
const pending = load();
try {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
const blob = pending.then((t) => new Blob([t], { type: 'text/plain' }));
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
return true;
}
} catch { /* 次の方法へ */ }
const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す
try {
await navigator.clipboard.writeText(text);
return true;
} catch { /* 手でコピーしてもらう */ }
showCopyDialog(text);
return false;
}
function showCopyDialog(text) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop confirm-backdrop';
backdrop.innerHTML =
'<div class="modal copy-modal" role="dialog" aria-modal="true">' +
'<h2 class="confirm-title">コピーしてください</h2>' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'<button type="button" class="btn btn-dark copy-close">閉じる</button>' +
'</div>';
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 のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する // サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
function connectSocket() { function connectSocket() {
const socket = io({ transports: ['websocket'] }); const socket = io({ transports: ['websocket'] });
@@ -167,5 +211,5 @@
FINISHING: 'FINISHING', 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 };
})(); })();
+4
View File
@@ -534,6 +534,9 @@ body.table-page {
.confirm-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; } .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 { padding: 12px 8px; font-size: 16px; white-space: normal; }
.confirm-buttons .btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; } .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-fade { from { opacity: 0; } }
@keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } } @keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } }
@media (prefers-reduced-motion: reduce) { @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-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 { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; margin-top: 16px; }
.review-buttons:has(> [hidden]) { grid-template-columns: 1fr; } .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; } .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-head { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
.rv-cards { display: flex; gap: 3px; } .rv-cards { display: flex; gap: 3px; }
+12
View File
@@ -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; }; const openReview = () => { $('review-modal').hidden = false; };
$('review-open').addEventListener('click', openReview); $('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview); $('review-show').addEventListener('click', openReview);
@@ -486,6 +497,7 @@
$('review-modal').hidden = true; $('review-modal').hidden = true;
emit('table:next'); emit('table:next');
}; };
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
$('review-next').addEventListener('click', nextHand); $('review-next').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand); $('review-modal-next').addEventListener('click', nextHand);
+1
View File
@@ -119,6 +119,7 @@
<button type="button" class="btn btn-dark" id="review-close">閉じる</button> <button type="button" class="btn btn-dark" id="review-close">閉じる</button>
<button type="button" class="btn btn-orange" id="review-modal-next">次のハンドへ</button> <button type="button" class="btn btn-orange" id="review-modal-next">次のハンドへ</button>
</div> </div>
<button type="button" class="btn btn-dark review-copy" id="review-copy">🤖 AIに質問する用にコピー</button>
<a class="link-gold review-link" id="review-link" href="/review" target="_blank" rel="noopener">対局全体のレビュー(点数表・成績・リプレイ)→</a> <a class="link-gold review-link" id="review-link" href="/review" target="_blank" rel="noopener">対局全体のレビュー(点数表・成績・リプレイ)→</a>
</div> </div>
</div> </div>
+12
View File
@@ -9,6 +9,7 @@ const auth = require('./auth');
const { TableManager, MODES } = require('./tables'); const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store'); const reviews = require('./review/store');
const { summarize } = require('./review/summary'); const { summarize } = require('./review/summary');
const { buildHandPrompt } = require('./review/prompt');
const PUBLIC = path.join(__dirname, '..', 'public'); const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す) // 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -122,6 +123,17 @@ app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
next(err); next(err);
} }
}); });
// 1ハンドの状況を AI に質問するための文章
app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
const hand = session && session.hands.find((h) => String(h.number) === req.params.number);
if (!hand) return res.status(404).json({ error: '見つかりません' });
res.json({ text: buildHandPrompt(hand) });
} catch (err) {
next(err);
}
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) })); app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req)))); app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
+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 };
+44
View File
@@ -364,3 +364,47 @@ test('レビュー付きCPU戦: 作成者だけが座れ、ハンドごとにレ
assert.equal(saved.hands.length, 1); assert.equal(saved.hands.length, 1);
assert.equal(await reviews.get('../etc/passwd'), null); 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, /## 質問/);
}
});