From 1dc339cfcf2cc7d766dee23e398f05a43f57f99c Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Mon, 20 Jul 2026 13:11:35 +0200 Subject: [PATCH 1/2] Add calling/defense range model: facing a shove, facing an open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CallingRange module covers the two situations PushFoldRange and OpeningRange don't: hero reacting to someone else's action rather than acting first. DefendingPosition extends the six-position scheme with the big blind, since a defending hero can be exactly the position Position deliberately excludes. Both defense models reuse PushFoldRange/OpeningRange's own numbers rather than inventing a second opinion on how wide any position shoves or opens — big-blind-facing-a-shove is the one case with a real Nash equivalent (heads-up SB-shove/BB-call), everything else is a disclosed, hand-tuned extrapolation off that. See ai-docs/RANGES.md for the full source basis, including exactly which numbers are sourced vs. hand-tuned placeholders. PreflopGrid gains callingDecisions/openDefenseDecisions, returning nil for position pairings that can't happen at an unopened table (e.g. UTG can't be facing anyone's shove). 22 new tests: coverage, nil-guards, threshold monotonicity by stack and position, suited-never-loses-to-offsuit, and grid/model consistency. --- PokerKit/Sources/PokerKit/CallingRange.swift | 310 ++++++++++++++++++ PokerKit/Sources/PokerKit/PreflopGrid.swift | 28 ++ .../PokerKitTests/CallingRangeTests.swift | 241 ++++++++++++++ ai-docs/PREFLOP-GRID.md | 53 ++- ai-docs/RANGES.md | 212 ++++++++++-- 5 files changed, 804 insertions(+), 40 deletions(-) create mode 100644 PokerKit/Sources/PokerKit/CallingRange.swift create mode 100644 PokerKit/Tests/PokerKitTests/CallingRangeTests.swift diff --git a/PokerKit/Sources/PokerKit/CallingRange.swift b/PokerKit/Sources/PokerKit/CallingRange.swift new file mode 100644 index 0000000..1a61a19 --- /dev/null +++ b/PokerKit/Sources/PokerKit/CallingRange.swift @@ -0,0 +1,310 @@ +import Foundation + +// MARK: - Defending position + +/// Hero's position when **defending** — reacting to another player's shove or open — +/// rather than acting first. Distinct from `Position` because a defending hero can be the +/// big blind, which `Position` deliberately excludes (see its doc comment): the big blind +/// never opens or shoves into an unopened pot, but is exactly who most often *calls* one. +/// +/// Cases are declared in the same order as `Position` (UTG...SB) with `.bigBlind` appended, +/// so each case's index in `allCases` doubles as its action-order seat at an unopened +/// table. `CallingRange` uses that ordering to reject nonsensical combinations — a +/// position can't defend against a shove or open from a position that acts *after* it. +public enum DefendingPosition: String, CaseIterable, Identifiable, Sendable { + case utg = "UTG" + case middlePosition = "MP" + case hijack = "HJ" + case cutoff = "CO" + case button = "BTN" + case smallBlind = "SB" + case bigBlind = "BB" + + public var id: String { rawValue } + + public var fullName: String { + switch self { + case .utg: return "Under the Gun" + case .middlePosition: return "Middle Position" + case .hijack: return "Hijack" + case .cutoff: return "Cutoff" + case .button: return "Button" + case .smallBlind: return "Small Blind" + case .bigBlind: return "Big Blind" + } + } + + /// This position's seat index in the standard UTG-to-BB action order. + public var actionOrderIndex: Int { Self.allCases.firstIndex(of: self)! } +} + +extension Position { + /// This position's seat index in the standard UTG-to-BB action order — comparable + /// directly with `DefendingPosition.actionOrderIndex` since both enums declare their + /// shared six cases (UTG...SB) in identical order. + public var actionOrderIndex: Int { Self.allCases.firstIndex(of: self)! } +} + +// MARK: - Facing a shove + +public enum CallVsShoveAction: String, Sendable { + case call = "Call" + case fold = "Fold" +} + +public struct CallVsShoveDecision: Sendable { + public let action: CallVsShoveAction + public let handScore: Double + public let scoreThreshold: Double + public let callPercentage: Double + + public var reasoning: String { + let pct = String(format: "%.0f", callPercentage) + switch action { + case .call: + return "Hand strength score \(formatted(handScore)) clears the calling threshold of " + + "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this stack and position." + case .fold: + return "Hand strength score \(formatted(handScore)) is below the calling threshold of " + + "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this stack and position." + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +// MARK: - Facing an open + +public enum OpenDefenseAction: String, Sendable { + case threeBet = "3-Bet" + case call = "Call" + case fold = "Fold" +} + +public struct OpenDefenseDecision: Sendable { + public let action: OpenDefenseAction + public let handScore: Double + public let threeBetThreshold: Double + public let callThreshold: Double + public let totalDefensePercentage: Double + public let threeBetPercentage: Double + + public var reasoning: String { + let defendPct = String(format: "%.0f", totalDefensePercentage) + switch action { + case .threeBet: + return "Hand strength score \(formatted(handScore)) clears the 3-bet threshold of " + + "\(formatted(threeBetThreshold)) — part of the top \(defendPct)% this spot defends." + case .call: + return "Hand strength score \(formatted(handScore)) clears the calling threshold of " + + "\(formatted(callThreshold)) but not the 3-bet threshold of \(formatted(threeBetThreshold)) — " + + "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) + } +} + +/// Defense models for the two situations `PushFoldRange` and `OpeningRange` don't cover: +/// hero **reacting** to someone else's shove or open, rather than acting first. +/// +/// **This is a hand-tuned study aid, not solver output** — same posture as `PushFoldRange` +/// and `OpeningRange`, and considerably less certain than either. Real defending ranges +/// (especially multi-way, ICM-weighted ones) come from solvers or ICM calculators that +/// account for exact stacks, payouts, and every opponent's tendencies. See `RANGES.md` for +/// the full source basis of every number here — in short: +/// +/// - **Facing a shove, big blind calling** is the best-grounded case: it's the one +/// situation with a widely-published Nash equivalent (heads-up SB-shoves/BB-calls). +/// - **Facing a shove, small blind or a non-blind caller** and **facing an open from any +/// position** are meaningfully less certain — derived from documented qualitative +/// principles (calling needs real equity so is tighter than shoving; blind defense is +/// wide and gets wider late; small blind is more fold-or-3-bet than big blind) rather +/// than a position-by-position sourced chart, because no such chart is publicly +/// available for anything beyond the pure heads-up case. Treat every number outside +/// "BB facing a shove" as directional, not precise. +/// +/// Both models reuse `ChenScore` for hand ranking and `PushFoldRange.scoreThreshold` for +/// turning a percentage into a score cutoff — still the only hand-ranking pipeline in this +/// codebase. Neither model invents a new one. +public enum CallingRange { + // MARK: Facing a shove + + /// Same ten stack breakpoints as `PushFoldRange`, since this model's percentages are + /// discounts *off* `PushFoldRange`'s own shove percentages (see below). + private static let shoveDiscountBreakpoints: [Double] = [1, 2, 3, 5, 7, 10, 12, 15, 17, 20] + + /// Fraction of the shover's own `PushFoldRange` shove-percentage that's a profitable + /// call, aligned 1:1 with `shoveDiscountBreakpoints`. Calling always requires more + /// equity than shoving (the caller faces a real range instead of just buying fold + /// equity), so this is always < 1, and the gap narrows as the stack shortens — both + /// documented, widely-repeated qualitative facts. The specific curve is a hand-tuned + /// approximation calibrated against the one concrete external data point found: a + /// published heads-up Nash SB-shove figure of ~50% at 10bb, compared against this + /// project's own `PushFoldRange` SB shove figure of 58% at 10bb (close enough to treat + /// as the same phenomenon in a 6-max-context table). See RANGES.md. + private static let shoveDiscountByStack: [Double] = [0.90, 0.82, 0.75, 0.65, 0.58, 0.52, 0.48, 0.44, 0.41, 0.38] + + /// Further discount applied for callers other than the big blind. The big blind is the + /// only calling position with a genuine Nash equivalent (it closes the action, so a + /// shove reaching it is, in effect, the heads-up case); every other caller either has a + /// worse price (small blind posted half a blind, not a full one) or risks a player still + /// left to act waking up behind them (squeeze risk) — both reasons real defending ranges + /// there are narrower, but neither is quantified by any source found. These numbers are + /// this model's least-confident part. `.utg` is included only for dictionary + /// completeness — it can never actually be a valid caller (nobody acts before UTG). + private static let callerPositionDiscount: [DefendingPosition: Double] = [ + .bigBlind: 1.0, + .smallBlind: 0.75, + .button: 0.55, + .cutoff: 0.5, + .hijack: 0.45, + .middlePosition: 0.4, + .utg: 0.35, + ] + + private static func shoveDiscount(effectiveStackBB: Double) -> Double { + let stack = min(max(effectiveStackBB, shoveDiscountBreakpoints.first!), shoveDiscountBreakpoints.last!) + + guard let upperIndex = shoveDiscountBreakpoints.firstIndex(where: { $0 >= stack }) else { + return shoveDiscountByStack.last! + } + if shoveDiscountBreakpoints[upperIndex] == stack || upperIndex == 0 { + return shoveDiscountByStack[upperIndex] + } + + let lowerIndex = upperIndex - 1 + let lowerBB = shoveDiscountBreakpoints[lowerIndex] + let upperBB = shoveDiscountBreakpoints[upperIndex] + let fraction = (stack - lowerBB) / (upperBB - lowerBB) + return shoveDiscountByStack[lowerIndex] + fraction * (shoveDiscountByStack[upperIndex] - shoveDiscountByStack[lowerIndex]) + } + + /// % of hands it's profitable for `caller` to call a shove from `shover`, or `nil` if + /// `caller` couldn't actually be facing that shove (they'd have to act before `shover` + /// at an unopened table). + public static func callPercentage(caller: DefendingPosition, shover: Position, effectiveStackBB: Double) -> Double? { + guard caller.actionOrderIndex > shover.actionOrderIndex else { return nil } + let shovePercentage = PushFoldRange.shovePercentage(position: shover, effectiveStackBB: effectiveStackBB) + let discount = shoveDiscount(effectiveStackBB: effectiveStackBB) * callerPositionDiscount[caller]! + return min(max(shovePercentage * discount, 0), 100) + } + + /// Call/fold decision for `hand` when `caller` is facing an all-in shove from `shover` + /// at `effectiveStackBB`, or `nil` for a nonsensical position pairing (see + /// `callPercentage`). + public static func decideVsShove( + hand: HoleCards, + caller: DefendingPosition, + shover: Position, + effectiveStackBB: Double + ) -> CallVsShoveDecision? { + guard let percentage = callPercentage(caller: caller, shover: shover, effectiveStackBB: effectiveStackBB) else { + return nil + } + let threshold = PushFoldRange.scoreThreshold(forPercentage: percentage) + let handScore = ChenScore.score(for: hand) + let action: CallVsShoveAction = handScore >= threshold ? .call : .fold + return CallVsShoveDecision(action: action, handScore: handScore, scoreThreshold: threshold, callPercentage: percentage) + } + + // MARK: Facing an open + + /// Big blind's combined call+3-bet continuing frequency against a button open — + /// sourced (see RANGES.md): a commonly-cited combined-defense figure of ~84% for BB vs + /// a standard-sized button open. This is the model's one external anchor; every other + /// position/opener combination below scales off it. + private static let bigBlindDefenseVsButton: Double = 84 + + /// Small blind's total defense as a fraction of what the big blind would defend against + /// the same open — small blind gets a worse price (half a blind posted, not a full + /// one) and is out of position for the rest of the hand against everyone except the + /// button, so real small-blind ranges are consistently narrower than big-blind ranges + /// in every source found, though none gives an exact ratio. Hand-tuned. + private static let smallBlindDefenseFactor: Double = 0.65 + + /// Total defense for a non-blind defender (someone still to act behind the opener, not + /// in the blinds — e.g. the cutoff facing an under-the-gun open) as a fraction of what + /// the big blind would defend against the same open. No position-by-position source was + /// found for this case at all, unlike blind defense; this single flat factor is the + /// least-confident number in this entire model. Treat it as a rough placeholder, not a + /// considered chart. + private static let nonBlindDefenseFactor: Double = 0.5 + + /// Share of total defense that goes to 3-betting rather than flatting. The small blind + /// is documented as leaning harder toward 3-bet-or-fold (avoiding cold-calls, which are + /// weak out of position against a raise) than the big blind; non-blind defenders are + /// assumed in between. All three numbers are hand-tuned, not sourced — see RANGES.md. + /// A real 3-betting range is polarized (strong hands *and* bluffs); ranking purely by + /// Chen score only captures the value end of that, never the bluffing combos — a + /// deliberate, disclosed simplification. + private static func threeBetShare(of defender: DefendingPosition) -> Double { + switch defender { + case .bigBlind: return 0.25 + case .smallBlind: return 0.45 + default: return 0.35 + } + } + + /// % of hands `defender` should continue with (call or 3-bet, combined) against an open + /// from `opener`, or `nil` if `defender` couldn't actually be facing that open (they'd + /// have to act before `opener` at an unopened table). + public static func totalDefensePercentage(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 bigBlindDefense = bigBlindDefenseVsButton * (openerOpenPercentage / buttonOpenPercentage) + + let factor: Double + switch defender { + case .bigBlind: factor = 1.0 + case .smallBlind: factor = smallBlindDefenseFactor + default: factor = nonBlindDefenseFactor + } + return min(max(bigBlindDefense * factor, 0), 100) + } + + /// Fold/call/3-bet decision for `hand` when `defender` is facing an open-raise from + /// `opener` at `effectiveStackBB`, or `nil` for a nonsensical position pairing (see + /// `totalDefensePercentage`). + public static func decideVsOpen( + hand: HoleCards, + defender: DefendingPosition, + opener: Position, + effectiveStackBB: Double + ) -> OpenDefenseDecision? { + guard let totalDefense = totalDefensePercentage(defender: defender, opener: opener, effectiveStackBB: effectiveStackBB) else { + return nil + } + let threeBetPercentage = totalDefense * threeBetShare(of: defender) + let callThreshold = PushFoldRange.scoreThreshold(forPercentage: totalDefense) + let threeBetThreshold = PushFoldRange.scoreThreshold(forPercentage: threeBetPercentage) + let handScore = ChenScore.score(for: hand) + + let action: OpenDefenseAction + if handScore >= threeBetThreshold { + action = .threeBet + } else if handScore >= callThreshold { + action = .call + } else { + action = .fold + } + + return OpenDefenseDecision( + action: action, + handScore: handScore, + threeBetThreshold: threeBetThreshold, + callThreshold: callThreshold, + totalDefensePercentage: totalDefense, + threeBetPercentage: threeBetPercentage + ) + } +} diff --git a/PokerKit/Sources/PokerKit/PreflopGrid.swift b/PokerKit/Sources/PokerKit/PreflopGrid.swift index 5775fe9..260e501 100644 --- a/PokerKit/Sources/PokerKit/PreflopGrid.swift +++ b/PokerKit/Sources/PokerKit/PreflopGrid.swift @@ -45,4 +45,32 @@ public enum PreflopGrid { row.map { OpeningRange.decide(hand: $0, position: position, effectiveStackBB: effectiveStackBB) } } } + + /// The call/fold decision for every cell, for a given caller facing a shove from a + /// given position — `nil` if `caller` couldn't actually be facing that shove (see + /// `CallingRange.callPercentage`). + public static func callingDecisions( + caller: DefendingPosition, + shover: Position, + effectiveStackBB: Double + ) -> [[CallVsShoveDecision]]? { + guard caller.actionOrderIndex > shover.actionOrderIndex else { return nil } + return hands.map { row in + row.map { CallingRange.decideVsShove(hand: $0, caller: caller, shover: shover, effectiveStackBB: effectiveStackBB)! } + } + } + + /// The fold/call/3-bet 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 + /// (see `CallingRange.totalDefensePercentage`). + public static func openDefenseDecisions( + defender: DefendingPosition, + opener: Position, + effectiveStackBB: Double + ) -> [[OpenDefenseDecision]]? { + guard defender.actionOrderIndex > opener.actionOrderIndex else { return nil } + return hands.map { row in + row.map { CallingRange.decideVsOpen(hand: $0, defender: defender, opener: opener, effectiveStackBB: effectiveStackBB)! } + } + } } diff --git a/PokerKit/Tests/PokerKitTests/CallingRangeTests.swift b/PokerKit/Tests/PokerKitTests/CallingRangeTests.swift new file mode 100644 index 0000000..7296855 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/CallingRangeTests.swift @@ -0,0 +1,241 @@ +import Testing +@testable import PokerKit + +// MARK: - Facing a shove + +@Test func premiumHandAlwaysCallsAShove() { + let aa = HoleCards(canonical: "AA")! + for shover in Position.allCases { + for caller in DefendingPosition.allCases where caller.actionOrderIndex > shover.actionOrderIndex { + for stack: Double in [1, 5, 10, 15, 20] { + let decision = CallingRange.decideVsShove(hand: aa, caller: caller, shover: shover, effectiveStackBB: stack) + #expect(decision?.action == .call, "AA should call \(shover)'s shove from \(caller) at \(stack)bb") + } + } + } +} + +@Test func trashHandFoldsToAShoveDeepAndEarly() { + // 72o facing a UTG shove at 20bb (the widest the model goes) is a clear fold for any caller. + let trash = HoleCards(canonical: "72o")! + for caller in DefendingPosition.allCases where caller.actionOrderIndex > Position.utg.actionOrderIndex { + let decision = CallingRange.decideVsShove(hand: trash, caller: caller, shover: .utg, effectiveStackBB: 20) + #expect(decision?.action == .fold, "72o should fold to a UTG shove from \(caller) at 20bb") + } +} + +@Test func vsShoveInvalidPositionPairingsReturnNil() { + // UTG acts first at an unopened table — it can never be facing anyone else's shove. + for shover in Position.allCases { + #expect(CallingRange.callPercentage(caller: .utg, shover: shover, effectiveStackBB: 10) == nil) + #expect(CallingRange.decideVsShove(hand: HoleCards(canonical: "AA")!, caller: .utg, shover: shover, effectiveStackBB: 10) == nil) + } + // A position can't face its own shove. + #expect(CallingRange.callPercentage(caller: .smallBlind, shover: .smallBlind, effectiveStackBB: 10) == nil) + // Nor a shove from someone who acts after it. + #expect(CallingRange.callPercentage(caller: .hijack, shover: .button, effectiveStackBB: 10) == nil) +} + +@Test func bigBlindCanFaceAShoveFromEveryPosition() { + // The big blind always closes the action, so it's never an invalid pairing. + for shover in Position.allCases { + #expect(CallingRange.callPercentage(caller: .bigBlind, shover: shover, effectiveStackBB: 10) != nil) + } +} + +@Test func callWidensAsStackShortens() { + // Same invariant as PushFoldRange/OpeningRange: once a hand calls at a given stack, it + // must also call at every shorter stack from the same position pairing. + let hand = HoleCards(canonical: "A9s")! + var lastWasCall = false + for stack: Double in stride(from: 20, through: 1, by: -1) { + let decision = CallingRange.decideVsShove(hand: hand, caller: .bigBlind, shover: .utg, effectiveStackBB: stack)! + if lastWasCall { + #expect(decision.action == .call, "Calling range should not tighten as stack shortens (stack \(stack))") + } + lastWasCall = decision.action == .call + } +} + +@Test func callIsTighterAgainstAnEarlierShoverAtTheSameStack() { + // An earlier shover's range is stronger (PushFoldRange itself shoves tighter from UTG + // than SB), so a rational caller needs more to call it. + let utgCallPct = CallingRange.callPercentage(caller: .bigBlind, shover: .utg, effectiveStackBB: 10)! + let sbCallPct = CallingRange.callPercentage(caller: .bigBlind, shover: .smallBlind, effectiveStackBB: 10)! + #expect(utgCallPct < sbCallPct) +} + +@Test func bigBlindCallsWiderThanSmallBlindAgainstTheSameShove() { + // The big blind is this model's best-grounded calling position; every other caller is + // discounted below it (see CallingRange.callerPositionDiscount). + for shover in [Position.utg, .hijack, .button] { + let bbPct = CallingRange.callPercentage(caller: .bigBlind, shover: shover, effectiveStackBB: 10)! + let sbPct = CallingRange.callPercentage(caller: .smallBlind, shover: shover, effectiveStackBB: 10)! + #expect(bbPct > sbPct, "BB should call wider than SB against a \(shover) shove") + } +} + +@Test func callingSuitedHandNeverLoosesToItsOffsuitCounterpart() { + // ChenScore only ever scores a suited hand >= its offsuit counterpart (the +2 suited + // bonus), so if the offsuit version clears the calling threshold, the suited version + // must too. + for (offsuit, suited) in [("A9o", "A9s"), ("KJo", "KJs"), ("T8o", "T8s"), ("76o", "76s")] { + let offsuitDecision = CallingRange.decideVsShove( + hand: HoleCards(canonical: offsuit)!, caller: .bigBlind, shover: .cutoff, effectiveStackBB: 12 + )! + let suitedDecision = CallingRange.decideVsShove( + hand: HoleCards(canonical: suited)!, caller: .bigBlind, shover: .cutoff, effectiveStackBB: 12 + )! + if offsuitDecision.action == .call { + #expect(suitedDecision.action == .call, "\(suited) should call whenever \(offsuit) calls") + } + } +} + +@Test func vsShoveReasoningMentionsCallingThreshold() { + let decision = CallingRange.decideVsShove( + hand: HoleCards(canonical: "AA")!, caller: .bigBlind, shover: .utg, effectiveStackBB: 10 + )! + #expect(decision.reasoning.contains("calling threshold")) +} + +// MARK: - Facing an open + +@Test func premiumHandAlwaysThreeBetsAnOpen() { + let aa = HoleCards(canonical: "AA")! + for opener in Position.allCases { + for defender in DefendingPosition.allCases where defender.actionOrderIndex > opener.actionOrderIndex { + let decision = CallingRange.decideVsOpen(hand: aa, defender: defender, opener: opener, effectiveStackBB: 40) + #expect(decision?.action == .threeBet, "AA should 3-bet \(opener)'s open from \(defender)") + } + } +} + +@Test func trashHandFoldsToAnOpenFromEarlyPosition() { + let trash = HoleCards(canonical: "72o")! + let decision = CallingRange.decideVsOpen(hand: trash, defender: .bigBlind, opener: .utg, effectiveStackBB: 100)! + #expect(decision.action == .fold) +} + +@Test func vsOpenInvalidPositionPairingsReturnNil() { + // UTG acts first — it can never be facing anyone else's open. + for opener in Position.allCases { + #expect(CallingRange.totalDefensePercentage(defender: .utg, opener: opener, effectiveStackBB: 40) == nil) + } + // A position can't face its own open. + #expect(CallingRange.totalDefensePercentage(defender: .cutoff, opener: .cutoff, effectiveStackBB: 40) == nil) + // Nor an open from someone who acts after it. + #expect(CallingRange.totalDefensePercentage(defender: .hijack, opener: .button, effectiveStackBB: 40) == nil) +} + +@Test func bigBlindCanFaceAnOpenFromEveryPosition() { + for opener in Position.allCases { + #expect(CallingRange.totalDefensePercentage(defender: .bigBlind, opener: opener, effectiveStackBB: 40) != nil) + } +} + +@Test func defenseWidensAgainstALaterOpenerAtTheSameStack() { + // A button open is wider than a UTG open (OpeningRange itself), so there's less to + // fear and more reason to defend against it. + let vsUTG = CallingRange.totalDefensePercentage(defender: .bigBlind, opener: .utg, effectiveStackBB: 40)! + let vsButton = CallingRange.totalDefensePercentage(defender: .bigBlind, opener: .button, effectiveStackBB: 40)! + #expect(vsUTG < vsButton) +} + +@Test func bigBlindDefendsWiderThanSmallBlindAgainstTheSameOpen() { + for opener in [Position.utg, .hijack, .button] { + let bbPct = CallingRange.totalDefensePercentage(defender: .bigBlind, opener: opener, effectiveStackBB: 40)! + let sbPct = CallingRange.totalDefensePercentage(defender: .smallBlind, opener: opener, effectiveStackBB: 40)! + #expect(bbPct > sbPct, "BB should defend wider than SB against a \(opener) open") + } +} + +@Test func threeBetThresholdIsAtLeastAsTightAsCallThreshold() { + // 3-betting hands are a subset of the overall defending range, so the 3-bet score + // threshold can never be looser than the call threshold. + for opener in Position.allCases { + for defender in DefendingPosition.allCases where defender.actionOrderIndex > opener.actionOrderIndex { + let decision = CallingRange.decideVsOpen( + hand: HoleCards(canonical: "AKs")!, defender: defender, opener: opener, effectiveStackBB: 40 + )! + #expect(decision.threeBetThreshold >= decision.callThreshold) + } + } +} + +@Test func defendingSuitedHandNeverLoosesToItsOffsuitCounterpart() { + for (offsuit, suited) in [("A9o", "A9s"), ("KJo", "KJs"), ("T8o", "T8s"), ("76o", "76s")] { + let offsuitDecision = CallingRange.decideVsOpen( + hand: HoleCards(canonical: offsuit)!, defender: .bigBlind, opener: .cutoff, effectiveStackBB: 40 + )! + let suitedDecision = CallingRange.decideVsOpen( + hand: HoleCards(canonical: suited)!, defender: .bigBlind, opener: .cutoff, effectiveStackBB: 40 + )! + // Fold < Call < 3-Bet in defensive strength; the suited version must be at least + // as aggressive a defense as its offsuit counterpart. + #expect(strength(suitedDecision.action) >= strength(offsuitDecision.action), "\(suited) should defend at least as much as \(offsuit)") + } +} + +private func strength(_ action: OpenDefenseAction) -> Int { + switch action { + case .fold: return 0 + case .call: return 1 + case .threeBet: return 2 + } +} + +@Test func vsOpenReasoningMentionsThreshold() { + let threeBetDecision = CallingRange.decideVsOpen( + hand: HoleCards(canonical: "AA")!, defender: .bigBlind, opener: .utg, effectiveStackBB: 40 + )! + #expect(threeBetDecision.reasoning.contains("3-bet threshold")) + + let foldDecision = CallingRange.decideVsOpen( + hand: HoleCards(canonical: "72o")!, defender: .bigBlind, opener: .utg, effectiveStackBB: 40 + )! + #expect(foldDecision.reasoning.contains("calling threshold")) +} + +// MARK: - PreflopGrid integration + +@Test func gridCallingDecisionsMatchDirectCallingRangeDecisions() { + for row in 0.. [[OpeningDecision]]` Same shape, mapping `OpeningRange.decide(hand:position:effectiveStackBB:)` -instead. `PreflopRangeView` has a "Push/Fold" vs. "Opening" segmented control -that picks which of these two functions backs the grid, plus a position -picker and a stack slider (1–20bb for push/fold, 20–100bb for opening) -driving the shared parameters live. Both render one `Color.accentColor` +instead. + +## `callingDecisions(caller:shover:effectiveStackBB:) -> [[CallVsShoveDecision]]?` + +Maps `CallingRange.decideVsShove(hand:caller:shover:effectiveStackBB:)` over +every cell — hero (`caller`, a `DefendingPosition`) is facing an all-in shove +from `shover`. Returns `nil` — not a grid of nonsense — when `caller` +couldn't actually be facing that shove (see `RANGES.md`'s "Positions +modeled"). + +## `openDefenseDecisions(defender:opener:effectiveStackBB:) -> [[OpenDefenseDecision]]?` + +Same shape, mapping `CallingRange.decideVsOpen(hand:defender:opener:effectiveStackBB:)` +— hero (`defender`) is facing an open-raise from `opener`. Also returns `nil` +for an invalid position pairing. + +`PreflopRangeView` has a mode control (Push/Fold, Opening, Facing a Shove, +Facing an Open) that picks which of these four functions backs the grid, +plus position picker(s) and a stack slider (1–20bb for the two short-stack +modes, 20–100bb for the two standard-stack modes) driving the shared +parameters live. The two aggressor modes render one `Color.accentColor` (shove/raise) or `Color(.secondarySystemBackground)` (fold) cell per -decision — the view only cares about `action == .push` / `action == .raise`, -never the two decision types at once. +decision. The two defending modes add a third color for 3-bet/call so all +three actions are visually distinct — the view only cares about which of +`push`/`raise`/`threeBet`/`call`/`fold` a cell's action is, never the +decision types themselves. -`gridDecisionsMatchDirectPushFoldRangeDecisions` and -`gridOpeningDecisionsMatchDirectOpeningRangeDecisions` (tests) are the +`gridDecisionsMatchDirectPushFoldRangeDecisions`, +`gridOpeningDecisionsMatchDirectOpeningRangeDecisions`, +`gridCallingDecisionsMatchDirectCallingRangeDecisions`, and +`gridOpenDefenseDecisionsMatchDirectCallingRangeDecisions` (tests) are the load-bearing guarantee here: every grid cell's decision is required to exactly match calling the corresponding model directly for that same -hand/position/stack — the grid is not allowed to drift into its own logic for -either model. +hand/position(s)/stack — the grid is not allowed to drift into its own logic +for any of the four models. `gridReturnsNilForInvalidPositionPairings` +covers the two defending modes' `nil` case specifically. diff --git a/ai-docs/RANGES.md b/ai-docs/RANGES.md index 7a38429..cc1e7e5 100644 --- a/ai-docs/RANGES.md +++ b/ai-docs/RANGES.md @@ -1,18 +1,28 @@ # Preflop Ranges Source: `PokerKit/Sources/PokerKit/ChenScore.swift`, `PushFoldRange.swift`, -`OpeningRange.swift`, `PushFoldSpot.swift`, `Position.swift`. Tests: -`ChenScoreTests.swift`, `PushFoldRangeTests.swift`, `OpeningRangeTests.swift`. +`OpeningRange.swift`, `CallingRange.swift`, `PushFoldSpot.swift`, +`Position.swift`. Tests: `ChenScoreTests.swift`, `PushFoldRangeTests.swift`, +`OpeningRangeTests.swift`, `CallingRangeTests.swift`. -Two range models live here, covering two different stack regimes of an MTT: +Four 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, shove-or-fold. +- **Push/Fold** (`PushFoldRange`) — short stacks, roughly 1–20bb, hero is the + aggressor: shove-or-fold. - **Opening / raise-first-in** (`OpeningRange`) — standard stacks, roughly - 20–100bb, raise-or-fold. Covers the part of a tournament before the stack - gets short enough for push/fold to take over. - -Both are **hand-tuned study aids, not solver output** — see each section -below for what "hand-tuned" means and where the numbers come from. + 20–100bb, hero is the aggressor: raise-or-fold. Covers the part of a + tournament before the stack gets short enough for push/fold to take over. +- **Facing a shove** (`CallingRange.decideVsShove`) — short stacks, hero is + *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. ## Push/Fold @@ -162,33 +172,185 @@ Identical shape to push/fold's, with `OpeningRange` in place of — raises if `handScore >= threshold`. `OpeningDecision.reasoning` mirrors `PushFoldDecision.reasoning`'s wording, swapped to "open-raise threshold." +## Facing a Shove + +An **all-in call/fold decision**: someone has already shoved (modeled as +coming from a specific `Position`, roughly 1–20bb effective), and `caller` — +a `DefendingPosition`, which unlike `Position` includes the big blind — has +to decide whether to call it off or fold. No 3-betting; once someone's +all-in, calling is the only "continue" option. + +**This is a hand-tuned study aid, and the least certain model in this file +after "Facing an open."** Read this before trusting a specific number: + +- **What's actually well-documented:** the pure heads-up case — small blind + shoves, big blind calls — has a widely-published Nash equilibrium (see + e.g. HoldemResources.net's and GTOCharts.com's heads-up Nash push/fold + charts). Multiple sources are explicit that this is *the only* case with a + standard, independently-computed answer: calling charts for anyone other + than the big blind, against a shove from anyone, don't have an equivalent + public standard — those genuinely require a solver run for the exact + stacks and payout structure in play. +- **What this model does about that:** rather than inventing position-by- + position numbers with false precision, `CallingRange` derives calling + percentages from a number already in this codebase — + `PushFoldRange.shovePercentage(position:effectiveStackBB:)`, the shover's + own modeled range width — discounted by two factors: + 1. **`shoveDiscountByStack`** — calling requires real showdown equity, not + just fold equity, so the profitable call% at a given stack is always a + fraction of the shove% at that stack, and that fraction shrinks as the + stack deepens (there's more room to be dominated, and less desperation + pressure). The curve (0.90 at 1bb down to 0.38 at 20bb) is a hand-tuned + approximation, calibrated against the one concrete external number + found: a heads-up Nash small-blind shove figure of **~50% at 10bb** + (via web search of published heads-up Nash summaries), compared against + this project's own `PushFoldRange` SB shove figure of **58% at 10bb** — + close enough that treating them as the same underlying phenomenon (a + 6-max-context table vs. a pure-heads-up one) is a reasonable, disclosed + assumption rather than a citation. + 2. **`callerPositionDiscount`** — a further, *unsourced* discount for every + caller besides the big blind. The big blind is the only calling + position that's genuinely equivalent to the heads-up Nash case (it + always closes the action). The small blind gets a worse price (half a + blind posted, not a full one) and non-blind callers risk a player still + to act behind them waking up with a hand (squeeze risk) — both real, + commonly-cited reasons real ranges there are narrower, but no source + gives a magnitude for either. **Treat every non-big-blind number in this + model as a rough placeholder, not a considered chart.** + +`caller.actionOrderIndex > shover.actionOrderIndex` is required (see +"Positions modeled" below) — `callPercentage`/`decideVsShove` return `nil` +for pairings that can't happen at an unopened table (e.g. UTG can't be +"facing" anyone's shove; nobody can face their own). + +### The pipeline + +1. `PushFoldRange.shovePercentage(position: shover, ...)` — the shover's own + modeled range width, reused rather than re-derived. +2. `shoveDiscount(effectiveStackBB:)` — the stack-only discount curve above, + interpolated the same way `PushFoldRange`/`OpeningRange` interpolate their + own breakpoint tables. +3. `callerPositionDiscount[caller]` — the position-only discount above. +4. `callPercentage` multiplies all three (clamped to `[0, 100]`). +5. `PushFoldRange.scoreThreshold(forPercentage:)` — reused directly, same as + `OpeningRange`. +6. `decideVsShove` calls if `handScore >= threshold`. + +## Facing an Open + +A **fold/call/3-bet decision**: someone has open-raised (modeled as coming +from a specific `Position`, roughly 20–100bb effective), and `defender` — a +`DefendingPosition` — has to decide whether to fold, flat-call, or 3-bet. + +**This is also a hand-tuned study aid, and this file's least certain model.** +No position-by-position sourced chart for defending ranges (as opposed to +opening ranges, which do have one — see "Opening" above) was found at all; +everything here is derived from one sourced anchor plus qualitative, +commonly-repeated MTT strategy principles: + +- **The one sourced anchor:** big blind's combined call+3-bet continuing + frequency against a standard button open is commonly cited at **~84%** + (found via web search of MTT preflop-strategy material discussing big + blind defense — a "defend almost everything, since suited hands are + essentially never a fold for the big blind" figure that shows up + repeatedly across sources, alongside the well-known qualitative shorthand + that big blind's *offsuit* defend boundary tightens by opener position, + e.g. down to the 5-high offsuit hands vs. a button open, the 6-high + offsuit hands vs. a cutoff open, and so on for earlier opens). This is the + only number in "Facing an open" backed by an external figure rather than + pure extrapolation. +- **Every other position/opener combination is derived from that one + anchor**, scaled by the ratio of `OpeningRange.openPercentage(opener:)` to + `OpeningRange.openPercentage(.button:)` at the same stack — i.e. "the big + blind should defend against a given opener's range roughly in proportion + to how wide that opener actually opens, relative to how wide a button + open is." This reuses `OpeningRange`'s own already-disclosed numbers + (including its own uncertain columns — see "Opening" above) rather than + inventing a second, independent opinion about how wide each position + opens. +- **Small blind's total defense** is set to **65%** of what the big blind + would defend against the same open — every source found agrees small + blind defends narrower than big blind (worse price: half a blind posted, + not a full one; out of position the rest of the hand against everyone but + the button), but none gives an exact ratio. Hand-tuned. +- **Non-blind defenders** (someone still to act behind the opener, not in + the blinds — e.g. the cutoff facing an under-the-gun open) get a flat + **50%** of what the big blind would defend. **This is the single + least-confident number in this entire file.** No source distinguishing, + say, "hijack facing a UTG open" from "button facing a cutoff open" was + found — this model treats every non-blind defending position identically, + which is certainly wrong in degree even if the direction (tighter than + the blinds, since there's no sunk blind investment) is reasonable. +- **The call/3-bet split** within total defense: big blind's is set to 25% + 3-bet / 75% call, small blind's to 45% 3-bet / 55% call (small blind is + documented in multiple sources as leaning more toward "3-bet or fold," + avoiding cold-calls that play badly out of position against a raise), and + non-blind defenders to 35% 3-bet / 65% call as a middling, unsourced + guess. **A real 3-betting range is polarized** — strong value hands *and* + bluffs with blockers/playability. Ranking purely by Chen score and taking + the top slice as "3-bet" only ever captures the value half of that; this + model has no bluff-3-bet concept at all. Disclosed simplification, not an + oversight. + +If you want to firm any of this up: a real position-by-position, stack-aware +defending chart (ideally covering blind defense and non-blind defense +separately, the way opening charts already do) would let +`totalDefensePercentage` and `threeBetShare` be replaced outright — nothing +downstream changes, same upgrade path every other model in this file +documents for itself. + +### The pipeline + +1. `OpeningRange.openPercentage(position: opener, ...)` and + `OpeningRange.openPercentage(position: .button, ...)` — reused, not + re-derived. +2. `totalDefensePercentage` — the big blind anchor (84%) scaled by that + ratio, then by the defender-position factor (1.0 / 0.65 / 0.5) above. +3. `threeBetShare(of: defender)` — the unsourced call/3-bet split above. +4. `PushFoldRange.scoreThreshold(forPercentage:)` — reused twice: once for + the overall defend threshold, once for the (narrower) 3-bet threshold. +5. `decideVsOpen` — 3-bets at or above the 3-bet threshold, calls at or + above the defend threshold, folds below it. + ## Positions modeled `Position` (`UTG, MP, HJ, CO, BTN, SB`) deliberately **excludes the big blind** — if action folds all the way around, BB has already won the pot -uncontested, so there's no opening or push/fold decision to make there. A BB -facing an earlier shove or raise is a *calling* range, a different (and -currently unmodeled) tool. See `Position.swift`'s doc comment. Both range -models share this same six-position scheme, so `OpeningRange` introduces no -new position taxonomy. +uncontested, so there's no opening or push/fold decision to make there. +`DefendingPosition` (`UTG, MP, HJ, CO, BTN, SB, BB`) is the position type for +the two *defending* models above — it adds the big blind back in, since a +defending hero can be exactly the player who was excluded from `Position`. +Both enums declare their shared six cases in identical order, so +`Position.actionOrderIndex`/`DefendingPosition.actionOrderIndex` (private +helpers in `CallingRange.swift`) are directly comparable: a defender is only +ever facing a valid shove/open if `defender.actionOrderIndex > +shover.actionOrderIndex` — i.e. the defender genuinely acts after the +aggressor at an unopened table. `callPercentage`, `totalDefensePercentage`, +`decideVsShove`, `decideVsOpen`, and their `PreflopGrid` equivalents all +return `nil` rather than a nonsensical decision when that's violated. ## Consumers - `PushFoldTrainerView` — plain random push/fold practice. - `PreflopGrid` (`PREFLOP-GRID.md`) — renders `PushFoldRange.decide` (via - `decisions`) and `OpeningRange.decide` (via `openingDecisions`) for all 169 - hands at once as a grid; `PreflopRangeView` toggles between the two with a - segmented control. + `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. - `LeakAnalysisEngine` (`LEAK-ANALYSIS.md`) — compares hero's actual imported-hand decisions against `PushFoldRange.decide` to find deviations. - Still push/fold-only; opening-range adherence is not tracked. + Still push/fold-only; opening-range and defending-range adherence are not + tracked. - `DrillGenerator` (`DRILLS.md`) — deals `PushFoldSpot`s weighted toward those deviations. Still push/fold-only. -Within each model there is exactly one implementation — every consumer of -push/fold calls through `PushFoldRange`, every consumer of opening ranges -calls through `OpeningRange`, so there's never a second opinion on what -"correct" means for a given spot under a given model. The two models -themselves are intentionally separate (different stack regimes, different -source basis, different confidence level) rather than one model trying to -cover both. +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. From dc4248c4560498479d8c0fc3cc521b8165cd7bb2 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Mon, 20 Jul 2026 13:11:55 +0200 Subject: [PATCH 2/2] Wire calling/defense ranges into the range viewer screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the four-way mode picker (Push/Fold, Opening, Facing Shove, Facing Open) with an opponent-position + hero-position picker pair for the two new defense modes, replacing the single position picker used by the aggressor modes. The hero picker filters to only positions that could plausibly be facing the chosen opponent (DefendingPosition.actionOrderIndex > Position.actionOrderIndex), auto-resetting to the big blind — always valid — if the opponent choice would otherwise strand it on an impossible pairing. Facing Open renders a third grid color (3-bet vs. call vs. fold) since it's the one mode with three distinct actions. Both defense modes show a persistent caveat line pointing at RANGES.md's confidence notes, rather than presenting these numbers with the same certainty as push/fold or opening. --- app/Sources/PreflopRangeView.swift | 199 +++++++++++++++++++++----- app/UITests/PreflopRangeUITests.swift | 55 +++++++ 2 files changed, 220 insertions(+), 34 deletions(-) diff --git a/app/Sources/PreflopRangeView.swift b/app/Sources/PreflopRangeView.swift index 18bd8ed..8096e7f 100644 --- a/app/Sources/PreflopRangeView.swift +++ b/app/Sources/PreflopRangeView.swift @@ -1,41 +1,71 @@ import SwiftUI import PokerKit -/// Which range model the grid is currently rendering: short-stack push/fold -/// (`PushFoldRange`) or standard-stack opening/raise-first-in (`OpeningRange`). Purely a -/// view-layer switch — both branches call straight through to `PreflopGrid`, so there's -/// still exactly one decision per model, never a third opinion invented here. +/// Which range model the grid is currently rendering. Purely a view-layer switch — every +/// case calls straight through to `PreflopGrid`, so there's still exactly one decision per +/// model, never a third opinion invented here. private enum RangeMode: String, CaseIterable, Identifiable { case pushFold = "Push/Fold" case opening = "Opening" + case vsShove = "Facing Shove" + case vsOpen = "Facing Open" 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 { + switch self { + case .pushFold, .opening: return false + case .vsShove, .vsOpen: return true + } + } + var stackRange: ClosedRange { switch self { - case .pushFold: return 1...20 - case .opening: return 20...100 + case .pushFold, .vsShove: return 1...20 + case .opening, .vsOpen: return 20...100 } } var defaultStack: Double { switch self { - case .pushFold: return 10 - case .opening: return 50 + case .pushFold, .vsShove: return 10 + case .opening, .vsOpen: return 50 } } - var actionLabel: String { + var legendEntries: [(color: Color, label: String)] { switch self { - case .pushFold: return "Shove" - case .opening: return "Raise" + case .pushFold: return [(.accentColor, "Shove")] + case .opening: return [(.accentColor, "Raise")] + case .vsShove: return [(.accentColor, "Call")] + case .vsOpen: return [(.accentColor, "3-Bet"), (.teal, "Call")] } } +} + +/// A cell's visual role — collapses all four models' distinct action types (push/raise/ +/// call/3-bet/fold) down to what the grid actually needs to render. `.secondary` is only +/// ever produced by `.vsOpen` ("call" as distinct from "3-bet"); every other mode only +/// produces `.primary`/`.fold`. +private enum CellStyle { + case primary + case secondary + case fold - var summarySuffix: String { + var color: Color { switch self { - case .pushFold: return "% of hands to shove" - case .opening: return "% of hands to open" + case .primary: return .accentColor + case .secondary: return .teal + case .fold: return Color(.secondarySystemBackground) + } + } + + var textColor: Color { + switch self { + case .primary, .secondary: return .white + case .fold: return .secondary } } } @@ -43,26 +73,71 @@ private enum RangeMode: String, CaseIterable, Identifiable { struct PreflopRangeView: View { @State private var mode: RangeMode = .pushFold @State private var position: Position = .utg + @State private var opponentPosition: Position = .utg + @State private var heroPosition: DefendingPosition = .bigBlind @State private var effectiveStackBB: Double = RangeMode.pushFold.defaultStack - /// Whether each of the 169 grid cells is the "aggressive" action (shove, or open) for - /// the current mode/position/stack — the grid only needs this boolean to render, so - /// the two decision types (`PushFoldDecision`/`OpeningDecision`) never have to meet. - private var isAggressiveGrid: [[Bool]] { + /// `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 } + } + + private var cellStyles: [[CellStyle]] { switch mode { case .pushFold: return PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) - .map { row in row.map { $0.action == .push } } + .map { row in row.map { $0.action == .push ? .primary : .fold } } case .opening: return PreflopGrid.openingDecisions(position: position, effectiveStackBB: effectiveStackBB) - .map { row in row.map { $0.action == .raise } } + .map { row in row.map { $0.action == .raise ? .primary : .fold } } + case .vsShove: + guard let decisions = PreflopGrid.callingDecisions( + caller: heroPosition, shover: opponentPosition, effectiveStackBB: effectiveStackBB + ) else { + return emptyGrid + } + return decisions.map { row in row.map { $0.action == .call ? .primary : .fold } } + case .vsOpen: + guard let decisions = PreflopGrid.openDefenseDecisions( + defender: heroPosition, opener: opponentPosition, effectiveStackBB: effectiveStackBB + ) else { + return emptyGrid + } + return decisions.map { row in + row.map { decision in + switch decision.action { + case .threeBet: return .primary + case .call: return .secondary + case .fold: return .fold + } + } + } } } - private var actionPercentage: Double { - let flat = isAggressiveGrid.flatMap { $0 } - guard !flat.isEmpty else { return 0 } - return Double(flat.filter { $0 }.count) / Double(flat.count) * 100 + private var emptyGrid: [[CellStyle]] { + Array(repeating: Array(repeating: .fold, count: PreflopGrid.ranks.count), count: PreflopGrid.ranks.count) + } + + private var summaryText: String { + let flat = cellStyles.flatMap { $0 } + guard !flat.isEmpty else { return "" } + let total = Double(flat.count) + let defendCount = flat.filter { $0 != .fold }.count + let defendPct = Double(defendCount) / total * 100 + + switch mode { + case .pushFold: return "\(String(format: "%.0f", defendPct))% of hands to shove" + case .opening: return "\(String(format: "%.0f", defendPct))% of hands to open" + case .vsShove: return "\(String(format: "%.0f", defendPct))% of hands to call" + case .vsOpen: + let threeBetCount = flat.filter { $0 == .primary }.count + let threeBetPct = Double(threeBetCount) / total * 100 + return "\(String(format: "%.0f", defendPct))% of hands to defend " + + "(\(String(format: "%.0f", threeBetPct))% 3-bet)" + } } var body: some View { @@ -72,6 +147,9 @@ struct PreflopRangeView: View { summary grid legend + if mode.isDefending { + defenseCaveat + } } .padding() } @@ -92,13 +170,17 @@ struct PreflopRangeView: View { effectiveStackBB = newMode.defaultStack } - Picker("Position", selection: $position) { - ForEach(Position.allCases) { position in - Text(position.rawValue).tag(position) + if mode.isDefending { + defendingPositionControls + } else { + Picker("Position", selection: $position) { + ForEach(Position.allCases) { position in + Text(position.rawValue).tag(position) + } } + .pickerStyle(.segmented) + .accessibilityIdentifier("positionPicker") } - .pickerStyle(.segmented) - .accessibilityIdentifier("positionPicker") VStack(alignment: .leading, spacing: 4) { HStack { @@ -117,8 +199,43 @@ struct PreflopRangeView: View { } } + private var defendingPositionControls: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(mode == .vsShove ? "Shover" : "Opener") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Opponent", selection: $opponentPosition) { + ForEach(Position.allCases) { position in + Text(position.rawValue).tag(position) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("opponentPositionPicker") + .onChange(of: opponentPosition) { _, newOpponent in + if heroPosition.actionOrderIndex <= newOpponent.actionOrderIndex { + heroPosition = .bigBlind + } + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("You") + .font(.caption) + .foregroundStyle(.secondary) + Picker("You", selection: $heroPosition) { + ForEach(validHeroPositions) { position in + Text(position.rawValue).tag(position) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("heroPositionPicker") + } + } + } + private var summary: some View { - Text("\(String(format: "%.0f", actionPercentage))\(mode.summarySuffix)") + Text(summaryText) .font(.subheadline.bold()) .foregroundStyle(.secondary) .accessibilityIdentifier("shovePercentageText") @@ -138,22 +255,24 @@ struct PreflopRangeView: View { private func cell(row: Int, col: Int) -> some View { let notation = PreflopGrid.notation(row: row, col: col) - let isAggressive = isAggressiveGrid[row][col] + let style = cellStyles[row][col] return Text(notation) .font(.system(size: 10, weight: .semibold, design: .rounded)) .minimumScaleFactor(0.6) .lineLimit(1) .frame(maxWidth: .infinity, minHeight: 24) - .background(isAggressive ? Color.accentColor : Color(.secondarySystemBackground)) - .foregroundStyle(isAggressive ? Color.white : Color.secondary) + .background(style.color) + .foregroundStyle(style.textColor) .clipShape(RoundedRectangle(cornerRadius: 3)) .accessibilityIdentifier("cell-\(notation)") } private var legend: some View { HStack(spacing: 16) { - legendSwatch(color: .accentColor, label: mode.actionLabel) + ForEach(mode.legendEntries, id: \.label) { entry in + legendSwatch(color: entry.color, label: entry.label) + } legendSwatch(color: Color(.secondarySystemBackground), label: "Fold") } .font(.caption) @@ -169,6 +288,18 @@ struct PreflopRangeView: View { Text(label) } } + + 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") + } } #Preview { diff --git a/app/UITests/PreflopRangeUITests.swift b/app/UITests/PreflopRangeUITests.swift index 838fb41..7c14e15 100644 --- a/app/UITests/PreflopRangeUITests.swift +++ b/app/UITests/PreflopRangeUITests.swift @@ -23,6 +23,61 @@ final class PreflopRangeUITests: XCTestCase { attachScreenshot(of: app, name: "grid-button") } + func testFacingShoveModeRendersAndReactsToOpponentChange() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Preflop Ranges"].tap() + + app.buttons["Facing Shove"].tap() + + let aaCell = app.staticTexts["cell-AA"] + XCTAssertTrue(aaCell.waitForExistence(timeout: 5)) + attachScreenshot(of: app, name: "facing-shove-utg") + + let summaryText = app.staticTexts["shovePercentageText"] + XCTAssertTrue(summaryText.exists) + let utgSummary = summaryText.label + + // Both the opponent and hero pickers can show overlapping position labels (e.g. + // both may offer "BTN"), so scope the tap to the opponent picker specifically. + let opponentPicker = app.segmentedControls["opponentPositionPicker"] + XCTAssertTrue(opponentPicker.waitForExistence(timeout: 5)) + opponentPicker.buttons["BTN"].tap() + + XCTAssertTrue(summaryText.waitForExistence(timeout: 5)) + XCTAssertNotEqual(summaryText.label, utgSummary) + attachScreenshot(of: app, name: "facing-shove-button") + } + + func testFacingOpenModeRendersAndReactsToHeroChange() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Preflop Ranges"].tap() + + app.buttons["Facing Open"].tap() + + let aaCell = app.staticTexts["cell-AA"] + XCTAssertTrue(aaCell.waitForExistence(timeout: 5)) + attachScreenshot(of: app, name: "facing-open-bb") + + let summaryText = app.staticTexts["shovePercentageText"] + XCTAssertTrue(summaryText.exists) + let bbSummary = summaryText.label + + // Hero picker defaults to BB (always valid); switch to SB — should defend + // narrower, so the summary text should change. Scoped to the hero picker since + // "SB" also appears in the opponent picker. + 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: "facing-open-sb") + } + private func attachScreenshot(of app: XCUIApplication, name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name