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:
1 parent
3ddb0996e0
commit
d5ef2d310f
10 files changed
+294
-3
No files matched your search
@@ -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))));
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user