Advertisement

← poker-tutor

Sources/PokerCoach/RangeModel.swift

13.8 KB Download file
import Foundation
import PokerCore
import PokerAI

/// Two specific cards someone might be holding.
public struct Combo: Hashable, Sendable {
    public let a: Card
    public let b: Card

    public init(_ x: Card, _ y: Card) {
        // Normalised so the same two cards always make the same combo.
        if x.index < y.index { a = x; b = y } else { a = y; b = x }
    }

    public var cards: [Card] { [a, b] }
    public var handClass: HandClass { HandClass([a, b]) }
    public func blocks(_ card: Card) -> Bool { a == card || b == card }

    /// A fixed order over every possible pair of cards.
    ///
    /// It exists because a range is stored as a dictionary, and the order a Swift dictionary hands
    /// its keys back changes every time the program starts. Anything that reads those keys in
    /// order — drawing a sample, breaking a tie between two hands of equal strength — was therefore
    /// answering slightly differently on each launch from the same seed, and the coach's verdicts
    /// moved with it. A seeded hand has to play out the same way today and tomorrow, so the keys
    /// are put in this order before anything is done with them.
    public var order: Int { Int(a.index) * 52 + Int(b.index) }
}

/// What an opponent might be holding, as a weight on every possible pair of cards.
///
/// The coach cannot know an opponent's cards, and neither can the player. What it can do is what a
/// good player does: start from the hands somebody in that seat would have played that way, and
/// narrow it as the hand goes on. Every judgement the coach makes is made against this, not
/// against a random hand — which is the difference between "top pair is usually good" and "top
/// pair is behind everything that would bet twice here".
public struct HandRange: Sendable {
    public private(set) var weights: [Combo: Double]

    public init(weights: [Combo: Double] = [:]) { self.weights = weights }

    /// Every hand somebody could hold, minus the cards we can see.
    public static func everything(excluding dead: [Card]) -> HandRange {
        let deadSet = Set(dead)
        let live = Card.all.filter { !deadSet.contains($0) }
        var weights: [Combo: Double] = [:]
        for i in 0..<live.count {
            for j in (i + 1)..<live.count {
                weights[Combo(live[i], live[j])] = 1.0
            }
        }
        return HandRange(weights: weights)
    }

    /// The strongest `fraction` of starting hands, before any cards are out.
    public static func top(_ fraction: Double, excluding dead: [Card]) -> HandRange {
        var range = everything(excluding: dead)
        range.weights = range.weights.filter { PreflopTable.percentile($0.key.handClass) <= fraction }
        return range
    }

    public var count: Int { weights.count }
    public var totalWeight: Double { weights.values.reduce(0, +) }
    public var isEmpty: Bool { weights.isEmpty }

    public func removing(_ dead: [Card]) -> HandRange {
        let deadSet = Set(dead)
        return HandRange(weights: weights.filter { !deadSet.contains($0.key.a) && !deadSet.contains($0.key.b) })
    }

    /// Keeps the strongest `fraction` of the range on this board, by made-hand strength.
    ///
    /// This is how an action narrows a range: someone who bets twice into a dangerous board is
    /// weighted toward the top of whatever they started with.
    /// - Parameter countingDraws: rank the draws that play for a stack one category up —
    ///   alongside a pair of their own high card: a flush draw to the queen or better, or a
    ///   flush draw with a straight draw beside it. A bare low flush draw, a bare straight draw
    ///   and a gutshot do not count: nobody puts a hundred blinds in on those. Off by default:
    ///   narrowing by what an opponent has *shown* stays about made hands, but the hands that
    ///   shove a flop are part draws. Counting every draw was tried and made the range so weak
    ///   that stacking off with any top pair priced as right.
    public func strongest(_ fraction: Double, board: [Card], countingDraws: Bool = false) -> HandRange {
        guard fraction < 0.999, !weights.isEmpty else { return self }
        let ranked = rankedByStrength(board: board, countingDraws: countingDraws)
        let keep = max(1, Int(Double(ranked.count) * fraction.clamped(to: 0.02...1.0)))
        var out: [Combo: Double] = [:]
        for (combo, _) in ranked.prefix(keep) { out[combo] = weights[combo] ?? 1 }
        return HandRange(weights: out)
    }

