From d89ce1dd3048436f683c83e4ab28423bd8ad6f0b Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Mon, 19 Oct 2026 23:31:07 +0900 Subject: [PATCH] Follow Yokosawa hand range rules for 3-bets and BB defense in reviews When the hero's own raise is re-raised, a hand in the same color as the re-raiser's estimated range is now a call (raise still needs two above). In the BB facing a single open of up to 4BB, the hero now defends wider: blue or better (white vs CO, purple frame vs BTN, one below the opener) calls, and three above raises. Opponent range estimates follow the same rules. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++-- server/review/preflop.js | 62 +++++++++++++++++++++++++++++-------- test/review.test.js | 67 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8c3ad57..532f537 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,12 @@ http://localhost:4671 を開く。 ハンドが終わるたびにレビューが開き、「次のハンドへ」を押すまで次のハンドは始まらない。 -- **ハンドレンジ**: 配られた手が登録してあるハンドレンジ(`admin/range-data.js`。レンジ学習ページと共用)の何色か、そのとおりに行動できたかを判定する。 +- **ハンドレンジ**: 配られた手が登録してあるハンドレンジ(ヨコサワハンドレンジ。`admin/range-data.js`。レンジ学習ページと共用)の何色か、そのとおりに行動できたかを判定する。 - 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)の色以上ならレイズ、それより弱ければフォールド。8人以上は赤(紺も可)、6〜7人は黄、4〜5人は緑、3人は青、2人は白、1人(SB)は紫枠。紫枠は白の一つ下の段階として扱う。 - - 前にコール・レイズした人がいる: その人のレンジを、その人の後ろの人数に対応する色と推定し、自分の手がその一つ上ならコール、二つ上ならレイズ、それより弱ければフォールド。2人目以降に参加した人は、コールなら直前までで一番強いレンジの一つ上、レイズなら二つ上と推定する。紺はどんな相手にもコールまではできる。 + - 前にコール・レイズした人がいる: その人のレンジを、その人の後ろの人数に対応する色と推定し、自分の手がその一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド。紺はどんな相手にもコールまではできる。 + - 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ。 + - BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。青以上でコール(CO のオープンには白、BTN には紫枠以上。相手の色の一つ下まで)、相手の三つ上ならレイズ。 + - 2人目以降に参加した人のレンジも同じ決まりでコール・レイズできる一番弱い色と推定する(ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。 - チェックできるときのフォールド・コールの推奨はチェックになる。判定は ◎(100点)・△(消極的 50点/攻めすぎ 40点)・✕(参加しすぎ・降りすぎ 0点)。 - **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。 - **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。 diff --git a/server/review/preflop.js b/server/review/preflop.js index 8022946..15b9f29 100644 --- a/server/review/preflop.js +++ b/server/review/preflop.js @@ -1,15 +1,20 @@ 'use strict'; -// プリフロップの判定: 配られた手が登録してあるハンドレンジの何色で、そのとおりに行動できたか。 +// プリフロップの判定: 配られた手が登録してあるハンドレンジ(ヨコサワハンドレンジ)の何色で、そのとおりに行動できたか。 // 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)に対応する色 // 以上ならレイズ(オープン)、それより弱ければフォールド // 自分より前にコール・レイズした人がいる: その人のレンジを色で推定し(その人の後ろの人数に対応する色)、 -// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、それより弱ければフォールド -// 2人目以降に参加した人のレンジは、コールなら直前までで一番強いレンジの一つ上、レイズなら二つ上と推定する -// (同じ決まりで打つ人がコール・レイズできる一番弱い色)。 +// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、同じかそれより弱ければフォールド +// 自分のレイズにリレイズされた(3bet・4betされた): 相手の推定レンジと同じ色でもコール、二つ上ならレイズ +// BB で 4BB までのオープン(リレイズなし)に直面: 安く参加できるので広く守る。青以上でコール +// (CO のオープンなら白、BTN なら紫枠以上。相手の色の一つ下まで)、三つ上ならレイズ +// 2人目以降に参加した人のレンジも同じ決まりで、コール・レイズできる一番弱い色と推定する +// (ふつうはコールなら直前までで一番強いレンジの一つ上、レイズなら二つ上)。 const HandRange = require('../../admin/range-data'); const TOP_TIER = 7; // 紺 +const BB_CALL_TIER = 3; // 青: BB はどこからのオープンにもこの色以上でコールできる +const BB_DEFENSE_MAX_OPEN = 4; // BB で広く守るのはオープンがこの BB 数まで const ACTION_KIND = { FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'raise', RAISE: 'raise' }; const ACTION_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' }; @@ -38,16 +43,23 @@ function positionOf(hand, seat) { const tierName = (tier) => (tier > TOP_TIER ? 'なし' : HandRange.categoryOfTier(Math.max(0, tier)).name); +// 前に参加した人がいるときに、コール・レイズできる一番弱い色の段階。rule は +// 'normal': 一つ上でコール、二つ上でレイズ(紺は紺に対してもコールできる) +// 'reraised': 自分のレイズにリレイズされた。同じ色でコール、二つ上でレイズ +// 'bb': BB でオープンに直面。相手の色の一つ下(青まで)でコール、三つ上でレイズ(紫枠より下は参加しない) +function thresholds(rule, ref) { + if (rule === 'reraised') return { call: Math.min(TOP_TIER, ref), raise: ref + 2 }; + if (rule === 'bb') return { call: Math.max(1, Math.min(BB_CALL_TIER, ref - 1)), raise: ref + 3 }; + return { call: Math.min(TOP_TIER, ref + 1), raise: ref + 2 }; +} + // 推奨: 'raise' / 'call' / 'fold'(チェックできるときの 'fold' と 'call' は 'check') function recommend(tier, situation, entry) { let rec; if (situation.kind === 'open') { rec = situation.behind === 0 ? 'check' : tier >= situation.required ? 'raise' : 'fold'; } else { - const ref = situation.refTier; - if (tier >= ref + 2) rec = 'raise'; - else if (tier >= ref + 1 || (tier === TOP_TIER && ref >= TOP_TIER)) rec = 'call'; - else rec = 'fold'; + rec = tier >= situation.raise ? 'raise' : tier >= situation.call ? 'call' : 'fold'; } if (rec === 'raise' && !entry.canRaise) rec = 'call'; if ((rec === 'call' || rec === 'fold') && entry.canCheck) rec = 'check'; @@ -70,19 +82,30 @@ function judgePreflop(hand, hero) { const estimated = new Map(); // 席 → 推定レンジの一番弱い色の段階 let maxTier = null; let maxSeat = null; + let raises = 0; + const raisers = new Set(); // プリフロップでレイズした席 const judgments = []; // まだ行動していない後ろの人数(自分を含めない) const behindOf = (seat) => order.slice(order.indexOf(seat) + 1).filter((s) => !acted.has(s)).length; + // 前に参加した人がいるときの決まり(thresholds を参照) + const ruleOf = (e) => { + if (raisers.has(e.seat)) return 'reraised'; + if (e.seat === hand.bbSeat && raises === 1 && e.currentBet <= BB_DEFENSE_MAX_OPEN * hand.bb) return 'bb'; + return 'normal'; + }; + hand.log.forEach((e, index) => { if (e.street !== 'PREFLOP') return; const kind = ACTION_KIND[e.type]; + const rule = maxTier == null ? null : ruleOf(e); + const limits = rule && thresholds(rule, maxTier); if (e.seat === hero && !e.auto) { const situation = maxTier == null ? { kind: 'open', behind: behindOf(hero), required: HandRange.requiredTier(behindOf(hero)) } - : { kind: 'facing', refTier: maxTier, refSeat: maxSeat }; + : { kind: 'facing', rule, refTier: maxTier, refSeat: maxSeat, ...limits }; const rec = recommend(tier, situation, e); const verdict = verdictOf(rec, kind); judgments.push({ index, situation, recommended: rec, actual: kind, verdict, ...VERDICTS[verdict] }); @@ -91,13 +114,18 @@ function judgePreflop(hand, hero) { const est = maxTier == null ? HandRange.requiredTier(behindOf(e.seat)) - : Math.min(TOP_TIER, maxTier + (kind === 'raise' ? 2 : 1)); + : Math.min(TOP_TIER, kind === 'raise' ? limits.raise : limits.call); estimated.set(e.seat, Math.max(estimated.get(e.seat) || 0, est)); - if (maxTier == null || est >= maxTier) { + // 一番強いレンジ(同じならレイズした人)を、後の人の判断の基準にする + if (maxTier == null || est > maxTier || (est === maxTier && kind === 'raise')) { maxTier = est; maxSeat = e.seat; } } + if (kind === 'raise') { + raises++; + raisers.add(e.seat); + } acted.add(e.seat); }); return { tier, judgments, estimated }; @@ -109,8 +137,16 @@ function describeSituation(situation, nameOf) { if (situation.behind === 0) return '全員が降りてBBに回ってきた'; return `前に参加者なし・後ろに${situation.behind}人 → ${tierName(situation.required)}以上でオープン`; } - const ref = situation.refTier; - return `${nameOf(situation.refSeat)} の推定レンジ ${tierName(ref)} → ${tierName(ref + 1)}以上でコール、${tierName(ref + 2)}以上でレイズ`; + const who = nameOf(situation.refSeat); + const ref = tierName(situation.refTier); + const head = + situation.rule === 'reraised' + ? `自分のレイズに ${who} がリレイズ(推定レンジ ${ref})` + : situation.rule === 'bb' + ? `BBで ${who} のオープン(推定レンジ ${ref})に直面` + : `${who} の推定レンジ ${ref}`; + const raise = situation.raise > TOP_TIER ? 'レイズはなし' : `${tierName(situation.raise)}以上でレイズ`; + return `${head} → ${tierName(situation.call)}以上でコール、${raise}`; } module.exports = { judgePreflop, positionOf, describeSituation, tierName, ACTION_KIND, ACTION_LABEL, VERDICTS }; diff --git a/test/review.test.js b/test/review.test.js index f286cfa..38124ab 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -135,6 +135,73 @@ test('プリフロップ: 前に参加者がいたら、その人の色の一つ assert.equal(j.verdict, 'good'); }); +test('プリフロップ: 自分のレイズにリレイズされたら、相手と同じ色でもコールできる', () => { + // 6人: UTG(hero、後ろに5人 → 緑)がオープン、HJ が3bet(緑の二つ上 → 赤と推定)、ほかは降りて hero に戻る + for (const [cards, action, verdict] of [ + [['Jh', 'Jd'], 'call', 'good'], // 赤 = 同じ → コール + [['Jh', 'Jd'], 'fold', 'tight'], + [['Kh', 'Qd'], 'call', 'loose'], // 黄 = 一つ下 → フォールド + [['Kh', 'Qd'], 'fold', 'good'], + [['Ah', 'Ad'], 'call', 'good'], // 紺 = 一つ上 → コール(二つ上はない) + ]) { + const t = makeTable(6); + const hero = act(t, 'raise', 30); + const hj = act(t, 'raise', 90); + for (let k = 0; k < 4; k++) act(t, 'fold'); + assert.equal(t.hand.toAct, hero); + act(t, action); + give(t, hero, cards); + const { judgments, estimated } = judgePreflop(t.hand, hero); + assert.equal(estimated.get(hj), 6); + const j = judgments[1]; + assert.equal(j.situation.rule, 'reraised'); + assert.equal(j.verdict, verdict, `${cards} ${action}`); + } +}); + +test('プリフロップ: BB は 4BB までのオープンに広くコールし、三つ上でレイズ', () => { + // 6人: CO(後ろに3人 → 青)がオープンして BB(hero)まで降りる。白以上でコール、赤以上でレイズ + const coOpen = (to, cards, action) => { + const t = makeTable(6); + act(t, 'fold'); + act(t, 'fold'); + act(t, 'raise', to); + act(t, 'fold'); + act(t, 'fold'); + const bb = t.hand.toAct; + assert.equal(bb, t.hand.bbSeat); + act(t, action, action === 'raise' ? to * 3 : 0); + give(t, bb, cards); + return judgePreflop(t.hand, bb).judgments[0]; + }; + for (const [cards, action, verdict] of [ + [['Kh', '8h'], 'call', 'good'], // 白 = 青の一つ下 → コール + [['Qh', '5h'], 'call', 'loose'], // 紫枠 → フォールド + [['Kh', 'Qd'], 'call', 'good'], // 黄 = 二つ上 → まだコール + [['Kh', 'Qd'], 'raise', 'aggressive'], + [['Jh', 'Jd'], 'raise', 'good'], // 赤 = 三つ上 → レイズ + ]) { + const j = coOpen(30, cards, action); + assert.equal(j.situation.rule, 'bb'); + assert.equal(j.verdict, verdict, `${cards} ${action}`); + } + // 4BB を超えるオープンには、ほかの席と同じく一つ上が必要 + const big = coOpen(50, ['Kh', '8h'], 'call'); + assert.equal(big.situation.rule, 'normal'); + assert.equal(big.verdict, 'loose'); + + // BTN(後ろに2人 → 白)のオープンには紫枠からコールできる。コールした BB のレンジも紫枠と推定する + const t = makeTable(6); + for (let k = 0; k < 3; k++) act(t, 'fold'); + act(t, 'raise', 25); + act(t, 'fold'); + const bb = act(t, 'call'); + give(t, bb, ['Qh', '5h']); + const { judgments, estimated } = judgePreflop(t.hand, bb); + assert.equal(judgments[0].verdict, 'good'); + assert.equal(estimated.get(bb), 1); +}); + test('オールインの期待値: AA 対 KK はおよそ 82%', () => { const hands = new Map([ [0, ['As', 'Ad']],