diff --git a/PokerKit/Sources/PokerKit/FourBetRange.swift b/PokerKit/Sources/PokerKit/FourBetRange.swift new file mode 100644 index 0000000..3f10d55 --- /dev/null +++ b/PokerKit/Sources/PokerKit/FourBetRange.swift @@ -0,0 +1,154 @@ +import Foundation + +public enum FourBetAction: String, Sendable { + case fourBetValue = "4-Bet (Value)" + case fourBetBluff = "4-Bet (Bluff)" + case call = "Call" + case fold = "Fold" +} + +public struct FourBetDecision: Sendable { + public let action: FourBetAction + public let handScore: Double + public let valueThreshold: Double + public let callThreshold: Double + public let totalContinuePercentage: Double + public let fourBetPercentage: Double + public let isBluffCombo: Bool + + public var reasoning: String { + let continuePct = String(format: "%.0f", totalContinuePercentage) + switch action { + case .fourBetValue: + return "Hand strength score \(formatted(handScore)) clears the 4-bet-value threshold of " + + "\(formatted(valueThreshold)) — part of the top \(continuePct)% hero continues with here." + case .fourBetBluff: + return "A designated blocker-bluff combo hero also would have opened — " + + "part of the top \(continuePct)% hero continues with here." + case .call: + return "Hand strength score \(formatted(handScore)) clears the calling threshold of " + + "\(formatted(callThreshold)) but isn't a 4-bet-value hand or a designated bluff combo — " + + "part of the top \(continuePct)% hero continues with here." + case .fold: + return "Hand strength score \(formatted(handScore)) is below the calling threshold of " + + "\(formatted(callThreshold)) (top \(continuePct)% hero continues with here)." + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +/// Hero **opened**, got **3-bet**, and now decides fold / call / 4-bet — the one preflop +/// decision point nothing else in this codebase covers. `PushFoldRange`/`OpeningRange` model +/// hero as the first aggressor; `CallingRange`/`ThreeBetRange` model hero reacting to +/// someone else's shove or open. This is hero reacting to someone reacting to *them*. +/// +/// **This is this codebase's least-certain preflop model.** Every other model has at least +/// one genuinely-sourced anchor from a dedicated source about that exact situation. +/// `FourBetRange`'s one anchor is a single reported example (a cutoff open facing a button +/// 3-bet, continuing 67% — 50% call, 17% four-bet, folding 33%, found via web search) for +/// *one specific position pairing*, generalized to every other pairing by the same +/// `OpeningRange`-ratio scaling technique used throughout this codebase. Treat every number +/// here as directional, not precise — see `RANGES.md` for the full derivation. +public enum FourBetRange { + /// The one sourced anchor: cutoff (opener) facing a button 3-bet continues 67% of hands + /// (50% call + 17% four-bet), assumed ~100bb (the source didn't specify a stack depth — + /// see `RANGES.md`). + private static let openerContinueVsAnchor: Double = 67 + private static let fourBetShareOfContinueVsAnchor: Double = 17.0 / 67.0 + + /// The anchor pairing itself, so every other spot can be expressed as a ratio against it. + private static let anchorOpener: Position = .cutoff + private static let anchorThreeBettor: DefendingPosition = .button + + /// Same blocker-bluff selection `ThreeBetRange` uses — suited wheel aces are the + /// standard 4-bet bluff too (see `RANGES.md`), for the same reason: they block villain's + /// AA/AK/KK while keeping real equity if called. + public static var bluffCombos: Set { ThreeBetRange.bluffCombos } + private static let bluffPercentageOfCanonicalHands: Double = Double(ThreeBetRange.bluffCombos.count) / 169.0 * 100.0 + + /// % of hands hero (the original opener) continues with — call or 4-bet, combined — + /// facing a 3-bet from `threeBettor`, or `nil` if `threeBettor` couldn't actually be + /// facing hero's open (they'd have to act before `opener` at an unopened table — the + /// same `actionOrderIndex` validity check every other defending model in this codebase + /// uses). + /// + /// Scaled by how wide `ThreeBetRange` predicts `threeBettor` is 3-betting `opener` at + /// this spot, relative to how wide it predicts the anchor pairing 3-bets — a narrower, + /// more polarized-and-therefore-stronger 3-bettor should see hero continue tighter; + /// a wider one, looser. Reuses `ThreeBetRange`'s own numbers rather than inventing a + /// second opinion on 3-bet width. + public static func totalContinuePercentage(opener: Position, threeBettor: DefendingPosition, effectiveStackBB: Double) -> Double? { + guard threeBettor.actionOrderIndex > opener.actionOrderIndex else { return nil } + + let anchorThreeBetPercentage = ThreeBetRange.totalThreeBetPercentage( + defender: anchorThreeBettor, opener: anchorOpener, effectiveStackBB: effectiveStackBB + ) ?? openerContinueVsAnchor // should never actually be nil (the anchor pairing is always valid), guarded defensively + let thisThreeBetPercentage = ThreeBetRange.totalThreeBetPercentage( + defender: threeBettor, opener: opener, effectiveStackBB: effectiveStackBB + ) ?? anchorThreeBetPercentage + + let ratio = anchorThreeBetPercentage > 0 ? thisThreeBetPercentage / anchorThreeBetPercentage : 1 + return min(max(openerContinueVsAnchor * ratio, 0), 100) + } + + /// Fold/call/4-bet(value)/4-bet(bluff) decision for `hand`, hero having opened from + /// `opener` and been 3-bet by `threeBettor`, or `nil` for a nonsensical position pairing. + /// + /// Same value/bluff split shape as `ThreeBetRange.decide`: value is the top of `hand`'s + /// Chen-score ranking sized so value + the fixed bluff list equal + /// `totalContinuePercentage`'s 4-bet share; bluffs are the fixed suited-wheel-ace list, + /// included only when `hand` is also within hero's own opening range for `opener` at + /// this stack (you can't 4-bet-bluff a hand you wouldn't have opened) and + /// `effectiveStackBB >= 40` — 4-betting needs meaningfully more room behind it than + /// 3-betting does; shorter than that a "4-bet" is functionally a shove, better modeled by + /// `PushFoldRange` directly. + public static func decide( + hand: HoleCards, + opener: Position, + threeBettor: DefendingPosition, + effectiveStackBB: Double + ) -> FourBetDecision? { + guard let totalContinue = totalContinuePercentage(opener: opener, threeBettor: threeBettor, effectiveStackBB: effectiveStackBB) else { + return nil + } + + let fourBetPercentage = totalContinue * fourBetShareOfContinueVsAnchor + let handScore = ChenScore.score(for: hand) + + let wouldHaveOpened = OpeningRange.decide(hand: hand, position: opener, effectiveStackBB: effectiveStackBB).action == .raise + + // Same reasoning as `ThreeBetRange.decide`: a 4-bet range this narrow can be smaller + // than the fixed bluff carve-out, in which case the honest read is "no bluffs here," + // not a shrunken value range — see that function's doc comment for the full rationale. + let hasRoomForBluffs = fourBetPercentage > bluffPercentageOfCanonicalHands + let isBluffCombo = hasRoomForBluffs && ThreeBetRange.bluffCombos.contains(hand.notation) && wouldHaveOpened && effectiveStackBB >= 40 + + let valuePercentage = hasRoomForBluffs ? fourBetPercentage - bluffPercentageOfCanonicalHands : fourBetPercentage + let valueThreshold = PushFoldRange.scoreThreshold(forPercentage: valuePercentage) + let callThreshold = PushFoldRange.scoreThreshold(forPercentage: totalContinue) + + let action: FourBetAction + if handScore >= valueThreshold { + action = .fourBetValue + } else if isBluffCombo { + action = .fourBetBluff + } else if handScore >= callThreshold { + action = .call + } else { + action = .fold + } + + return FourBetDecision( + action: action, + handScore: handScore, + valueThreshold: valueThreshold, + callThreshold: callThreshold, + totalContinuePercentage: totalContinue, + fourBetPercentage: fourBetPercentage, + isBluffCombo: isBluffCombo + ) + } +} diff --git a/PokerKit/Sources/PokerKit/PreflopGrid.swift b/PokerKit/Sources/PokerKit/PreflopGrid.swift index 260e501..b4a4326 100644 --- a/PokerKit/Sources/PokerKit/PreflopGrid.swift +++ b/PokerKit/Sources/PokerKit/PreflopGrid.swift @@ -73,4 +73,34 @@ public enum PreflopGrid { row.map { CallingRange.decideVsOpen(hand: $0, defender: defender, opener: opener, effectiveStackBB: effectiveStackBB)! } } } + + /// The fold/call/3-bet(value)/3-bet(bluff) decision for every cell, for a given + /// defender facing an open from a given position — `nil` if `defender` couldn't + /// actually be facing that open. Uses `ThreeBetRange`'s polarized value/bluff model, + /// not `CallingRange.decideVsOpen`'s flat split — see `ThreeBetRange`'s doc comment + /// for why the two deliberately disagree. + public static func threeBetDecisions( + defender: DefendingPosition, + opener: Position, + effectiveStackBB: Double + ) -> [[ThreeBetDecision]]? { + guard defender.actionOrderIndex > opener.actionOrderIndex else { return nil } + return hands.map { row in + row.map { ThreeBetRange.decide(hand: $0, defender: defender, opener: opener, effectiveStackBB: effectiveStackBB)! } + } + } + + /// The fold/call/4-bet(value)/4-bet(bluff) decision for every cell, for hero having + /// opened from `opener` and been 3-bet by `threeBettor` — `nil` for a nonsensical + /// position pairing (see `FourBetRange.totalContinuePercentage`). + public static func fourBetDecisions( + opener: Position, + threeBettor: DefendingPosition, + effectiveStackBB: Double + ) -> [[FourBetDecision]]? { + guard threeBettor.actionOrderIndex > opener.actionOrderIndex else { return nil } + return hands.map { row in + row.map { FourBetRange.decide(hand: $0, opener: opener, threeBettor: threeBettor, effectiveStackBB: effectiveStackBB)! } + } + } } diff --git a/PokerKit/Sources/PokerKit/ThreeBetRange.swift b/PokerKit/Sources/PokerKit/ThreeBetRange.swift new file mode 100644 index 0000000..4e87ded --- /dev/null +++ b/PokerKit/Sources/PokerKit/ThreeBetRange.swift @@ -0,0 +1,179 @@ +import Foundation + +public enum ThreeBetAction: String, Sendable { + case threeBetValue = "3-Bet (Value)" + case threeBetBluff = "3-Bet (Bluff)" + case call = "Call" + case fold = "Fold" +} + +public struct ThreeBetDecision: Sendable { + public let action: ThreeBetAction + public let handScore: Double + public let valueThreshold: Double + public let callThreshold: Double + public let totalDefensePercentage: Double + public let threeBetPercentage: Double + public let isBluffCombo: Bool + + public var reasoning: String { + let defendPct = String(format: "%.0f", totalDefensePercentage) + switch action { + case .threeBetValue: + return "Hand strength score \(formatted(handScore)) clears the 3-bet-value threshold of " + + "\(formatted(valueThreshold)) — part of the top \(defendPct)% this spot defends." + case .threeBetBluff: + return "A designated blocker-bluff combo (not a raw hand-strength threshold) — " + + "part of the top \(defendPct)% this spot defends." + case .call: + return "Hand strength score \(formatted(handScore)) clears the calling threshold of " + + "\(formatted(callThreshold)) but isn't a 3-bet-value hand or a designated bluff combo — " + + "part of the top \(defendPct)% this spot defends." + case .fold: + return "Hand strength score \(formatted(handScore)) is below the calling threshold of " + + "\(formatted(callThreshold)) (top \(defendPct)% this spot defends)." + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +/// A dedicated, more carefully-sourced model of the **3-betting** slice of facing an open — +/// `CallingRange.decideVsOpen` already produces a fold/call/3-bet decision, but its 3-bet +/// split is a flat, undifferentiated top-slice of Chen score (25%/45%/35% of total defense +/// by position — see `RANGES.md`), which can't represent what a real 3-bet range actually +/// looks like: **polarized**, built from premium value hands *and* a distinct set of +/// blocker-driven bluffs, not a single contiguous slice of "hand strength." +/// +/// **This module is not a replacement for `CallingRange`** — `CallingRange.decideVsOpen` +/// is unchanged, still backs the existing "Facing Open" grid mode, and its own tests still +/// pass with its own numbers. `ThreeBetRange` is a second, more detailed opinion +/// specifically for players studying 3-bet/4-bet strategy, and the two *will* disagree on +/// a given spot's 3-bet percentage — see `RANGES.md`'s "Two opinions, on purpose" section +/// for exactly how much and why that's a disclosed choice, not an inconsistency to silently +/// paper over. +/// +/// **This is a hand-tuned study aid, not solver output** — same posture as every other +/// model in this codebase, and see "Source basis" in `RANGES.md` for exactly what's sourced +/// (one external anchor) vs. hand-tuned (everything else, clearly flagged). +public enum ThreeBetRange { + /// Big blind's 3-bet percentage against a button open at ~100bb — sourced (see + /// `RANGES.md`): commonly cited in the 12-14% range for a polarized 100bb 3-bet. This + /// project's own anchor is the midpoint, 13%. This is the model's one external anchor; + /// every other position/opener/stack combination scales off it via the same + /// `OpeningRange`-ratio technique `CallingRange.totalDefensePercentage` already uses. + private static let bigBlindThreeBetVsButton: Double = 13 + + /// Same position-based scaling factors `CallingRange.totalDefensePercentage` uses for + /// its own total-defense figure, reused here rather than inventing a second opinion on + /// "how much narrower is the small blind / a non-blind defender than the big blind." + private static let smallBlindFactor: Double = 0.65 + private static let nonBlindFactor: Double = 0.5 + + /// The standard, most-consistently-cited 3-bet **blocker bluff** selection across MTT + /// strategy sources found while building this (upswingpoker.com, tournamentpokeredge.com, + /// bbzpoker.com — see `RANGES.md`): suited wheel aces, A5s down to A2s. These block + /// villain's premium pairs and AK while retaining real equity when called — the standard + /// justification for 3-bet bluffing with them rather than, say, a middling offsuit + /// broadway that blocks less and plays worse out of position. + /// + /// **Deliberately not scaled by stack or position** — real 3-bet bluff selection is + /// chosen for blocker properties, a different axis than the raw hand-strength percentile + /// this codebase's threshold pipeline otherwise uses everywhere else. Unlike every + /// percentage in this file, this list doesn't shrink or grow with the spot; only whether + /// it's included at all does (see `decide`'s stack-depth guard below). + public static let bluffCombos: Set = ["A5s", "A4s", "A3s", "A2s"] + + /// % of the 169 canonical hands `bluffCombos` represents — used to back the value + /// threshold out of the total 3-bet percentage (see `decide`). + private static let bluffPercentageOfCanonicalHands: Double = Double(bluffCombos.count) / 169.0 * 100.0 + + /// % of hands `defender` should 3-bet (value + bluff combined) against an open from + /// `opener`, or `nil` for a nonsensical position pairing (see `CallingRange`'s + /// `actionOrderIndex` validity check, reused identically here). + public static func totalThreeBetPercentage(defender: DefendingPosition, opener: Position, effectiveStackBB: Double) -> Double? { + guard defender.actionOrderIndex > opener.actionOrderIndex else { return nil } + + let openerOpenPercentage = OpeningRange.openPercentage(position: opener, effectiveStackBB: effectiveStackBB) + let buttonOpenPercentage = OpeningRange.openPercentage(position: .button, effectiveStackBB: effectiveStackBB) + let bigBlindThreeBet = bigBlindThreeBetVsButton * (openerOpenPercentage / buttonOpenPercentage) + + let factor: Double + switch defender { + case .bigBlind: factor = 1.0 + case .smallBlind: factor = smallBlindFactor + default: factor = nonBlindFactor + } + return min(max(bigBlindThreeBet * factor, 0), 100) + } + + /// Fold/call/3-bet(value)/3-bet(bluff) decision for `hand`, or `nil` for a nonsensical + /// position pairing. + /// + /// The 3-bet range is built from two independent pieces, not one contiguous slice: + /// - **Value**: the top of `hand`'s Chen-score ranking, sized so that value + the fixed + /// bluff-combo list together equal `totalThreeBetPercentage` — i.e. the bluffs are + /// *carved out of*, not added on top of, the sourced total. + /// - **Bluff**: exactly `bluffCombos`, included whenever the spot is valid and + /// `effectiveStackBB >= 20` (3-bet bluffing needs enough stack behind it to fold out a + /// real range and still play a pot if called; shorter than that, this codebase's + /// short-stack tools — `PushFoldRange`/`CallingRange.decideVsShove` — are the better + /// model for the spot anyway). + /// + /// Reuses `CallingRange.totalDefensePercentage` as the outer call-or-better boundary + /// (still the one existing opinion on "how wide overall does this spot defend") — this + /// module only refines what's *inside* that boundary. + public static func decide( + hand: HoleCards, + defender: DefendingPosition, + opener: Position, + effectiveStackBB: Double + ) -> ThreeBetDecision? { + guard let threeBetTotal = totalThreeBetPercentage(defender: defender, opener: opener, effectiveStackBB: effectiveStackBB) else { + return nil + } + guard let totalDefense = CallingRange.totalDefensePercentage(defender: defender, opener: opener, effectiveStackBB: effectiveStackBB) else { + return nil + } + + let handScore = ChenScore.score(for: hand) + + // A spot this narrow (e.g. a non-blind defender vs. a tight UTG open) can have a + // total 3-bet percentage *smaller* than the fixed bluff-combo carve-out below — real + // advice for a spot that tight is "just value, no bluffs" (see `bluffCombos`'s doc + // comment's sources), not "shrink value to make room." Bluffs are only carved out + // when there's genuine room for them; otherwise the whole total is value, and + // `PushFoldRange.scoreThreshold` already guarantees at least the single best hand + // (AA) clears the value threshold even at very small percentages — no separate + // "is there a value range at all" guard is needed here. + let hasRoomForBluffs = threeBetTotal > bluffPercentageOfCanonicalHands + let isBluffCombo = hasRoomForBluffs && bluffCombos.contains(hand.notation) && effectiveStackBB >= 20 + + let valuePercentage = hasRoomForBluffs ? threeBetTotal - bluffPercentageOfCanonicalHands : threeBetTotal + let valueThreshold = PushFoldRange.scoreThreshold(forPercentage: valuePercentage) + let callThreshold = PushFoldRange.scoreThreshold(forPercentage: max(totalDefense, threeBetTotal)) + + let action: ThreeBetAction + if handScore >= valueThreshold { + action = .threeBetValue + } else if isBluffCombo { + action = .threeBetBluff + } else if handScore >= callThreshold { + action = .call + } else { + action = .fold + } + + return ThreeBetDecision( + action: action, + handScore: handScore, + valueThreshold: valueThreshold, + callThreshold: callThreshold, + totalDefensePercentage: totalDefense, + threeBetPercentage: threeBetTotal, + isBluffCombo: isBluffCombo + ) + } +} diff --git a/PokerKit/Tests/PokerKitTests/FourBetRangeTests.swift b/PokerKit/Tests/PokerKitTests/FourBetRangeTests.swift new file mode 100644 index 0000000..dd33d39 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/FourBetRangeTests.swift @@ -0,0 +1,92 @@ +import Testing +@testable import PokerKit + +@Test func fourBetInvalidPositionPairingsReturnNil() { + // UTG opened — nobody 3-bets it from a position that acts before UTG (impossible), and + // UTG can't have "3-bet itself." + for opener in Position.allCases { + #expect(FourBetRange.totalContinuePercentage(opener: opener, threeBettor: .utg, effectiveStackBB: 100) == nil) + } + #expect(FourBetRange.totalContinuePercentage(opener: .cutoff, threeBettor: .cutoff, effectiveStackBB: 100) == nil) + + #expect(FourBetRange.decide(hand: HoleCards(canonical: "AA")!, opener: .button, threeBettor: .utg, effectiveStackBB: 100) == nil) +} + +@Test func cutoffVsButtonThreeBetMatchesTheSourcedAnchor() { + // Sourced: a cutoff open facing a button 3-bet continues 67% (50% call + 17% four-bet), + // assumed ~100bb — the exact anchor pairing, so the ratio scaling is 1 and this should + // reproduce the raw anchor numbers. + let totalContinue = FourBetRange.totalContinuePercentage(opener: .cutoff, threeBettor: .button, effectiveStackBB: 100)! + #expect(abs(totalContinue - 67) < 0.01) + + let decision = FourBetRange.decide(hand: HoleCards(canonical: "AA")!, opener: .cutoff, threeBettor: .button, effectiveStackBB: 100)! + let expectedFourBetPercentage = 67.0 * (17.0 / 67.0) + #expect(abs(decision.fourBetPercentage - expectedFourBetPercentage) < 0.01) +} + +@Test func premiumHandAlways4BetsForValue() { + let aa = HoleCards(canonical: "AA")! + for opener in Position.allCases { + for threeBettor in DefendingPosition.allCases where threeBettor.actionOrderIndex > opener.actionOrderIndex { + let decision = FourBetRange.decide(hand: aa, opener: opener, threeBettor: threeBettor, effectiveStackBB: 100) + #expect(decision?.action == .fourBetValue, "AA should 4-bet for value vs a 3-bet from \(threeBettor) after opening \(opener)") + } + } +} + +@Test func fourBetTrashHandFolds() { + let trash = HoleCards(canonical: "72o")! + let decision = FourBetRange.decide(hand: trash, opener: .utg, threeBettor: .bigBlind, effectiveStackBB: 100)! + #expect(decision.action == .fold) +} + +@Test func totalContinueWidensAgainstANarrower3Bet() { + // ThreeBetRange itself predicts a tighter 3-bet range against an earlier opener (see + // ThreeBetRangeTests) — a tighter, more polarized-toward-premium 3-bet should make the + // original opener continue *narrower*, not wider, since the 3-bettor's range is + // stronger on average. + let vsUTGOpen = FourBetRange.totalContinuePercentage(opener: .utg, threeBettor: .bigBlind, effectiveStackBB: 100)! + let vsButtonOpen = FourBetRange.totalContinuePercentage(opener: .button, threeBettor: .bigBlind, effectiveStackBB: 100)! + #expect(vsUTGOpen < vsButtonOpen, "Facing a 3-bet should be scarier (tighter continue) after opening from UTG than from the button") +} + +@Test func bluffComboRequiresHandWouldHaveBeenOpened() { + // Every designated bluff combo that's flagged as a bluff must, by construction, also be + // a hand `OpeningRange` would have opened from that position/stack — you can't + // 4-bet-bluff with a hand you'd have folded preflop. + for combo in ThreeBetRange.bluffCombos { + let hand = HoleCards(canonical: combo)! + for opener in Position.allCases { + for threeBettor in DefendingPosition.allCases where threeBettor.actionOrderIndex > opener.actionOrderIndex { + guard let decision = FourBetRange.decide(hand: hand, opener: opener, threeBettor: threeBettor, effectiveStackBB: 100) else { continue } + if decision.isBluffCombo { + let wouldHaveOpened = OpeningRange.decide(hand: hand, position: opener, effectiveStackBB: 100).action == .raise + #expect(wouldHaveOpened, "\(combo) flagged as a 4-bet bluff from \(opener) but wouldn't have been opened there") + } + } + } + } +} + +@Test func bluffCombosDoNotApplyBelowFortyBB() { + let hand = HoleCards(canonical: "A5s")! + let decision = FourBetRange.decide(hand: hand, opener: .button, threeBettor: .bigBlind, effectiveStackBB: 25)! + #expect(!decision.isBluffCombo, "4-bet bluffing shouldn't apply below the stack depth where a 4-bet is meaningfully different from a shove") +} + +@Test func fourBetValueThresholdIsAtLeastAsTightAsCallThreshold() { + for opener in Position.allCases { + for threeBettor in DefendingPosition.allCases where threeBettor.actionOrderIndex > opener.actionOrderIndex { + let decision = FourBetRange.decide(hand: HoleCards(canonical: "AKs")!, opener: opener, threeBettor: threeBettor, effectiveStackBB: 100)! + #expect(decision.valueThreshold >= decision.callThreshold) + } + } +} + +@Test func fourBetReasoningMentionsTheRelevantThreshold() { + let value = FourBetRange.decide(hand: HoleCards(canonical: "AA")!, opener: .utg, threeBettor: .bigBlind, effectiveStackBB: 100)! + #expect(value.reasoning.contains("4-bet-value threshold")) + + let fold = FourBetRange.decide(hand: HoleCards(canonical: "72o")!, opener: .utg, threeBettor: .bigBlind, effectiveStackBB: 100)! + #expect(fold.reasoning.contains("calling threshold")) +} diff --git a/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift b/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift index beacb21..7f747a4 100644 --- a/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift +++ b/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift @@ -88,3 +88,51 @@ import Testing let shortSB = PreflopGrid.decisions(position: .smallBlind, effectiveStackBB: 1) #expect(shortSB[row][col].action == .push) } + +@Test func gridThreeBetDecisionsMatchDirectThreeBetRangeDecisions() { + for row in 0.. opener.actionOrderIndex { + let decisions = PreflopGrid.threeBetDecisions(defender: defender, opener: opener, effectiveStackBB: 100)! + #expect(decisions[0][0].action == .threeBetValue, "AA should 3-bet for value vs \(opener) from \(defender)") + } + } +} + +@Test func threeBetDecisionsNilForNonsensicalPositionPairing() { + #expect(PreflopGrid.threeBetDecisions(defender: .hijack, opener: .button, effectiveStackBB: 100) == nil) +} + +@Test func gridFourBetDecisionsMatchDirectFourBetRangeDecisions() { + for row in 0.. opener.actionOrderIndex { + let decisions = PreflopGrid.fourBetDecisions(opener: opener, threeBettor: threeBettor, effectiveStackBB: 100)! + #expect(decisions[0][0].action == .fourBetValue, "AA should 4-bet for value vs a 3-bet from \(threeBettor) after opening \(opener)") + } + } +} + +@Test func fourBetDecisionsNilForNonsensicalPositionPairing() { + #expect(PreflopGrid.fourBetDecisions(opener: .button, threeBettor: .utg, effectiveStackBB: 100) == nil) +} diff --git a/PokerKit/Tests/PokerKitTests/ThreeBetRangeTests.swift b/PokerKit/Tests/PokerKitTests/ThreeBetRangeTests.swift new file mode 100644 index 0000000..4ff5bfa --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/ThreeBetRangeTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import PokerKit + +@Test func threeBetInvalidPositionPairingsReturnNil() { + // UTG acts first — it can never be 3-betting anyone's open. + for opener in Position.allCases { + #expect(ThreeBetRange.totalThreeBetPercentage(defender: .utg, opener: opener, effectiveStackBB: 100) == nil) + } + // A position can't 3-bet its own open. + #expect(ThreeBetRange.totalThreeBetPercentage(defender: .cutoff, opener: .cutoff, effectiveStackBB: 100) == nil) + // Nor an open from someone who acts after it. + #expect(ThreeBetRange.totalThreeBetPercentage(defender: .hijack, opener: .button, effectiveStackBB: 100) == nil) + + #expect(ThreeBetRange.decide(hand: HoleCards(canonical: "AA")!, defender: .utg, opener: .button, effectiveStackBB: 100) == nil) +} + +@Test func bigBlindThreeBetVsButtonMatchesTheSourcedAnchor() { + // Sourced: BB 3-bets ~12-14% of hands vs a BTN open at 100bb (this project's anchor is + // the midpoint, 13%) — see RANGES.md. At the anchor pairing itself, the ratio scaling + // is exactly 1, so this should reproduce the raw anchor number. + let percentage = ThreeBetRange.totalThreeBetPercentage(defender: .bigBlind, opener: .button, effectiveStackBB: 100) + #expect(percentage == 13) +} + +@Test func premiumHandAlways3BetsForValue() { + let aa = HoleCards(canonical: "AA")! + for opener in Position.allCases { + for defender in DefendingPosition.allCases where defender.actionOrderIndex > opener.actionOrderIndex { + let decision = ThreeBetRange.decide(hand: aa, defender: defender, opener: opener, effectiveStackBB: 100) + #expect(decision?.action == .threeBetValue, "AA should 3-bet for value vs \(opener) from \(defender)") + } + } +} + +@Test func trashHandFolds() { + let trash = HoleCards(canonical: "72o")! + let decision = ThreeBetRange.decide(hand: trash, defender: .bigBlind, opener: .utg, effectiveStackBB: 100)! + #expect(decision.action == .fold) +} + +@Test func threeBetValueWidensAgainstALaterOpenerAtTheSameStack() { + let vsUTG = ThreeBetRange.totalThreeBetPercentage(defender: .bigBlind, opener: .utg, effectiveStackBB: 100)! + let vsButton = ThreeBetRange.totalThreeBetPercentage(defender: .bigBlind, opener: .button, effectiveStackBB: 100)! + #expect(vsUTG < vsButton, "3-bet range should be tighter against an earlier (stronger) opening range") +} + +@Test func bluffCombosAre3BetBluffsWhenStackIsDeepEnough() { + // Suited wheel aces are designated blocker bluffs whenever the spot is valid and the + // stack is deep enough (>= 20bb) — regardless of whether they'd independently clear the + // value threshold (the whole point of a polarized range: these are *not* value hands). + for combo in ThreeBetRange.bluffCombos { + let hand = HoleCards(canonical: combo)! + let decision = ThreeBetRange.decide(hand: hand, defender: .bigBlind, opener: .button, effectiveStackBB: 100)! + #expect(decision.action == .threeBetBluff || decision.action == .threeBetValue, "\(combo) should be in the 3-bet range (bluff or value) at 100bb") + #expect(decision.isBluffCombo) + } +} + +@Test func bluffCombosDoNotApplyBelowTwentyBB() { + let hand = HoleCards(canonical: "A5s")! + let decision = ThreeBetRange.decide(hand: hand, defender: .bigBlind, opener: .button, effectiveStackBB: 15)! + #expect(!decision.isBluffCombo, "3-bet bluffing shouldn't apply at short-stack push/fold depths") +} + +@Test func threeBetPercentageNeverExceedsTotalDefense() { + for opener in Position.allCases { + for defender in DefendingPosition.allCases where defender.actionOrderIndex > opener.actionOrderIndex { + for stack: Double in [20, 40, 100] { + let decision = ThreeBetRange.decide(hand: HoleCards(canonical: "AKs")!, defender: defender, opener: opener, effectiveStackBB: stack)! + #expect(decision.threeBetPercentage <= decision.totalDefensePercentage) + } + } + } +} + +@Test func valueThresholdIsAtLeastAsTightAsCallThreshold() { + for opener in Position.allCases { + for defender in DefendingPosition.allCases where defender.actionOrderIndex > opener.actionOrderIndex { + let decision = ThreeBetRange.decide(hand: HoleCards(canonical: "AKs")!, defender: defender, opener: opener, effectiveStackBB: 100)! + #expect(decision.valueThreshold >= decision.callThreshold) + } + } +} + +@Test func reasoningMentionsTheRelevantThreshold() { + let value = ThreeBetRange.decide(hand: HoleCards(canonical: "AA")!, defender: .bigBlind, opener: .utg, effectiveStackBB: 100)! + #expect(value.reasoning.contains("3-bet-value threshold")) + + let bluff = ThreeBetRange.decide(hand: HoleCards(canonical: "A4s")!, defender: .bigBlind, opener: .button, effectiveStackBB: 100)! + if bluff.action == .threeBetBluff { + #expect(bluff.reasoning.contains("blocker-bluff combo")) + } + + let fold = ThreeBetRange.decide(hand: HoleCards(canonical: "72o")!, defender: .bigBlind, opener: .utg, effectiveStackBB: 100)! + #expect(fold.reasoning.contains("calling threshold")) +} diff --git a/ai-docs/RANGES.md b/ai-docs/RANGES.md index db8566f..8b7ab46 100644 --- a/ai-docs/RANGES.md +++ b/ai-docs/RANGES.md @@ -1,11 +1,12 @@ # Preflop Ranges Source: `PokerKit/Sources/PokerKit/ChenScore.swift`, `PushFoldRange.swift`, -`OpeningRange.swift`, `CallingRange.swift`, `PushFoldSpot.swift`, -`Position.swift`. Tests: `ChenScoreTests.swift`, `PushFoldRangeTests.swift`, -`OpeningRangeTests.swift`, `CallingRangeTests.swift`. +`OpeningRange.swift`, `CallingRange.swift`, `ThreeBetRange.swift`, +`FourBetRange.swift`, `PushFoldSpot.swift`, `Position.swift`. Tests: +`ChenScoreTests.swift`, `PushFoldRangeTests.swift`, `OpeningRangeTests.swift`, +`CallingRangeTests.swift`, `ThreeBetRangeTests.swift`, `FourBetRangeTests.swift`. -Four range models live here, covering both sides of a preflop decision across +Six range models live here, covering both sides of a preflop decision across two stack regimes of an MTT: - **Push/Fold** (`PushFoldRange`) — short stacks, roughly 1–20bb, hero is the @@ -17,12 +18,20 @@ two stack regimes of an MTT: *defending*: call-or-fold against someone else's shove. - **Facing an open** (`CallingRange.decideVsOpen`) — standard stacks, hero is *defending*: fold, call, or 3-bet against someone else's open-raise. - -All four are **hand-tuned study aids, not solver output** — see each section -below for what "hand-tuned" means and where the numbers come from. The two -defending models are meaningfully less certain than the two aggressor models -— see "Facing a shove" and "Facing an open" below for exactly why, and which -parts of each to trust least. +- **3-Bet** (`ThreeBetRange`) — standard stacks, roughly 20–100bb, hero is + *defending*: a more detailed, polarized (value + bluff) opinion on the + 3-bet slice of "Facing an open," specifically for players studying 3-bet + strategy. +- **4-Bet** (`FourBetRange`) — standard stacks, roughly 20–100bb, hero + *opened*, got 3-bet, and now decides fold/call/4-bet(value)/4-bet(bluff) — + the one preflop decision point none of the other five models cover. + +All six are **hand-tuned study aids, not solver output** — see each section +below for what "hand-tuned" means and where the numbers come from. The four +defending/reacting models are meaningfully less certain than the two +aggressor models — see their sections below for exactly why, and which parts +of each to trust least. `FourBetRange` in particular is this file's single +least-certain model — see its section for why. `PushFoldRange` also has an optional PKO **bounty-adjusted** overlay — `BountyEquity` — that widens its shove percentage when hero covers a bountied @@ -318,6 +327,156 @@ documents for itself. 5. `decideVsOpen` — 3-bets at or above the 3-bet threshold, calls at or above the defend threshold, folds below it. +### "Two opinions, on purpose" + +`CallingRange.decideVsOpen` already produces a 3-bet number (via +`threeBetShare`, above) — `ThreeBetRange` is a **second, deliberately more +careful opinion on the same question**, not a replacement. `CallingRange` +stays exactly as documented above, still backs the "Facing Open" grid mode, +and its own tests still pass with its own numbers. The two *will* disagree on +a given spot, and that disagreement is disclosed here rather than silently +papered over: + +At the big blind vs. button anchor spot, 100bb: `CallingRange.decideVsOpen` +implies a **~21%** 3-bet (25% of its 84% total-defense anchor). +`ThreeBetRange.totalThreeBetPercentage` gives **13%** at the same spot (its +own sourced anchor — see below). That's an ~8-point disagreement on the +exact same question, and it's expected: `CallingRange`'s 25%/45%/35% split is +an explicitly unsourced, middling guess (see "Facing an Open" above); +`ThreeBetRange`'s 13% is the one number in either model actually backed by a +cited external figure. If you're studying 3-bet strategy specifically, trust +`ThreeBetRange`'s number over `CallingRange`'s for this spot; if you're just +looking at the "Facing Open" grid's overall shape, `CallingRange`'s number is +what's shown there and isn't being silently overridden. + +## 3-Bet Ranges + +A **fold/call/3-bet(value)/3-bet(bluff) decision**: same inputs as "Facing an +Open" (`defender: DefendingPosition` facing an `opener: Position`'s open, +roughly 20–100bb effective) but a materially different *shape* of range. +`CallingRange.decideVsOpen`'s 3-bet slice is a single contiguous top-Chen-score +band — it can't represent what a real 3-bet range actually looks like: +**polarized**, built from premium value hands *and* a distinct set of +blocker-driven bluffs that are *not* the next-best hands by raw strength. +`ThreeBetRange` models that shape directly. + +**This is a hand-tuned study aid, not solver output**, same posture as every +other model in this file. Source basis: + +- **The one sourced anchor:** big blind's 3-bet percentage against a button + open at ~100bb is commonly cited in the **12–14%** range for a polarized + 100bb 3-bet (found via web search of MTT 3-betting strategy material). This + project's own anchor is the midpoint, **13%**. Every other + position/opener/stack combination scales off this one number, via the same + `OpeningRange`-ratio technique `CallingRange.totalDefensePercentage` already + uses (`totalThreeBetPercentage(opener:) / totalThreeBetPercentage(anchor)` + scaling by `OpeningRange.openPercentage(opener:) / OpeningRange.openPercentage(.button:)`), + and by the same small-blind (0.65×) / non-blind (0.5×) factors + `CallingRange.totalDefensePercentage` already uses — reused, not + re-derived, so there's still only one opinion in this codebase on "how + much narrower is the small blind / a non-blind defender than the big + blind." +- **The bluff-combo selection** (`ThreeBetRange.bluffCombos`): suited wheel + aces, **A5s down to A2s** — the most consistently-cited 3-bet blocker-bluff + selection across MTT strategy sources found while building this + (upswingpoker.com, tournamentpokeredge.com, bbzpoker.com). These block + villain's premium pairs and AK while retaining real equity when called — + the standard justification for 3-bet bluffing with them rather than, say, + a middling offsuit broadway that blocks less and plays worse out of + position. **Deliberately not scaled by stack or position** — real 3-bet + bluff selection is chosen for blocker properties, a different axis than + the raw hand-strength percentile this codebase's threshold pipeline + otherwise uses everywhere else. This fixed list doesn't shrink or grow + with the spot; only *whether it's included at all* does — bluffs require + `effectiveStackBB >= 20` (3-bet bluffing needs enough stack behind it to + fold out a real range and still play a pot if called). +- **Value is carved out of the sourced total, not added on top of it**: + `valuePercentage = totalThreeBet − bluffPercentageOfCanonicalHands` (the + ~2.4% of the 169 canonical hands the 4 bluff combos represent) whenever + there's room; the top Chen-score slice of that size is "value." + **Narrow spots can have less total 3-bet range than the fixed bluff + carve-out** (e.g. a deep non-blind defender vs. a tight UTG open, where the + sourced total scales down to well under 2%) — in that case the model + doesn't shrink value to make room for bluffs it can't afford; it drops the + bluffs entirely and the whole (small) total becomes value-only. This + matches the qualitative guidance found across sources ("if your value + range is tight, you don't need bluffs to go with it") rather than + mechanically forcing every spot into the same value+bluff shape. +- **The call/fold boundary** still comes from `CallingRange.totalDefensePercentage` + — this module only refines what's *inside* that existing, already-disclosed + boundary, not the boundary itself. + +### The pipeline + +1. `ThreeBetRange.totalThreeBetPercentage(defender:opener:effectiveStackBB:)` + — the 13% anchor, scaled by opener strength and defender-position factor, + same technique as `CallingRange.totalDefensePercentage`. +2. `CallingRange.totalDefensePercentage(defender:opener:effectiveStackBB:)` — + reused as the outer call-or-better boundary. +3. `hasRoomForBluffs = totalThreeBet > bluffPercentageOfCanonicalHands` — the + gate described above; when false, bluffs are omitted and value takes the + whole total. +4. `PushFoldRange.scoreThreshold(forPercentage:)` — reused twice: once for + the value threshold, once for the overall call threshold. Its existing + floor-at-the-single-best-hand behavior (see `PushFoldRange`) means value + is never actually empty even at a 0%-after-carve-out spot — AA (Chen + score 20, the maximum) always clears whatever threshold a near-zero + percentage resolves to. +5. `decide` — 3-bets for value at or above the value threshold, 3-bets as a + bluff if it's a designated bluff combo and bluffs are affordable, calls at + or above the call threshold, else folds. + +## 4-Bet Ranges + +A **fold/call/4-bet(value)/4-bet(bluff) decision**: hero **opened**, got +**3-bet**, and now decides how to continue — the one preflop decision point +none of the other five models in this file cover (every other model has +hero either opening/shoving as the first aggressor, or reacting to someone +else's *first* raise/shove; this is hero reacting to someone reacting to +*them*). + +**This is this file's single least-certain model.** Every other model has at +least one genuinely-sourced anchor for *that exact situation*. `FourBetRange`'s +one anchor is a single reported example — a cutoff open facing a button +3-bet, continuing **67%** of hands (**50%** call + **17%** four-bet, folding +the rest), assumed ~100bb since the source didn't specify a stack depth +(found via web search) — generalized to every other position pairing by +scaling against `ThreeBetRange`'s own predicted 3-bet width at that pairing, +relative to its width at the anchor pairing +(`totalContinuePercentage ratio = ThreeBetRange.totalThreeBetPercentage(this pairing) / ThreeBetRange.totalThreeBetPercentage(anchor pairing)`). +This reuses `ThreeBetRange`'s own numbers rather than inventing a second +opinion on 3-bet width — but it also means `FourBetRange`'s accuracy is +capped by `ThreeBetRange`'s own (already-disclosed, itself hand-tuned) +accuracy, one layer removed from the single external data point either model +has. **Treat every number in this section as directional, not precise.** + +- **Bluffs**: the same suited-wheel-ace list `ThreeBetRange.bluffCombos` + uses (see above) — the standard 4-bet bluff selection too, for the same + blocker-driven reason. Included only when `hand` is also within hero's own + opening range for `opener` at this stack (`OpeningRange.decide(...).action == .raise` + — you can't 4-bet-bluff a hand you wouldn't have opened) and + `effectiveStackBB >= 40` — 4-betting needs meaningfully more room behind + it than 3-betting does; shorter than that, a "4-bet" is functionally a + shove, better modeled by `PushFoldRange` directly. Same room-gating as + `ThreeBetRange`: a continuing range narrower than the bluff carve-out drops + the bluffs and goes value-only, rather than forcing bluffs into a spot too + tight to have any. +- **Value** is carved out of the 4-bet share of `totalContinuePercentage` + the same way `ThreeBetRange` carves value out of its total. + +### The pipeline + +1. `FourBetRange.totalContinuePercentage(opener:threeBettor:effectiveStackBB:)` + — the 67% anchor, scaled by `ThreeBetRange`'s own ratio for this pairing + vs. the anchor pairing. +2. `fourBetPercentage = totalContinue × (17/67)` — the anchor's own + four-bet-vs-continue ratio, held fixed across every pairing (no + independent sourcing exists for how that ratio itself might shift by + position — another disclosed simplification). +3. Same `hasRoomForBluffs` gate, value/threshold/`scoreThreshold` reuse, and + decision ordering as `ThreeBetRange.decide` (see above), with + `totalContinue` playing the role `totalDefense` plays there. + ## Positions modeled `Position` (`UTG, MP, HJ, CO, BTN, SB`) deliberately **excludes the big @@ -340,10 +499,13 @@ return `nil` rather than a nonsensical decision when that's violated. - `PushFoldTrainerView` — plain random push/fold practice. - `PreflopGrid` (`PREFLOP-GRID.md`) — renders `PushFoldRange.decide` (via `decisions`), `OpeningRange.decide` (via `openingDecisions`), - `CallingRange.decideVsShove` (via `callingDecisions`), and - `CallingRange.decideVsOpen` (via `openDefenseDecisions`) for all 169 hands - at once as a grid; `PreflopRangeView` switches between all four with a - mode control. + `CallingRange.decideVsShove` (via `callingDecisions`), + `CallingRange.decideVsOpen` (via `openDefenseDecisions`), + `ThreeBetRange.decide` (via `threeBetDecisions`), and `FourBetRange.decide` + (via `fourBetDecisions`) for all 169 hands at once as a grid; + `PreflopRangeView` switches between all six with a mode control (3-Bet and + 4-Bet render a third cell color for bluff combos, distinct from value and + call). - `LeakAnalysisEngine` (`LEAK-ANALYSIS.md`) — compares hero's actual imported-hand decisions against `PushFoldRange.decide` to find deviations. Still push/fold-only; opening-range and defending-range adherence are not @@ -353,10 +515,13 @@ return `nil` rather than a nonsensical decision when that's violated. Within each model there is exactly one implementation — every consumer of a given model calls through that model directly, so there's never a second -opinion on what "correct" means for a given spot. The four models themselves -are intentionally separate (different stack regimes, different roles, -different source basis, different confidence level) rather than one model -trying to cover everything. `CallingRange`'s two halves in particular reuse -`PushFoldRange`/`OpeningRange`'s own numbers rather than re-deriving -anything — there's exactly one "how wide does this position shove/open" -opinion in the codebase, and both defending models scale off it. +opinion on what "correct" means for a given spot (the one deliberate +exception, `ThreeBetRange` vs. `CallingRange.decideVsOpen`'s 3-bet slice, is +disclosed in "Two opinions, on purpose" above rather than silently +overriding one with the other). The six models themselves are intentionally +separate (different stack regimes, different roles, different source basis, +different confidence level) rather than one model trying to cover +everything. `CallingRange`'s two halves, `ThreeBetRange`, and `FourBetRange` +all reuse `PushFoldRange`/`OpeningRange`'s own numbers rather than +re-deriving anything — there's exactly one "how wide does this position +shove/open" opinion in the codebase, and every reacting model scales off it. diff --git a/app/Sources/PreflopRangeView.swift b/app/Sources/PreflopRangeView.swift index 52a019f..f9537f8 100644 --- a/app/Sources/PreflopRangeView.swift +++ b/app/Sources/PreflopRangeView.swift @@ -9,29 +9,44 @@ private enum RangeMode: String, CaseIterable, Identifiable { case opening = "Opening" case vsShove = "Facing Shove" case vsOpen = "Facing Open" + case threeBet = "3-Bet" + case fourBet = "4-Bet" var id: String { rawValue } - /// Whether this mode is hero-as-aggressor (one position picker) or hero-as-defender - /// (an opponent-position picker plus a hero-position picker). - var isDefending: Bool { + /// Which position-picker shape this mode needs. `.position` is hero-as-aggressor (one + /// picker); `.defenderVsOpener` is hero-as-defender (opponent opens/shoves as a + /// `Position`, hero responds as a `DefendingPosition`) — `vsShove`, `vsOpen`, and + /// `threeBet` all share this shape. `.fourBet` inverts it: hero is the original + /// `Position` opener, the opponent is the `DefendingPosition` who 3-bet *them*. + enum ControlLayout { + case position + case defenderVsOpener + case openerVsThreeBettor + } + + var controlLayout: ControlLayout { switch self { - case .pushFold, .opening: return false - case .vsShove, .vsOpen: return true + case .pushFold, .opening: return .position + case .vsShove, .vsOpen, .threeBet: return .defenderVsOpener + case .fourBet: return .openerVsThreeBettor } } var stackRange: ClosedRange { switch self { case .pushFold, .vsShove: return 1...20 - case .opening, .vsOpen: return 20...100 + case .opening, .vsOpen, .threeBet, .fourBet: return 20...100 } } + /// 3-bet/4-bet default to 100bb specifically because that's the stack depth each + /// model's one sourced anchor pairing was measured at — see `RANGES.md`. var defaultStack: Double { switch self { case .pushFold, .vsShove: return 10 case .opening, .vsOpen: return 50 + case .threeBet, .fourBet: return 100 } } @@ -41,6 +56,8 @@ private enum RangeMode: String, CaseIterable, Identifiable { case .opening: return [(.accentColor, "Raise")] case .vsShove: return [(.accentColor, "Call")] case .vsOpen: return [(.accentColor, "3-Bet"), (.teal, "Call")] + case .threeBet: return [(.accentColor, "3-Bet Value"), (.purple, "3-Bet Bluff"), (.teal, "Call")] + case .fourBet: return [(.accentColor, "4-Bet Value"), (.purple, "4-Bet Bluff"), (.teal, "Call")] } } } @@ -54,6 +71,9 @@ private enum RangeMode: String, CaseIterable, Identifiable { private enum CellStyle { case primary case secondary + /// 3-bet/4-bet bluff combos — a third role distinct from `.primary` (value) and + /// `.secondary` (call), only ever produced by `.threeBet`/`.fourBet` mode. + case tertiary case bountyOnly case fold @@ -61,6 +81,7 @@ private enum CellStyle { switch self { case .primary: return .accentColor case .secondary: return .teal + case .tertiary: return .purple case .bountyOnly: return .orange case .fold: return Color(.secondarySystemBackground) } @@ -68,7 +89,7 @@ private enum CellStyle { var textColor: Color { switch self { - case .primary, .secondary, .bountyOnly: return .white + case .primary, .secondary, .tertiary, .bountyOnly: return .white case .fold: return .secondary } } @@ -89,13 +110,16 @@ struct PreflopRangeView: View { private var isBountyActive: Bool { mode == .pushFold && bountyEnabled } - /// `DefendingPosition`s that could plausibly be facing `opponentPosition` — anyone who - /// acts after them at an unopened table. The big blind is always valid, so it's a safe - /// fallback default no matter what `opponentPosition` is. - private var validHeroPositions: [DefendingPosition] { - DefendingPosition.allCases.filter { $0.actionOrderIndex > opponentPosition.actionOrderIndex } + /// `DefendingPosition`s that could plausibly be facing an open/shove from `opener` — + /// anyone who acts after them at an unopened table. The big blind is always valid (it + /// has the highest `actionOrderIndex` of any `DefendingPosition`), so it's a safe + /// fallback default no matter what `opener` is. + private func validDefendingPositions(after opener: Position) -> [DefendingPosition] { + DefendingPosition.allCases.filter { $0.actionOrderIndex > opener.actionOrderIndex } } + private var validHeroPositions: [DefendingPosition] { validDefendingPositions(after: opponentPosition) } + /// Whether each of the 169 grid cells is `PushFoldRange`'s own push/fold action — /// ignoring any bounty overlay. Only actually used in Push/Fold mode, but cheap enough /// (169 cells) that computing it unconditionally isn't worth guarding. @@ -157,6 +181,38 @@ struct PreflopRangeView: View { } } } + case .threeBet: + guard let decisions = PreflopGrid.threeBetDecisions( + defender: heroPosition, opener: opponentPosition, effectiveStackBB: effectiveStackBB + ) else { + return emptyGrid + } + return decisions.map { row in + row.map { decision in + switch decision.action { + case .threeBetValue: return .primary + case .threeBetBluff: return .tertiary + case .call: return .secondary + case .fold: return .fold + } + } + } + case .fourBet: + guard let decisions = PreflopGrid.fourBetDecisions( + opener: position, threeBettor: heroPosition, effectiveStackBB: effectiveStackBB + ) else { + return emptyGrid + } + return decisions.map { row in + row.map { decision in + switch decision.action { + case .fourBetValue: return .primary + case .fourBetBluff: return .tertiary + case .call: return .secondary + case .fold: return .fold + } + } + } } } @@ -197,6 +253,16 @@ struct PreflopRangeView: View { let threeBetPct = Double(threeBetCount) / total * 100 return "\(pct(defendPct))% of hands to defend " + "(\(pct(threeBetPct))% 3-bet)" + case .threeBet: + let valuePct = Double(flat.filter { $0 == .primary }.count) / total * 100 + let bluffPct = Double(flat.filter { $0 == .tertiary }.count) / total * 100 + return "\(pct(defendPct))% of hands to defend " + + "(\(pct(valuePct))% value, \(pct(bluffPct))% bluff)" + case .fourBet: + let valuePct = Double(flat.filter { $0 == .primary }.count) / total * 100 + let bluffPct = Double(flat.filter { $0 == .tertiary }.count) / total * 100 + return "\(pct(defendPct))% of hands to continue " + + "(\(pct(valuePct))% value, \(pct(bluffPct))% bluff)" } } @@ -209,7 +275,7 @@ struct PreflopRangeView: View { summary grid legend - if mode.isDefending { + if mode.controlLayout != .position { defenseCaveat } else if isBountyActive { bountyCaveat @@ -232,11 +298,23 @@ struct PreflopRangeView: View { .accessibilityIdentifier("rangeModePicker") .onChange(of: mode) { _, newMode in effectiveStackBB = newMode.defaultStack + // Land on each model's one sourced anchor pairing by default — see + // `ThreeBetRange`/`FourBetRange`'s doc comments for why these two spots + // specifically are the best-grounded numbers each model produces. + switch newMode { + case .threeBet: + opponentPosition = .button + heroPosition = .bigBlind + case .fourBet: + position = .cutoff + heroPosition = .button + default: + break + } } - if mode.isDefending { - defendingPositionControls - } else { + switch mode.controlLayout { + case .position: Picker("Position", selection: $position) { ForEach(Position.allCases) { position in Text(position.rawValue).tag(position) @@ -244,6 +322,10 @@ struct PreflopRangeView: View { } .pickerStyle(.segmented) .accessibilityIdentifier("positionPicker") + case .defenderVsOpener: + defendingPositionControls + case .openerVsThreeBettor: + fourBetControls } VStack(alignment: .leading, spacing: 4) { @@ -329,6 +411,45 @@ struct PreflopRangeView: View { } } + /// `.fourBet` mode's control shape is `defendingPositionControls` inverted: hero is the + /// original `Position` opener, the opponent is the `DefendingPosition` who 3-bet them — + /// reuses the same `position`/`heroPosition` state as the other modes, just with the + /// roles swapped. + private var fourBetControls: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("You Opened") + .font(.caption) + .foregroundStyle(.secondary) + Picker("You Opened", selection: $position) { + ForEach(Position.allCases) { position in + Text(position.rawValue).tag(position) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("fourBetOpenerPositionPicker") + .onChange(of: position) { _, newOpener in + if heroPosition.actionOrderIndex <= newOpener.actionOrderIndex { + heroPosition = .bigBlind + } + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("3-Bettor") + .font(.caption) + .foregroundStyle(.secondary) + Picker("3-Bettor", selection: $heroPosition) { + ForEach(validDefendingPositions(after: position)) { position in + Text(position.rawValue).tag(position) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("fourBetThreeBettorPositionPicker") + } + } + } + private var summary: some View { Text(summaryText) .font(.subheadline.bold()) @@ -388,15 +509,26 @@ struct PreflopRangeView: View { } private var defenseCaveat: some View { - Text( - mode == .vsShove - ? "Study aid, not solver output. Big-blind calls are this model's best-grounded numbers; every other caller here is a rougher approximation — see RANGES.md." - : "Study aid, not solver output. Blind-defense shape is sourced; non-blind defenders and the 3-bet/call split are hand-tuned approximations — see RANGES.md." - ) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .accessibilityIdentifier("defenseCaveatText") + Text(defenseCaveatText) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .accessibilityIdentifier("defenseCaveatText") + } + + private var defenseCaveatText: String { + switch mode { + case .pushFold, .opening: + return "" + case .vsShove: + return "Study aid, not solver output. Big-blind calls are this model's best-grounded numbers; every other caller here is a rougher approximation — see RANGES.md." + case .vsOpen: + return "Study aid, not solver output. Blind-defense shape is sourced; non-blind defenders and the 3-bet/call split are hand-tuned approximations — see RANGES.md." + case .threeBet: + return "Study aid, not solver output. Polarized value + a fixed blocker-bluff list (suited wheel aces), not a solver-derived range — bluffs only apply at 20bb+, and this model deliberately disagrees with \"Facing Open\"'s cruder 3-bet split. See RANGES.md." + case .fourBet: + return "Study aid, not solver output — this codebase's least-certain preflop model. One sourced anchor (cutoff opener vs. a button 3-bet, ~100bb); every other spot is scaled from it. Bluffs only apply at 40bb+. See RANGES.md." + } } private var bountyCaveat: some View { diff --git a/app/UITests/PreflopRangeUITests.swift b/app/UITests/PreflopRangeUITests.swift index 49718c6..c002959 100644 --- a/app/UITests/PreflopRangeUITests.swift +++ b/app/UITests/PreflopRangeUITests.swift @@ -117,6 +117,68 @@ final class PreflopRangeUITests: XCTestCase { attachScreenshot(of: app, name: "facing-open-sb") } + func testThreeBetModeRendersAndReactsToHeroChange() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Preflop Ranges"].tap() + + app.buttons["3-Bet"].tap() + + let aaCell = app.staticTexts["cell-AA"] + XCTAssertTrue(aaCell.waitForExistence(timeout: 5)) + attachScreenshot(of: app, name: "three-bet-bb") + + let summaryText = app.staticTexts["shovePercentageText"] + XCTAssertTrue(summaryText.exists) + XCTAssertTrue(summaryText.label.contains("value"), "3-Bet summary should break out value/bluff") + let bbSummary = summaryText.label + + let caveat = app.staticTexts["defenseCaveatText"] + XCTAssertTrue(caveat.waitForExistence(timeout: 5)) + XCTAssertTrue(caveat.label.contains("RANGES.md")) + + let heroPicker = app.segmentedControls["heroPositionPicker"] + XCTAssertTrue(heroPicker.waitForExistence(timeout: 5)) + heroPicker.buttons["SB"].tap() + + XCTAssertTrue(summaryText.waitForExistence(timeout: 5)) + XCTAssertNotEqual(summaryText.label, bbSummary) + attachScreenshot(of: app, name: "three-bet-sb") + } + + func testFourBetModeRendersAndReactsToOpenerChange() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Preflop Ranges"].tap() + + app.buttons["4-Bet"].tap() + + let aaCell = app.staticTexts["cell-AA"] + XCTAssertTrue(aaCell.waitForExistence(timeout: 5)) + attachScreenshot(of: app, name: "four-bet-co-vs-btn") + + let summaryText = app.staticTexts["shovePercentageText"] + XCTAssertTrue(summaryText.exists) + XCTAssertTrue(summaryText.label.contains("continue"), "4-Bet summary should describe continuing, not defending") + let coSummary = summaryText.label + + let caveat = app.staticTexts["defenseCaveatText"] + XCTAssertTrue(caveat.waitForExistence(timeout: 5)) + XCTAssertTrue(caveat.label.contains("least-certain")) + + // Opener picker scoped by accessibility identifier since "UTG" doesn't collide with + // the 3-bettor picker's options in this mode's default state. + let openerPicker = app.segmentedControls["fourBetOpenerPositionPicker"] + XCTAssertTrue(openerPicker.waitForExistence(timeout: 5)) + openerPicker.buttons["UTG"].tap() + + XCTAssertTrue(summaryText.waitForExistence(timeout: 5)) + XCTAssertNotEqual(summaryText.label, coSummary) + attachScreenshot(of: app, name: "four-bet-utg-vs-bb") + } + private func attachScreenshot(of app: XCUIApplication, name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name