    /// Every combo in the range, in a fixed order that does not depend on how the dictionary
    /// happens to be laid out this run.
    public var orderedCombos: [Combo] { weights.keys.sorted { $0.order < $1.order } }

    /// Every combo, strongest first. Two hands of exactly equal strength — which is common on a
    /// paired board — keep their fixed order rather than whichever way round the dictionary
    /// offered them, so the same board narrows the same way every time.
    public func rankedByStrength(board: [Card], countingDraws: Bool = false) -> [(Combo, Double)] {
        let scored: [(Combo, Double)] = board.count >= 3
            ? orderedCombos.map { combo in
                var value = Double(HandEvaluator.evaluate(combo.cards + board).value)
                if countingDraws, board.count < 5 { value += Self.drawBonus(combo: combo, board: board) }
                return (combo, value)
            }
            : orderedCombos.map { ($0, 1 - PreflopTable.percentile($0.handClass)) }
        return scored.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.order < $1.0.order }
    }

    /// What a draw is worth when ranking hands that would play for a stack, in `HandRank` units:
    /// a flush draw to the queen or better, or any flush draw with an eight-out straight draw
    /// beside it, counts as one category — a pair of its own high card. Everything else counts
    /// as nothing. Only draws that use a hole card count.
    static func drawBonus(combo: Combo, board: [Card]) -> Double {
        let category = Double(1 << 20)
        let all = combo.cards + board
        var suits: [Suit: Int] = [:]
        for card in all { suits[card.suit, default: 0] += 1 }
        let flushDrawSuit = suits.first { suit, count in
            count == 4 && combo.cards.contains { $0.suit == suit }
        }?.key
        let flushDraw = flushDrawSuit != nil
        let bigFlushDraw = flushDrawSuit.map { suit in
            combo.cards.contains { $0.suit == suit && $0.rank >= .queen }
        } ?? false
        var mask: UInt16 = 0
        for card in all { mask |= card.rank.bit }
        let boardMask = board.reduce(UInt16(0)) { $0 | $1.rank.bit }
        // Straight draws: how many cards a five-card window is short, counting the wheel.
        var bestShort = 5
        let windows: [UInt16] = (0..<9).map { 0b11111 << UInt16($0) } + [0b1_0000_0000_1111]
        for window in windows where (mask & window).nonzeroBitCount >= 4 && (boardMask & window) != (mask & window) {
            bestShort = min(bestShort, 5 - (mask & window).nonzeroBitCount)
        }
        let openEnded = bestShort <= 1 && Self.hasOpenEnd(mask: mask, boardMask: boardMask)
        return bigFlushDraw || (flushDraw && openEnded) ? category : 0
    }

    /// Four in a row using a hole card, with room at both ends.
    static func hasOpenEnd(mask: UInt16, boardMask: UInt16) -> Bool {
        for low in 0..<10 {
            let four: UInt16 = 0b1111 << UInt16(low)
            guard mask & four == four, (boardMask & four) != four else { continue }
            let below = low > 0 && low + 4 <= 12
            if below { return true }
        }
        return false
    }

    /// Draws a sample of combos, for equity work that would be too slow exhaustively.
    public func sample(_ count: Int, rng: inout SeededRandom) -> [Combo] {
        let all = orderedCombos
        guard all.count > count else { return all }
        var out: [Combo] = []
        var pool = all
        for _ in 0..<count {
            let index = Int(rng.next() % UInt64(pool.count))
            out.append(pool[index])
            pool.remove(at: index)
        }
        return out
    }
}

/// Builds an opponent's likely range from how they have played the hand.
public enum RangeReader {

