Advertisement

← poker-tutor

Sources/PokerCoach/LimitEvaluator.swift

15.0 KB Download file
import Foundation
import PokerCore
import PokerAI

/// The coach's judgement after the flop at a fixed-limit table.
///
/// Fixed-limit is a different game and the no-limit arithmetic about bet sizes does not apply
/// to it. A bet is a fraction of the pot by rule — an eighth to a quarter, usually — so:
///
/// - the price of a call is nearly always right for a draw, and the expensive habit is the fold:
///   a draw let go for one small bet, a made hand folded on the river to one bet in a pot of ten;
/// - a bet folds almost nobody, so betting is how value is collected, not how pots are stolen;
///   the habit is calling with the best hand where a raise would have been paid;
/// - the player who acts last can raise a draw on the flop and, about half the time, be checked
///   to on the turn — the free card, which is worth a whole big bet;
/// - bluffing into a pot that offers eight to one is a way to lose one bet at a time.
///
/// The same machinery as the no-limit judgement — a threat range, hero's share against it, a
/// price for each candidate action — with limit's own candidates, fold rates and mistakes.
extension DecisionEvaluator {

    static func evaluateLimitPostflop(_ view: DecisionView, took action: PlayerAction,
                                      rng: inout SeededRandom) -> Verdict {
        let bb = Double(view.bigBlind)
        let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
        let range = RangeReader.threatRange(in: view)
        let equity = RangeEquity.equity(hole: view.holeCards, board: view.board, versus: range, rng: &rng)
        var spot = freeCardSpot(view, read: read) ? SpotKind.freeCardChance : postflopSpot(view)

        let candidates = limitCandidates(view, read: read, equity: equity)
        let values = candidates.map { ($0, limitValue(of: $0, view: view, equity: equity, read: read)) }
        guard let bestPair = values.max(by: { $0.1 < $1.1 }) else {
            return neutral(view: view, action: action, spot: spot)
        }
        let takenValue = values.first { same($0.0, action) }?.1
            ?? limitValue(of: action, view: view, equity: equity, read: read)

        var cost = max(0, bestPair.1 - takenValue) / bb
        var error: ErrorKind? = nil
        if cost > 0 {
            error = classifyLimit(taken: action, best: bestPair.0, view: view, read: read, equity: equity)
        }
        if error == nil { cost = 0 }
        cost = min(cost, Self.mostOneDecisionCosts)
        // The free-card spot has one habit under it. A draw folded there is the flop fold
        // `limit-fold-to-price` exists for, and is filed where that habit can see it.
        if spot == .freeCardChance, error != nil, error != .missedFreeCard { spot = postflopSpot(view) }

        let confidence = limitSensitivity(view: view, read: read, best: bestPair.0, action: action, rng: &rng)
        let severity = Severity.from(costInBB: cost)
        let text = explainLimitPostflop(view: view, read: read, equity: equity, taken: action,
                                        best: bestPair.0, error: error)
        return Verdict(street: view.street, spot: spot, holeCards: view.holeCards, board: view.board,
                       taken: action, best: bestPair.0, costInBB: error == nil ? 0 : cost,
                       severity: error == nil ? .fine : severity, error: error,
                       confidence: confidence, headline: text.0, detail: text.1)
    }

    /// Fold, the passive action, and the one bet or raise the rules allow — offered only when the
    /// hand has something behind it: likely best, a real draw, or the free-card raise.
    static func limitCandidates(_ view: DecisionView, read: HandAssessment, equity: Double) -> [PlayerAction] {
        let legal = view.legal
        var out: [PlayerAction] = []
        if legal.canCheck { out.append(.check) } else { out.append(.fold); if legal.canCall { out.append(.call) } }
        let worth = equity >= Self.limitValueEquity || read.outs >= Self.bigDrawOuts || freeCardSpot(view, read: read)
        if legal.canBetOrRaise, worth {
            out.append(legal.canBet ? .bet(to: legal.minRaiseTo) : .raise(to: legal.minRaiseTo))
        }
        return out
    }

