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, /## 質問/);
+ }
+});