    /// The range an opponent's preflop line suggests, before any board narrowing.
    public static func preflopRange(for seat: SeatView, in view: DecisionView) -> HandRange {
        let dead = view.holeCards + view.board
        let players = view.playersDealtIn
        let position = seat.position ?? .button
        let preflop = view.actions.filter { $0.street == .preflop && !$0.isForced && $0.seat == seat.seat }
        let raisesBefore = view.preflopRaiseCount

        let raised = preflop.contains {
            if case .raise = $0.action { return true }
            if case .bet = $0.action { return true }
            return false
        }
        let called = preflop.contains { $0.action == .call }

        // At a fixed-limit table the opponents play the limit charts, so they are read off them;
        // reading a limit raiser off the no-limit chart understated every range and had the
        // coach preferring folds at the one game where folding is the expensive habit.
        let format: CoachFormat = view.structure == .fixedLimit ? .limit : .tournament
        var fraction: Double
        if raised && raisesBefore >= 2 {
            fraction = ReferenceRanges.fourBet(from: position, players: players, format: format) * 2.2
        } else if raised {
            fraction = raisesBefore >= 2
                ? ReferenceRanges.threeBet(from: position, versus: .button, players: players, format: format) * 1.8
                : ReferenceRanges.open(position: position, players: players, format: format) * 1.35
        } else if called {
            fraction = ReferenceRanges.callVersusOpen(from: position, versus: .cutoff, players: players, format: format) * 1.8
        } else {
            // Checked the big blind, or limped in: very wide and weighted to nothing special.
            fraction = 0.80
        }

        // Real opponents are looser than any chart. Widening deliberately keeps the coach from
        // telling the player their good hand was beaten by a range nobody actually has.
        fraction = fraction.clamped(to: 0.03...0.95)
        return HandRange.top(fraction, excluding: dead)
    }

    /// Narrows a range by what the opponent has done on this street.
    public static func narrow(_ range: HandRange, by seat: SeatView, in view: DecisionView) -> HandRange {
        var out = range.removing(view.holeCards + view.board)
        guard view.board.count >= 3 else { return out }

        // At a fixed-limit table a bet is a fraction of the pot, so people bet and call with far
        // more — a bet there says less about the hand behind it than a no-limit bet does.
        let limit = view.structure == .fixedLimit
        for action in view.actions where action.street == view.street && !action.isForced && action.seat == seat.seat {
            switch action.action {
            case .bet, .raise:
                // Betting is polarising, but at amateur stakes it mostly means a real hand.
                out = out.strongest(limit ? 0.72 : 0.55, board: view.board)
            case .call:
                out = out.strongest(limit ? 0.85 : 0.70, board: view.board)
            case .check:
                out = out.strongest(0.92, board: view.board)   // barely narrows: everyone checks everything
            case .fold:
                break
            }
        }
        return out
    }

    /// The range of the opponent whose action the player is answering — or the strongest threat
    /// still in the hand when nobody has bet.
    public static func threatRange(in view: DecisionView) -> HandRange {
        let opponents = view.opponents
        guard !opponents.isEmpty else { return HandRange.everything(excluding: view.holeCards + view.board) }
        let target = view.lastAggressor.flatMap { seat in opponents.first { $0.seat == seat } } ?? opponents[0]
        return narrow(preflopRange(for: target, in: view), by: target, in: view)
    }
}

/// Hero's chances against a modelled range, rather than against a random hand.
public enum RangeEquity {

    /// Share of the pot hero expects against this range with the board run out.
    public static func equity(hole: [Card], board: [Card], versus range: HandRange,
                              trials: Int = 600, rng: inout SeededRandom) -> Double {
        guard !range.isEmpty else { return 0.5 }
        let combos = range.sample(min(trials, 120), rng: &rng)
        guard !combos.isEmpty else { return 0.5 }

        let dead = Set(hole + board)
        let runoutsEach = max(1, trials / combos.count)
        var wins = 0.0
        var total = 0.0

        for combo in combos {
            guard !dead.contains(combo.a), !dead.contains(combo.b) else { continue }
            let blocked = dead.union([combo.a, combo.b])
            var stub = Card.all.filter { !blocked.contains($0) }
            let needed = 5 - board.count

            for _ in 0..<runoutsEach {
                for i in 0..<needed {
                    let j = i + Int(rng.next() % UInt64(stub.count - i))
                    stub.swapAt(i, j)
                }
                let full = board + Array(stub.prefix(needed))
                let mine = HandEvaluator.evaluate(hole + full)
                let theirs = HandEvaluator.evaluate(combo.cards + full)
                if mine > theirs { wins += 1 } else if mine == theirs { wins += 0.5 }
                total += 1
            }
        }
        return total > 0 ? wins / total : 0.5
    }
}