← poker-tutor
import Foundation
import PokerCore
/// Preflop hand strength: raw equity, and the ordering real ranges are actually built on.
///
/// Ranking hands purely by equity produces ranges no player would recognise — K2s outranks 76s
/// on raw equity, but every sound chart plays 76s and folds K2s, because a suited connector makes
/// hands that win big pots and a weak ace-x makes hands that lose them. `rangeScore` adds that
/// playability back in, and every range in the app is cut from it.
public enum PreflopTable {
struct Entry {
let headsUp: Double
let multiway: Double
let rangeScore: Double
}
static let entries: [HandClass: Entry] = {
var out: [HandClass: Entry] = [:]
for (text, headsUp, multiway) in preflopEquityTable {
guard let hand = HandClass(text) else { continue }
out[hand] = Entry(headsUp: headsUp, multiway: multiway, rangeScore: score(hand, headsUp: headsUp))
}
return out
}()
/// Equity plus playability. The bonuses are small on purpose: they reorder neighbours without
/// ever promoting a weak hand past a genuinely strong one.
///
/// Two corrections do most of the work, and both are places where raw all-in equity misleads:
/// pocket pairs are undervalued, because equity-against-a-random-hand ignores how easy they
/// are to play and how often they flop a set that wins a stack; and big-card-with-a-weak-
/// kicker hands are badly overvalued, because their equity comes from an ace or king that
/// makes exactly the top pair which is beaten by a better kicker. That is why every real chart
/// plays 55 and folds A9o, and why the two are nearly level on raw equity.
private static func score(_ hand: HandClass, headsUp: Double) -> Double {
var score = headsUp
if hand.isPair {
score += 0.015
return score
}
if hand.suited { score += 0.035 } // flushes, and cleaner folds
switch hand.gap {
case 0: score += 0.028 // connected
case 1: score += 0.016
case 2: score += 0.006
default: break
}
// Big card, weak kicker, offsuit: makes dominated top pairs and little else.
if !hand.suited && hand.high >= .jack && hand.low <= .nine && hand.gap >= 2 {
score -= 0.035
}
return score
}
/// Equity against `opponents` random hands, interpolated between the measured endpoints.
public static func equity(_ hand: HandClass, opponents: Int) -> Double {
guard let e = entries[hand] else { return 0.5 }
let n = Double(max(1, min(opponents, 8)))
// Equity decays smoothly with the field; the two measured points pin the curve.
let t = (log(n) / log(5.0)).clamped(to: 0...1.6)
return e.headsUp + (e.multiway - e.headsUp) * t
}
public static func equity(_ cards: [Card], opponents: Int) -> Double {
equity(HandClass(cards), opponents: opponents)
}
/// All 169 hands, strongest first, by the ordering ranges are built from.
public static let ordered: [HandClass] = HandClass.all.sorted {
(entries[$0]?.rangeScore ?? 0) > (entries[$1]?.rangeScore ?? 0)
}
/// Where this hand sits in the deck as a fraction of all 1,326 two-card combinations:
/// 0 for aces, 1 for the worst hand. A hand is "in the top 20%" when this is at most 0.20.
///
/// Weighting by combinations matters — the 13 pairs are only 6% of deals despite being 8% of
/// the 169 classes, so counting classes would quietly make every range too tight.
public static let percentile: [HandClass: Double] = {
let total = Double(HandClass.all.reduce(0) { $0 + $1.combos })
var running = 0.0
var out: [HandClass: Double] = [:]
for hand in ordered {
running += Double(hand.combos)
out[hand] = running / total
}
return out
}()
public static func percentile(_ hand: HandClass) -> Double { percentile[hand] ?? 1.0 }
public static func percentile(_ cards: [Card]) -> Double { percentile(HandClass(cards)) }
/// The strongest `fraction` of all starting hands.
public static func range(top fraction: Double) -> Set<HandClass> {
Set(HandClass.all.filter { percentile($0) <= fraction })
}
/// Is this hand inside the top `fraction` of hands?
public static func isInTop(_ fraction: Double, _ cards: [Card]) -> Bool {
percentile(HandClass(cards)) <= fraction
}
}
public extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}