    /// Chips this action is worth on average at a limit table.
    static func limitValue(of action: PlayerAction, view: DecisionView, equity: Double, read: HandAssessment) -> Double {
        let pot = Double(view.pot)
        let toCall = Double(view.toCall)
        switch action {
        case .fold:
            return 0
        case .check:
            return equity * pot
        case .call:
            return equity * (pot + toCall) - toCall
        case .bet(let to), .raise(let to):
            let size = Double(to - view.heroSeat.committedThisRound) - toCall
            guard size > 0 else { return equity * (pot + toCall) - toCall }
            let folds = limitFoldEquity(betSize: size, pot: pot + toCall)
            // A bet of one unit into a pot of eight is called by almost everything, so the hands
            // that call are barely stronger than the hands that were there: a small discount, not
            // the no-limit one.
            let ratio = size / max(1, pot + toCall)
            let equityWhenCalled = max(0, equity - 0.03 - 0.10 * ratio)
            let called = equityWhenCalled * (pot + toCall + 2 * size) - (size + toCall)
            var value = folds * (pot + toCall) + (1 - folds) * called
            // The free card. Last to act on the flop with a real draw, a raise is usually checked
            // to on the turn, and the turn bet is the big one — the raise costs one small bet now
            // and saves a big bet about as often as not. A rule of thumb every limit player is
            // taught, priced as such.
            if freeCardSpot(view, read: read) {
                // Priced small on purpose. On the price alone the raise is about even with a
                // call: half the time the turn is checked and the river costs two small bets in
                // all, half the time it is bet and the two cards cost four. What the raise earns
                // is the times the pot is taken down now, the extra bet collected when the draw
                // gets there, and the option to check the river back — a fraction of a big bet,
                // not a whole one. It was credited a whole one and charged a call as a mistake
                // worth a big blind; the honest edge is a few tenths of a small bet.
                value += Self.freeCardOdds * Double(view.bigBlind) * 2 * 0.3 + 0.02 * (pot + toCall)
            }
            return value
        }
    }

    /// How often one bet takes a limit pot down. Almost never: the price is too good to fold to.
    static func limitFoldEquity(betSize: Double, pot: Double) -> Double {
        guard pot > 0 else { return 0.2 }
        let ratio = betSize / pot
        return (0.06 + 0.30 * ratio).clamped(to: 0.05...0.22)
    }

    /// The flop, facing a bet, last to act, with a draw worth having: the spot the free-card raise
    /// exists for.
    static func freeCardSpot(_ view: DecisionView, read: HandAssessment) -> Bool {
        view.street == .flop && view.toCall > 0 && read.outs >= Self.bigDrawOuts
            && PositionAdjustment.actsLast(view.position, among: view.opponents.compactMap(\.position))
    }

    static func classifyLimit(taken: PlayerAction, best: PlayerAction, view: DecisionView,
                              read: HandAssessment, equity: Double) -> ErrorKind? {
        let price = view.potOdds
        if taken == .fold, best != .fold {
            // Only a fold with something to fold is named: a real draw, or a share comfortably
            // above the price. A hopeless hand let go for one bet is not a mistake worth a word.
            return read.outs >= Self.smallDrawOuts || equity >= price + 0.04 ? .foldedTooMuch : nil
        }
        if taken == .call, best == .fold {
            // Calling down with any pair is limit poker, and the coach never charges it. A river
            // call with no pair and nothing to come is the bet given away.
            if view.street == .river { return read.category < .underPair ? .calledTooWide : nil }
            return .calledTooWide
        }
        if taken == .call, isAggressive(best) {
            if freeCardSpot(view, read: read), equity < Self.limitValueEquity { return .missedFreeCard }
            // A raise for value facing a bet is charged only with a hand clearly best: a call
            // with a hand that is merely likely best is a judgement call at limit, and a
            // sound player makes it both ways.
            return equity >= Self.limitRaiseValueEquity ? .missedValue : nil
        }
        if taken == .check, isAggressive(best) {
            return equity >= Self.limitValueEquity ? .missedValue : nil
        }
        if isAggressive(taken), best == .fold { return .bluffedTooMuch }
        if isAggressive(taken), isPassive(best) {
            return equity < 0.40 ? .bluffedTooMuch : nil
        }
        return nil
    }

    /// The no-limit sensitivity check, with limit's candidates and prices.
    static func limitSensitivity(view: DecisionView, read: HandAssessment, best: PlayerAction,
                                 action: PlayerAction, rng: inout SeededRandom) -> Double {
        guard !same(best, action) else { return 0.9 }
        var agreements = 0
        var total = 0
        for shift in [-0.18, 0.18] {
            let base = RangeReader.threatRange(in: view)
            let shifted = shift < 0 ? base.strongest(0.62, board: view.board) : base
            let equity = (RangeEquity.equity(hole: view.holeCards, board: view.board,
                                             versus: shifted, trials: 300, rng: &rng)
                + (shift > 0 ? 0.06 : 0)).clamped(to: 0...1)
            let values = limitCandidates(view, read: read, equity: equity)
                .map { ($0, limitValue(of: $0, view: view, equity: equity, read: read)) }
            if let top = values.max(by: { $0.1 < $1.1 }), same(top.0, best) { agreements += 1 }
            total += 1
        }
        return total == 0 ? 0.5 : (0.42 + 0.28 * Double(agreements)).clamped(to: 0...0.97)
    }

