From 9a46edcf58341abae7cf2ae1221cadcb6aa5a354 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Fri, 17 Jul 2026 23:26:31 +0200 Subject: [PATCH] Add Opening (RFI) range viewer alongside push/fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard-stack (20-100bb) raise-first-in ranges by position, as a second mode on the existing Preflop Ranges grid. Mirrors PushFoldRange.swift's shape and honesty posture — hand-tuned study aid, not solver output — and reuses ChenScore + PushFoldRange.scoreThreshold rather than introducing a second hand-ranking system. The 100bb column is sourced (PokerCoaching's implementable GTO charts, cross-verified); 40bb/20bb are disclosed extrapolations from a single anchor point, and SB is deliberately tightened below its cited source after two references disagreed by a wide margin. Full basis and uncertainty notes in ai-docs/RANGES.md. Also fixes the stale "opening/3-bet/4-bet" over-promise in StudyTool's home-screen summary and README now that opening ranges exist (3-bet/4-bet still doesn't). Co-Authored-By: Claude Sonnet 5 --- PokerKit/Sources/PokerKit/OpeningRange.swift | 94 +++++++++++ PokerKit/Sources/PokerKit/PreflopGrid.swift | 9 + PokerKit/Sources/PokerKit/StudyTool.swift | 2 +- .../PokerKitTests/OpeningRangeTests.swift | 96 +++++++++++ .../PokerKitTests/PreflopGridTests.swift | 20 +++ README.md | 10 +- ai-docs/PREFLOP-GRID.md | 35 ++-- ai-docs/RANGES.md | 155 +++++++++++++++--- app/Sources/PreflopRangeView.swift | 88 ++++++++-- 9 files changed, 456 insertions(+), 53 deletions(-) create mode 100644 PokerKit/Sources/PokerKit/OpeningRange.swift create mode 100644 PokerKit/Tests/PokerKitTests/OpeningRangeTests.swift diff --git a/PokerKit/Sources/PokerKit/OpeningRange.swift b/PokerKit/Sources/PokerKit/OpeningRange.swift new file mode 100644 index 0000000..c66e780 --- /dev/null +++ b/PokerKit/Sources/PokerKit/OpeningRange.swift @@ -0,0 +1,94 @@ +import Foundation + +public enum OpeningAction: String, Sendable { + case raise = "Raise" + case fold = "Fold" +} + +public struct OpeningDecision: Sendable { + public let action: OpeningAction + public let handScore: Double + public let scoreThreshold: Double + public let openPercentage: Double + + /// A short, human-readable justification for the range viewer's detail view. + public var reasoning: String { + let pct = String(format: "%.0f", openPercentage) + switch action { + case .raise: + return "Hand strength score \(formatted(handScore)) clears the open-raise threshold of " + + "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this position and stack." + case .fold: + return "Hand strength score \(formatted(handScore)) is below the open-raise threshold of " + + "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this position and stack." + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +/// An approximate, unopened-pot **opening (raise-first-in)** range model for standard +/// tournament stack depths (roughly 20-100bb) — the "should I open-raise this hand" +/// decision that covers most of an MTT before the stack gets short enough for +/// `PushFoldRange` to take over. +/// +/// This is a **study aid, not a solver** — same posture as `PushFoldRange`, and for the +/// same reason: real GTO-solved opening ranges depend on exact stack depths, rake, +/// opponent tendencies, and postflop strategy in a way a static table can't capture. +/// What's encoded here is a hand-tuned approximation of the general *shape* of +/// widely-published open-raise charts: tight up front, widening through the button, wide +/// from the small blind (heads-up against the big blind). See `RANGES.md` for the exact +/// source basis, what's directly sourced vs. extrapolated, and one deliberate place this +/// table is tightened *below* its cited source out of caution (small blind). +/// +/// Reuses `ChenScore` to rank the 169 starting hands (same ranking already used by +/// `PushFoldRange`) and `PushFoldRange.scoreThreshold(forPercentage:)` to turn a "top X%" +/// figure into a score cutoff — there is no second hand-ranking system in this codebase. +public enum OpeningRange { + /// Stack breakpoints (effective bb), ascending. Percentages below are only anchored + /// at these three points — deliberately fewer than `PushFoldRange`'s ten, because the + /// source material backing this table is thinner across stack depths (see + /// `RANGES.md`). `openPercentage` linearly interpolates between them and clamps + /// outside [20, 100]. + private static let breakpoints: [Double] = [20, 40, 100] + + /// % of the 169 starting hands to open-raise, indexed by position then aligned 1:1 + /// with `breakpoints` (20bb, 40bb, 100bb). Narrows as the stack deepens; widens as + /// position gets later. See `RANGES.md` for the source basis of each column. + private static let openPercentByPosition: [Position: [Double]] = [ + .utg: [16, 13, 10], + .middlePosition: [24, 21, 18], + .hijack: [27, 24, 21], + .cutoff: [34, 31, 28], + .button: [49, 46, 43], + .smallBlind: [51, 48, 45], + ] + + public static func openPercentage(position: Position, effectiveStackBB: Double) -> Double { + let table = openPercentByPosition[position]! + let stack = min(max(effectiveStackBB, breakpoints.first!), breakpoints.last!) + + guard let upperIndex = breakpoints.firstIndex(where: { $0 >= stack }) else { + return table.last! + } + if breakpoints[upperIndex] == stack || upperIndex == 0 { + return table[upperIndex] + } + + let lowerIndex = upperIndex - 1 + let lowerBB = breakpoints[lowerIndex] + let upperBB = breakpoints[upperIndex] + let fraction = (stack - lowerBB) / (upperBB - lowerBB) + return table[lowerIndex] + fraction * (table[upperIndex] - table[lowerIndex]) + } + + public static func decide(hand: HoleCards, position: Position, effectiveStackBB: Double) -> OpeningDecision { + let percentage = openPercentage(position: position, effectiveStackBB: effectiveStackBB) + let threshold = PushFoldRange.scoreThreshold(forPercentage: percentage) + let handScore = ChenScore.score(for: hand) + let action: OpeningAction = handScore >= threshold ? .raise : .fold + return OpeningDecision(action: action, handScore: handScore, scoreThreshold: threshold, openPercentage: percentage) + } +} diff --git a/PokerKit/Sources/PokerKit/PreflopGrid.swift b/PokerKit/Sources/PokerKit/PreflopGrid.swift index 6586d6d..5775fe9 100644 --- a/PokerKit/Sources/PokerKit/PreflopGrid.swift +++ b/PokerKit/Sources/PokerKit/PreflopGrid.swift @@ -36,4 +36,13 @@ public enum PreflopGrid { row.map { PushFoldRange.decide(hand: $0, position: position, effectiveStackBB: effectiveStackBB) } } } + + /// The opening (raise-first-in) decision for every cell, for a given position and + /// effective stack. Same enumeration as `decisions(position:effectiveStackBB:)`, just + /// mapped through `OpeningRange` instead of `PushFoldRange`. + public static func openingDecisions(position: Position, effectiveStackBB: Double) -> [[OpeningDecision]] { + hands.map { row in + row.map { OpeningRange.decide(hand: $0, position: position, effectiveStackBB: effectiveStackBB) } + } + } } diff --git a/PokerKit/Sources/PokerKit/StudyTool.swift b/PokerKit/Sources/PokerKit/StudyTool.swift index b5ed46b..992ad23 100644 --- a/PokerKit/Sources/PokerKit/StudyTool.swift +++ b/PokerKit/Sources/PokerKit/StudyTool.swift @@ -23,7 +23,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable { public var summary: String { switch self { case .preflopRanges: - return "Build and review opening/3-bet/4-bet ranges by position and stack depth." + return "Opening (raise-first-in) ranges for standard stacks, plus push/fold shove ranges for short stacks — by position and effective stack." case .pushFold: return "Shove-or-fold decisions for short stacks (~1-20bb), by position and effective stack." case .bankroll: diff --git a/PokerKit/Tests/PokerKitTests/OpeningRangeTests.swift b/PokerKit/Tests/PokerKitTests/OpeningRangeTests.swift new file mode 100644 index 0000000..3855934 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/OpeningRangeTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import PokerKit + +@Test func premiumHandAlwaysOpens() { + let aa = HoleCards(canonical: "AA")! + for position in Position.allCases { + for stack: Double in [20, 40, 60, 100] { + let decision = OpeningRange.decide(hand: aa, position: position, effectiveStackBB: stack) + #expect(decision.action == .raise, "AA should open from \(position) at \(stack)bb") + } + } +} + +@Test func openingTrashHandFoldsDeepAndEarly() { + // 72o from UTG at 100bb is the textbook clear fold — nowhere near an opening hand. + let trash = HoleCards(canonical: "72o")! + let decision = OpeningRange.decide(hand: trash, position: .utg, effectiveStackBB: 100) + #expect(decision.action == .fold) +} + +@Test func openingWidensAsStackShortens() { + // A hand that opens at a deeper stack must also open at any shorter stack (down to + // the 20bb floor of this model), from the same position — ranges only widen as the + // stack gets shorter, same invariant as PushFoldRange. + let hand = HoleCards(canonical: "A9s")! + let position = Position.utg + var lastWasOpen = false + for stack: Double in stride(from: 100, through: 20, by: -5) { + let decision = OpeningRange.decide(hand: hand, position: position, effectiveStackBB: stack) + if lastWasOpen { + #expect(decision.action == .raise, "Range should not tighten as stack shortens (stack \(stack))") + } + lastWasOpen = decision.action == .raise + } +} + +@Test func openingWidensByPosition() { + // If a hand opens from an earlier position, it must also open from every later + // position at the same stack — later positions are always >= as wide. + let orderedPositions = Position.allCases // already earliest-to-latest + for stack: Double in [20, 40, 60, 100] { + for handString in ["KQo", "A9s", "88", "T9s", "A5s"] { + let hand = HoleCards(canonical: handString)! + var sawOpen = false + for position in orderedPositions { + let decision = OpeningRange.decide(hand: hand, position: position, effectiveStackBB: stack) + if sawOpen { + #expect(decision.action == .raise, "\(handString) should still open from \(position) at \(stack)bb once it opens from an earlier position") + } + sawOpen = sawOpen || decision.action == .raise + } + } + } +} + +@Test func openPercentageInterpolatesBetweenBreakpoints() { + // 20bb and 40bb are both explicit breakpoints for UTG (16% and 13%); 30bb should + // land strictly between them. + let at20 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 20) + let at30 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 30) + let at40 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 40) + #expect(at40 < at30) + #expect(at30 < at20) +} + +@Test func openPercentageClampsOutsideDefinedRange() { + let below = OpeningRange.openPercentage(position: .button, effectiveStackBB: 5) + let at20 = OpeningRange.openPercentage(position: .button, effectiveStackBB: 20) + #expect(below == at20) + + let above = OpeningRange.openPercentage(position: .button, effectiveStackBB: 200) + let at100 = OpeningRange.openPercentage(position: .button, effectiveStackBB: 100) + #expect(above == at100) +} + +@Test func openingButtonIsWiderThanUTGAtSameStack() { + let utgPct = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 100) + let btnPct = OpeningRange.openPercentage(position: .button, effectiveStackBB: 100) + #expect(btnPct > utgPct) +} + +@Test func smallBlindIsWiderThanButtonAtSameStack() { + // Matches the cited source's qualitative shape: SB opens wider than BTN since SB is + // only getting through one player (BB), even though the absolute SB number here is + // deliberately tightened below the source's raw figure — see RANGES.md. + for stack: Double in [20, 40, 100] { + let btnPct = OpeningRange.openPercentage(position: .button, effectiveStackBB: stack) + let sbPct = OpeningRange.openPercentage(position: .smallBlind, effectiveStackBB: stack) + #expect(sbPct > btnPct, "SB should open wider than BTN at \(stack)bb") + } +} + +@Test func reasoningMentionsRaiseOrFold() { + let decision = OpeningRange.decide(hand: HoleCards(canonical: "AA")!, position: .utg, effectiveStackBB: 100) + #expect(decision.reasoning.contains("open-raise threshold")) +} diff --git a/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift b/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift index bdae579..beacb21 100644 --- a/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift +++ b/PokerKit/Tests/PokerKitTests/PreflopGridTests.swift @@ -54,6 +54,26 @@ import Testing } } +@Test func gridOpeningDecisionsMatchDirectOpeningRangeDecisions() { + for row in 0.. [[PushFoldDecision]]` Maps `PushFoldRange.decide(hand:position:effectiveStackBB:)` over every cell -of `hands`, indexed `[row][col]` to match. This is the only function the app -calls — `PreflopRangeView` renders one `Color.accentColor` (shove) or -`Color(.secondarySystemBackground)` (fold) cell per decision, with a position -picker and a stack slider (1–20bb) driving the two parameters live. - -`gridDecisionsMatchDirectPushFoldRangeDecisions` (test) is the load-bearing -guarantee here: every grid cell's decision is required to exactly match -calling `PushFoldRange.decide` directly for that same hand/position/stack — -the grid is not allowed to drift into its own logic. +of `hands`, indexed `[row][col]` to match. + +## `openingDecisions(position:effectiveStackBB:) -> [[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` +(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. + +`gridDecisionsMatchDirectPushFoldRangeDecisions` and +`gridOpeningDecisionsMatchDirectOpeningRangeDecisions` (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. diff --git a/ai-docs/RANGES.md b/ai-docs/RANGES.md index 91f3e1b..7a38429 100644 --- a/ai-docs/RANGES.md +++ b/ai-docs/RANGES.md @@ -1,10 +1,20 @@ -# Push/Fold Ranges +# Preflop Ranges Source: `PokerKit/Sources/PokerKit/ChenScore.swift`, `PushFoldRange.swift`, -`PushFoldSpot.swift`, `Position.swift`. Tests: `ChenScoreTests.swift`, -`PushFoldRangeTests.swift`. +`OpeningRange.swift`, `PushFoldSpot.swift`, `Position.swift`. Tests: +`ChenScoreTests.swift`, `PushFoldRangeTests.swift`, `OpeningRangeTests.swift`. -## What this models +Two range models live here, covering two different stack regimes of an MTT: + +- **Push/Fold** (`PushFoldRange`) — short stacks, roughly 1–20bb, 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. + +## Push/Fold An **unopened-pot, short-stack push/fold decision**: hero is first to act (or everyone before them folded), effective stack is roughly 1–20bb, and the only @@ -22,7 +32,7 @@ about this and about how to upgrade it later (swap `shovePercentByPosition` for solved numbers, or a full per-hand lookup table — nothing downstream changes). -## The pipeline +### The pipeline 1. **`ChenScore.score(for: HoleCards) -> Double`** — Bill Chen's published hand-strength heuristic. Ranks the 169 starting hands without hand-typing @@ -51,15 +61,7 @@ changes). UI ("Hand strength score 9 clears the shove threshold of 7 (top 22% of hands)..."). -## 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 push/fold decision to make there. A BB facing an -earlier shove is a *calling* range, a different (and currently unmodeled) -tool. See `Position.swift`'s doc comment. - -## `PushFoldSpot` +### `PushFoldSpot` A dealable drill spot: `hand: HoleCards`, `position: Position`, `effectiveStackBB: Int`. `.decision` computes the `PushFoldDecision` on @@ -68,16 +70,125 @@ this is what the plain Push/Fold Trainer screen (`PushFoldTrainerView`) uses; `DrillGenerator` (see `DRILLS.md`) biases the same primitive toward a user's own leak region instead of sampling uniformly. +## Opening (Raise-First-In) Ranges + +An **unopened-pot, standard-stack opening decision**: hero is first to enter +the pot, effective stack is roughly 20–100bb, and the model considers +raise-first-in vs. fold (no limping — consistent with the push/fold model's +own no-limp scope). This is the preflop decision that covers most of an MTT +before the stack gets short. + +**This is also a hand-tuned study aid, not solver output**, same posture and +same reason as `PushFoldRange`: real GTO-solved opening ranges depend on exact +stack depths, ante structure, rake, opponent tendencies, and postflop +strategy that a static table can't capture. `OpeningRange` reuses the exact +same pipeline as `PushFoldRange` — `ChenScore` for hand ranking and +`PushFoldRange.scoreThreshold(forPercentage:)` for turning a percentage into a +score cutoff — so there is still only one hand-ranking system in this +codebase. The only new thing `OpeningRange` introduces is its own +`openPercentByPosition` table and 3 stack breakpoints (`[20, 40, 100]` bb, +vs. push/fold's 10) — fewer breakpoints because the source material backing +this table is thinner across stack depths (see "Source basis" below). + +### Source basis — read this before trusting a specific number + +The 100bb column is the only one backed by a full, named, position-by-position +source. The 40bb and 20bb columns are **hand-tuned extrapolations**, not +independently sourced charts. Per this project's own rule (bad poker math is +worse than none), here's exactly what's sourced vs. extrapolated: + +- **100bb anchor (sourced).** PokerCoaching.com's "Implementable GTO Charts" + for 6-max, 100bb (a GTO-solver output with mixed strategies removed for + single-action clarity) — cross-verified by fetching it directly twice via + two different page paths, both times returning the identical numbers: + UTG 10.1%, LJ 17.6%, HJ 21.4%, CO 27.8%, BTN 43.5%, SB 62.3%. + (`poker-coaching.s3.amazonaws.com/tools/preflop-charts/online-6max-gto-charts.pdf`, + via `pokercoaching.com/preflop-charts/`.) Rounded to whole percentages for + the shipped table: UTG 10, MP(LJ) 18, HJ 21, CO 28, BTN 43. + +- **SB is deliberately tightened below its cited source.** The 62.3% SB + figure above is real and reproducible, but a second independent source + (freebetrange.com's 6-max open-raise chart) puts SB open-raising at 39–47% + for broadly the same context — a large enough disagreement between two + reputable sources that we treat SB as genuinely uncertain rather than + picking whichever number sounds more authoritative. Per this project's + "err tight, note the uncertainty" rule, the shipped table uses **45%** + (the conservative end of the second source's range) rather than the raw + 62.3% GTO figure. This is a deliberate, disclosed choice, not a rounding + error — if you find a better-corroborated number, `openPercentByPosition` + is a one-line change. The qualitative fact the sourced data agrees on (SB + opens wider than BTN, since SB is only playing past one opponent) is kept; + only the magnitude is pulled in. + +- **40bb column (single-anchor extrapolation).** The only concrete 40bb data + point found was UTG ≈ 13% (Preflop Wizard's MTT preflop-strategy write-up), + vs. the sourced 100bb UTG figure of 10.1% — a +3-point widening. Every + position's 40bb number in the shipped table is the 100bb number plus that + same +3-point offset. This is explicitly an extrapolation from one data + point, not six independently sourced ones. It's directionally consistent + with every source found (ranges widen as tournament stacks shorten, largely + because of increasing ante pressure), but the exact magnitude for + positions other than UTG is a hand-tuned guess, not a citation. + +- **20bb column (hand-tuned extrapolation, no direct source).** No source + with concrete 20bb 6-max opening percentages was found. The shipped table + uses the 100bb number plus a +6-point offset (double the 40bb offset), + consistent with the qualitative direction every source agrees on (ranges + widen further as the stack approaches push/fold territory) but with no + citation backing the specific number. Treat the 20bb column as the least + trustworthy part of this table — it exists mainly so the slider has a + smooth floor right where `PushFoldRange` picks up, not because it's + independently verified. **This model and `PushFoldRange` are not + reconciled at that boundary** — they're two separate hand-tuned tables, so + don't expect their numbers to line up exactly at 20bb. + +If you want to firm any of this up: replace `openPercentByPosition` with a +properly-sourced chart per breakpoint (ideally the same source across all +positions and all three depths) — nothing downstream changes, same upgrade +path `PushFoldRange` already documents for itself. + +### The pipeline + +Identical shape to push/fold's, with `OpeningRange` in place of +`PushFoldRange`: + +1. `ChenScore.score(for:)` — same hand ranking, no changes. +2. `OpeningRange.openPercentage(position:effectiveStackBB:)` — looks up + `openPercentByPosition[position]` at breakpoints `[20, 40, 100]` bb, + linearly interpolates, clamped to `[20, 100]`. +3. `PushFoldRange.scoreThreshold(forPercentage:)` — reused directly, not + duplicated. +4. `OpeningRange.decide(hand:position:effectiveStackBB:) -> OpeningDecision` + — raises if `handScore >= threshold`. `OpeningDecision.reasoning` mirrors + `PushFoldDecision.reasoning`'s wording, swapped to "open-raise threshold." + +## 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. + ## Consumers -- `PushFoldTrainerView` — plain random practice. -- `PreflopGrid` (`PREFLOP-GRID.md`) — renders `PushFoldRange.decide` for all - 169 hands at once as a grid. +- `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. - `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. - `DrillGenerator` (`DRILLS.md`) — deals `PushFoldSpot`s weighted toward those - deviations. - -There is exactly one push/fold model in the codebase; every screen and the -leak-analysis engine all call through `PushFoldRange`, so there's never a -second opinion on what "correct" means for a given spot. + 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. diff --git a/app/Sources/PreflopRangeView.swift b/app/Sources/PreflopRangeView.swift index 5121577..18bd8ed 100644 --- a/app/Sources/PreflopRangeView.swift +++ b/app/Sources/PreflopRangeView.swift @@ -1,18 +1,68 @@ 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. +private enum RangeMode: String, CaseIterable, Identifiable { + case pushFold = "Push/Fold" + case opening = "Opening" + + var id: String { rawValue } + + var stackRange: ClosedRange { + switch self { + case .pushFold: return 1...20 + case .opening: return 20...100 + } + } + + var defaultStack: Double { + switch self { + case .pushFold: return 10 + case .opening: return 50 + } + } + + var actionLabel: String { + switch self { + case .pushFold: return "Shove" + case .opening: return "Raise" + } + } + + var summarySuffix: String { + switch self { + case .pushFold: return "% of hands to shove" + case .opening: return "% of hands to open" + } + } +} + struct PreflopRangeView: View { + @State private var mode: RangeMode = .pushFold @State private var position: Position = .utg - @State private var effectiveStackBB: Double = 10 + @State private var effectiveStackBB: Double = RangeMode.pushFold.defaultStack - private var decisions: [[PushFoldDecision]] { - PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) + /// 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]] { + switch mode { + case .pushFold: + return PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) + .map { row in row.map { $0.action == .push } } + case .opening: + return PreflopGrid.openingDecisions(position: position, effectiveStackBB: effectiveStackBB) + .map { row in row.map { $0.action == .raise } } + } } - private var shovePercentage: Double { - let total = decisions.reduce(0) { $0 + $1.count } - let shoves = decisions.reduce(0) { $0 + $1.filter { $0.action == .push }.count } - return Double(shoves) / Double(total) * 100 + 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 } var body: some View { @@ -31,6 +81,17 @@ struct PreflopRangeView: View { private var controls: some View { VStack(alignment: .leading, spacing: 16) { + Picker("Range", selection: $mode) { + ForEach(RangeMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("rangeModePicker") + .onChange(of: mode) { _, newMode in + effectiveStackBB = newMode.defaultStack + } + Picker("Position", selection: $position) { ForEach(Position.allCases) { position in Text(position.rawValue).tag(position) @@ -50,14 +111,14 @@ struct PreflopRangeView: View { .monospacedDigit() .accessibilityIdentifier("stackValue") } - Slider(value: $effectiveStackBB, in: 1...20, step: 1) + Slider(value: $effectiveStackBB, in: mode.stackRange, step: 1) .accessibilityIdentifier("stackSlider") } } } private var summary: some View { - Text("\(String(format: "%.0f", shovePercentage))% of hands") + Text("\(String(format: "%.0f", actionPercentage))\(mode.summarySuffix)") .font(.subheadline.bold()) .foregroundStyle(.secondary) .accessibilityIdentifier("shovePercentageText") @@ -76,24 +137,23 @@ struct PreflopRangeView: View { } private func cell(row: Int, col: Int) -> some View { - let decision = decisions[row][col] let notation = PreflopGrid.notation(row: row, col: col) - let isShove = decision.action == .push + let isAggressive = isAggressiveGrid[row][col] return Text(notation) .font(.system(size: 10, weight: .semibold, design: .rounded)) .minimumScaleFactor(0.6) .lineLimit(1) .frame(maxWidth: .infinity, minHeight: 24) - .background(isShove ? Color.accentColor : Color(.secondarySystemBackground)) - .foregroundStyle(isShove ? Color.white : Color.secondary) + .background(isAggressive ? Color.accentColor : Color(.secondarySystemBackground)) + .foregroundStyle(isAggressive ? Color.white : Color.secondary) .clipShape(RoundedRectangle(cornerRadius: 3)) .accessibilityIdentifier("cell-\(notation)") } private var legend: some View { HStack(spacing: 16) { - legendSwatch(color: .accentColor, label: "Shove") + legendSwatch(color: .accentColor, label: mode.actionLabel) legendSwatch(color: Color(.secondarySystemBackground), label: "Fold") } .font(.caption)