From d5ef2d310fe08e5a2054cd935b5b614f3caed176 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Tue, 20 Oct 2026 23:51:41 +0900 Subject: [PATCH] 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 --- README.md | 3 +- admin/review.css | 1 + admin/review.js | 14 +++- public/assets/common.js | 46 +++++++++++- public/assets/style.css | 4 + public/assets/table.js | 12 +++ public/table.html | 1 + server/index.js | 12 +++ server/review/prompt.js | 160 ++++++++++++++++++++++++++++++++++++++++ test/review.test.js | 44 +++++++++++ 10 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 server/review/prompt.js 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, /## 質問/); + } +});