    /// A share of the pot from which a bet or raise collects rather than gambles.
    static let limitValueEquity = 0.55
    /// The share above which calling a bet with the best hand, instead of raising, is charged.
    static let limitRaiseValueEquity = 0.62
    /// A draw big enough to raise with: eight outs — two ends of a straight, or a flush draw.
    static let bigDrawOuts = 8
    /// A draw worth one small bet almost always: four outs in a pot of any size.
    static let smallDrawOuts = 4
    /// How often a flop raise from the last seat buys a free turn card.
    static let freeCardOdds = 0.45

    // MARK: - Words

    static func explainLimitPostflop(view: DecisionView, read: HandAssessment, equity: Double,
                                     taken: PlayerAction, best: PlayerAction, error: ErrorKind?) -> (String, String) {
        guard let error else { return ("", "") }
        let hand = read.summary
        let board = view.board.map(\.pretty).joined(separator: " ")
        let chance = "\(Int((equity * 100).rounded()))%"
        // "One time in nine": the pot against one bet, which is the whole point of limit.
        let timesIn = view.toCall > 0 ? max(2, Int((Double(view.pot + view.toCall) / Double(view.toCall)).rounded())) : 0
        let priceLine = view.toCall > 0
            ? "With a bet this small next to the pot you needed to be best only one time in \(timesIn)"
            : ""

        switch error {
        case .foldedTooMuch:
            if view.street == .river {
                return ("The pot was too big to fold that on the river.",
                        "You had \(hand) on \(board). \(priceLine), and a hand like that is good there about \(chance) of the time. At a limit table the last bet is small next to what is already in the middle, so a hand with any real chance calls it — folding is the mistake that costs the whole pot.")
            }
            return ("That was worth one more bet.",
                    "You had \(hand) on \(board). \(priceLine), and you win about \(chance) of the time. \(drawNote(read)) At a limit table a bet costs a fraction of the pot, so a draw almost always has the price to keep going.")
        case .calledTooWide:
            if view.street == .river {
                return ("Nothing to call with.",
                        "You had \(hand) on \(board): no pair and nothing to come. Calling the last bet with a hand that cannot beat a bluff is a bet given away — it wins about \(chance) of the time, and the pot was not big enough to make that worth it.")
            }
            return ("That one was worth letting go.",
                    "You had \(hand) on \(board), which wins about \(chance) of the time against what they are likely to have — not enough even at the price a limit bet offers. Paying a bet at a time to see more with nothing is the slow way to lose at this game.")
        case .missedValue:
            let didWhat = taken == .call ? "Just calling" : "Checking"
            let what = taken == .call
                ? "a raise costs one more bet and is paid by every worse hand that is not folding to it anyway"
                : "a bet is small next to the pot, so the weaker hands pay it, and checking collects nothing from them"
            let headline = taken == .call ? "You had the best of it and only called." : "You had the best of it and didn't bet."
            return (headline,
                    "\(hand) on \(board) is in front about \(chance) of the time here. \(didWhat) costs you: at a limit table \(what). Raising for value is how this game is won.")
        case .missedFreeCard:
            return ("Raise it, and the next card is usually free.",
                    "You had \(hand) on \(board) and were last to act. \(drawNote(read)) A raise here costs one small bet, and about half the time they check to you on the turn, where the bet is twice the size — so the raise often buys you the turn card for less than calling would have cost, and now and then takes the pot on the spot. That is the free card, and it is the best trick a limit player has.")
        case .bluffedTooMuch:
            return ("A bet does not win this pot by itself.",
                    "With \(hand) on \(board) you win about \(chance) of the time. At a limit table a bet is small next to the pot, so almost nobody folds to it: a bluff has to be good when it is called, and this one was not.")
        default:
            return ("Worth a second look.",
                    "You had \(hand) on \(board), which wins about \(chance) of the time against what they are likely to have.")
        }
    }
}