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. 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