Advertisement

← poker-tutor

Sources/PokerAI/Bot.swift

23.9 KB Download file
import Foundation
import PokerCore

/// A computer opponent: a style, a skill level, and its own random source so any session
/// replays identically.
///
/// The bot only ever sees a `DecisionView`, so it cannot read anyone's cards. Everything it
/// concludes about opponents comes from watching them act, exactly as a person would.
public struct Bot: Sendable {
    public let id: String
    public let personality: Personality
    public let skill: Int
    public var parameters: BotParameters
    /// Cash tables only: the bot calls one raise with a small pair or connected suited cards when
    /// the money behind pays for it. Off at a tournament, so nothing measured there moves.
    public var playsImpliedOdds = false
    private var rng: SeededRandom

    public init(id: String, personality: Personality, skill: Int, seed: UInt64) {
        self.id = id
        self.personality = personality
        self.skill = skill.clamped(to: personality.skillRange)
        self.parameters = personality.parameters(skill: self.skill)
        self.rng = SeededRandom(seed: seed)
    }

    /// Same player, fresh randomness. Copies the parameters as they are rather than rebuilding
    /// them from personality and skill, so a hand-tuned bot survives being re-seeded.
    public init(copying other: Bot, seed: UInt64) {
        self.id = other.id
        self.personality = other.personality
        self.skill = other.skill
        self.parameters = other.parameters
        self.rng = SeededRandom(seed: seed)
    }

    public var label: String { "\(personality.name) (\(skill))" }

    private mutating func roll() -> Double { Double(rng.next() % 10_000) / 10_000.0 }
    private mutating func chance(_ probability: Double) -> Bool { roll() < probability }

    /// Blurs a hand-strength judgement by this player's read error.
    ///
    /// This is what a weak player actually does wrong. They are not running the wrong arithmetic
    /// on the right number — they are running reasonable arithmetic on a number that is off,
    /// because they thought middle pair was good, or that their flush draw was already a hand.
    /// Every other parameter here describes a style; this one describes being worse at poker,
    /// and it is the only one that costs money no matter who is sitting across the table.
    private mutating func misread(_ value: Double) -> Double {
        guard parameters.readError > 0 else { return value }
        return (value + (roll() - 0.5) * 2 * parameters.readError).clamped(to: 0...1)
    }

    // MARK: - Entry point

    public mutating func decide(_ view: DecisionView, models: OpponentModels? = nil) -> PlayerAction {
        let action = view.street == .preflop
            ? decidePreflop(view, models: models)
            : decidePostflop(view, models: models)
        return legalise(action, view)
    }

    /// Rounds a size to whole chips, and to something the engine will accept. Both bots and the
    /// player bet in the same denominations — an opponent pushing out 437 next to a chip tray that
    /// cannot make 437 looks wrong, because it is.
    private func chips(_ amount: Int, _ view: DecisionView) -> Int {
        ChipRounding.round(amount,
                           bigBlind: view.level.bigBlind,
                           minimum: view.legal.minRaiseTo,
                           maximum: view.legal.maxRaiseTo)
    }

    /// Last line of defence: never return something the engine would reject.
    private func legalise(_ action: PlayerAction, _ view: DecisionView) -> PlayerAction {
        let legal = view.legal
        switch action {
        case .check where !legal.canCheck:
            return .fold
        case .call where !legal.canCall:
            return legal.canCheck ? .check : .fold
        case .fold where legal.canCheck:
            return .check                               // folding for free is never right
        case .bet(let to):
            guard legal.canBet else { return legal.canCheck ? .check : .call }
            return .bet(to: chips(to, view))
        case .raise(let to):
            guard legal.canRaise else { return legal.canCall ? .call : (legal.canCheck ? .check : .fold) }
            return .raise(to: chips(to, view))
        default:
            return action
        }
    }

    // MARK: - Before the flop

    private mutating func decidePreflop(_ view: DecisionView, models: OpponentModels?) -> PlayerAction {
        // Weak players overrate weak aces and underrate suited connectors; this is that, in one line.
        let percentile = misread(PreflopTable.percentile(view.holeCards))
        let raises = view.preflopRaiseCount

        // Short stacks stop raising and start moving in. Only players good enough to know this
        // do it — pushFoldDepth is zero for the recreational styles.
        if parameters.pushFoldDepth > 0, view.effectiveStackInBB <= parameters.pushFoldDepth, view.legal.canBetOrRaise {
            let range = shoveRange(depth: view.effectiveStackInBB, position: view.position, facingRaise: raises > 0)
            if percentile <= range {
                return view.legal.canBet ? .bet(to: view.legal.maxRaiseTo) : .raise(to: view.legal.maxRaiseTo)
            }
            if raises > 0 { return view.legal.canCheck ? .check : .fold }
        }

        switch raises {
        case 0: return openLimpOrFold(view, percentile: percentile)
        case 1: return versusOneRaise(view, percentile: percentile, models: models)
        default: return versusReraise(view, percentile: percentile, models: models)
        }
    }

    /// Fixed-limit is played wider by everybody, and for one reason: a raise is one extra small
    /// bet, so nobody is priced out and nobody is risking a stack. See `ReferenceRanges.Limit`.
    private func limitTable(_ view: DecisionView) -> Bool { view.structure == .fixedLimit }

    private mutating func openLimpOrFold(_ view: DecisionView, percentile: Double) -> PlayerAction {
        let table = PositionAdjustment.tableSizeMultiplier(playerCount: view.playersDealtIn)
        let limit = limitTable(view)
        let range = (parameters.openRange * PositionAdjustment.openMultiplier(view.position) * table
                     * (limit ? 1.35 : 1)).clamped(to: 0.02...0.95)
        let limpers = view.seats.filter { $0.isInHand && $0.seat != view.hero && $0.committedThisRound >= view.bigBlind && $0.position != .bigBlind }.count

        guard percentile <= range else {
            return view.legal.canCheck ? .check : .fold
        }

        // Passive players limp in rather than raise, which is the single most common amateur leak.
        if view.legal.canCall, chance(parameters.limpTendency), percentile > range * 0.25 {
            return .call
        }
        // At a limit table anybody calls behind callers with the hands that play in a crowd; only
        // the top of the range raises to thin nobody out.
        if limit, limpers > 0, view.legal.canCall, percentile > range * 0.35 {
            return .call
        }

        guard view.legal.canBetOrRaise else { return view.legal.canCheck ? .check : .call }
        // Standard open, plus a big blind for every limper already in.
        let openSize = Double(view.bigBlind) * (2.2 + 0.9 * Double(limpers))
        let to = jitter(openSize)
        return view.legal.canBet ? .bet(to: to) : .raise(to: to)
    }

    private mutating func versusOneRaise(_ view: DecisionView, percentile: Double, models: OpponentModels?) -> PlayerAction {
        let table = PositionAdjustment.tableSizeMultiplier(playerCount: view.playersDealtIn)
        let limit = limitTable(view)
        var threeBet = parameters.threeBetRange * table * (limit ? 1.3 : 1)
        var call = (view.position == .bigBlind ? parameters.blindDefenseRange : parameters.callVsRaiseRange) * table
            * (limit ? (view.position == .bigBlind ? 1.8 : 1.4) : 1)

        // A good player widens against someone who opens everything and tightens against a nit.
        if parameters.adaptation > 0, let models, let raiser = view.lastAggressor,
           let id = view.seats.first(where: { $0.seat == raiser })?.playerID {
            let stats = models[id]
            if let pfr = stats.pfr {
                let looseness = (pfr - 0.20) / 0.20                  // 0 at a normal 20% open
                threeBet *= (1 + looseness * parameters.adaptation).clamped(to: 0.5...3.0)
                call *= (1 + looseness * parameters.adaptation * 0.6).clamped(to: 0.6...2.0)
            }
            // A ceiling on where adaptation can go. Widening against a loose opener is right;
            // three-betting a third of all hands because the multiplier compounded is not, and
            // it loses more than the original leak it was correcting. The ceiling scales with the
            // table, because heads-up a "wide" defence really is most of the deck.
            threeBet = min(threeBet, 0.10 * table)
            call = min(call, 0.28 * table)
        }

        if percentile <= threeBet, view.legal.canRaise {
            let target = Double(view.legal.callCost + view.pot) * 0.9 + Double(view.heroSeat.committedThisRound)
            return .raise(to: jitter(max(Double(view.legal.minRaiseTo), target)))
        }
        if percentile <= call, view.legal.canCall {
            // Calling off a big chunk of the stack to see a flop is how short stacks die.
            let priceIsRight = limit || Double(view.toCall) <= Double(view.effectiveStack) * 0.16 || percentile <= threeBet * 2
            if priceIsRight { return .call }
        }
        // A cash player calls one raise with a small pair or two cards that fit together when the
        // money behind pays for it, whatever the chart says about the hand on its own.
        if playsImpliedOdds, view.legal.canCall, Bot.moneyBehindPays(view) { return .call }
        return view.legal.canCheck ? .check : .fold
    }

    /// Whether a small pair or two connected suited cards is paid for by the money behind: the
    /// pot and what is left to win against the price of the call — twenty times for a pair,
    /// twenty-five for connectors. The same rule the cash coach judges by.
    static func moneyBehindPays(_ view: DecisionView) -> Bool {
        let hand = HandClass(view.holeCards)
        let needed: Double
        if hand.isPair, hand.high <= .nine { needed = 20 }
        else if hand.suited, hand.gap <= 1, hand.high <= .jack, hand.low >= .five { needed = 25 }
        else { return false }
        guard view.toCall > 0 else { return false }
        // The seat is part of the rule (the same rule the cash coach judges by): in position on
        // the raiser with nobody but the blinds still to speak, or, for a pair, with at most two
        // players behind from anywhere.
        let stillToAct = view.playersStillToAct
        let inPosition: Bool = {
            guard let raiser = view.lastAggressor,
                  let opener = view.seats.first(where: { $0.seat == raiser })?.position else { return false }
            return PositionAdjustment.isInPosition(view.position, versus: opener)
        }()
        if hand.isPair {
            guard stillToAct <= 2 || inPosition else { return false }
        } else {
            guard inPosition, stillToAct <= 2 else { return false }
        }
        // The money behind is the raiser's, not the deepest stack at the table.
        let effective = view.lastAggressor.map { view.effectiveStack(against: $0) } ?? view.effectiveStack
        let behind = Double(max(0, effective - view.toCall))
        return (behind + Double(view.pot)) / Double(view.toCall) >= needed
    }

    private mutating func versusReraise(_ view: DecisionView, percentile: Double, models: OpponentModels?) -> PlayerAction {
        let table = PositionAdjustment.tableSizeMultiplier(playerCount: view.playersDealtIn)
        var fourBet = parameters.threeBetRange * 0.32 * table
        // One more small bet to see a flop in a pot already holding five: called far wider at limit.
        var callRange = parameters.threeBetRange * (1 - parameters.foldToThreeBet) * 2.4 * table
            * (limitTable(view) ? 2.4 : 1)

        // Facing a re-raise used to ignore who was doing the re-raising, so a player who
        // three-bet a fifth of all hands got the same respect as one who only ever did it with
        // aces. Folding ninety percent of hands to someone re-raising with a fifth of theirs is
        // a standing invitation, whatever it feels like from the inside.
        if parameters.adaptation > 0, let models, let raiser = view.lastAggressor,
           let id = view.seats.first(where: { $0.seat == raiser })?.playerID {
            let stats = models[id]
            if let rate = stats.threeBetRate {
                let looseness = (rate - 0.06) / 0.06                 // 0 at a normal 6% three-bet
                fourBet *= (1 + looseness * parameters.adaptation).clamped(to: 0.5...4.0)
                callRange *= (1 + looseness * parameters.adaptation * 0.8).clamped(to: 0.6...3.5)
            }
            fourBet = min(fourBet, 0.07 * table)
            callRange = min(callRange, 0.15 * table)
        }

        if percentile <= fourBet, view.legal.canRaise {
            let target = Double(view.legal.callCost + view.pot) * 0.75 + Double(view.heroSeat.committedThisRound)
            return .raise(to: jitter(max(Double(view.legal.minRaiseTo), target)))
        }
        if percentile <= callRange, view.legal.canCall { return .call }
        return view.legal.canCheck ? .check : .fold
    }

    /// How wide to move all in, by stack depth. Shallower stacks shove wider because there is
    /// more dead money relative to the risk.
    private func shoveRange(depth: Double, position: Position, facingRaise: Bool) -> Double {
        let base: Double
        switch depth {
        case ..<3: base = 0.75
        case ..<5: base = 0.50
        case ..<8: base = 0.34
        case ..<11: base = 0.24
        default: base = 0.16
        }
        let positional = base * PositionAdjustment.openMultiplier(position)
        return facingRaise ? positional * 0.25 : positional.clamped(to: 0.03...0.95)
    }

    // MARK: - After the flop

    private mutating func decidePostflop(_ view: DecisionView, models: OpponentModels?) -> PlayerAction {
        let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
        var valueThreshold = parameters.valueThreshold
        var bluffFrequency = parameters.bluffFrequency
        var callSlack = parameters.callSlack
        var raiseThreshold = parameters.raiseThreshold

        // Exploit what has actually been observed, if this opponent is good enough to notice.
        if parameters.adaptation > 0, let models {
            let live = view.opponents.map { models[$0.playerID] }
            if live.contains(where: { $0.foldsTooMuch }) {
                bluffFrequency += 0.25 * parameters.adaptation
            }
            if live.contains(where: { $0.callsTooMuch }) {
                bluffFrequency -= 0.30 * parameters.adaptation      // never bluff someone who cannot fold
                valueThreshold -= 0.07 * parameters.adaptation      // but bet thinner for value
                callSlack -= 0.03 * parameters.adaptation
            }
            // Against someone who bets everything, a bet carries almost no information. Folding
            // middle pair to a player who fires at every pot is how a solid player loses to a
            // maniac: each fold is correct against a normal range and wrong against this one.
            if live.contains(where: { $0.isHyperAggressive }) {
                callSlack += 0.16 * parameters.adaptation
                bluffFrequency -= 0.15 * parameters.adaptation
                valueThreshold -= 0.05 * parameters.adaptation
                raiseThreshold -= 0.10 * parameters.adaptation   // punish them, do not just call
            }
        }

        let strength = misread(read.showdownValue)
        var total = misread(read.total)

        // A bet from someone who bets everything says almost nothing about their hand. Hand
        // strength here is measured against a random holding; against a player whose betting
        // range really is close to random, that estimate is too pessimistic, because the usual
        // assumption that a bet means something no longer holds.
        if parameters.adaptation > 0, let models,
           view.opponents.contains(where: { models[$0.playerID].isHyperAggressive }) {
            total += (1 - total) * 0.35 * parameters.adaptation
        }

        // At a limit table a bet folds almost nobody, so betting is for value and bluffing is
        // rare; and the price of a call is nearly always right, so draws and pairs keep going.
        if limitTable(view) {
            valueThreshold -= 0.10
            bluffFrequency *= 0.4
            // Top pair raises a flop bet at limit; the players who know that are the ones who do
            // not give bets away calling, so the drop is bigger for them.
            raiseThreshold -= parameters.callSlack <= 0 ? 0.20 : 0.12
        }

        if view.legal.canCheck {
            return checkOrBet(view, read: read, strength: strength, total: total,
                              valueThreshold: valueThreshold, bluffFrequency: bluffFrequency)
        }
        return foldCallOrRaise(view, read: read, strength: strength, total: total,
                               callSlack: callSlack, bluffFrequency: bluffFrequency, raiseThreshold: raiseThreshold)
    }

    private mutating func checkOrBet(_ view: DecisionView, read: HandAssessment, strength: Double, total: Double,
                                     valueThreshold: Double, bluffFrequency: Double) -> PlayerAction {
        guard view.legal.canBet else { return .check }

        // Value.
        if strength >= valueThreshold {
            var frequency = view.street == .flop && view.heroWasPreflopAggressor ? max(parameters.cbetFrequency, 0.8) : 0.85
            // A limit bet is small and gets called, so a likely-best hand bets nearly always.
            if limitTable(view) { frequency = max(frequency, 0.93) }
            if chance(frequency) { return bet(view, fraction: parameters.betSizing) }
            return .check
        }

        // Semi-bluff: betting a draw wins the pot now and still has outs when it does not.
        let drawStrength = total - strength
        if drawStrength >= 0.16, chance(bluffFrequency + 0.30) {
            return bet(view, fraction: parameters.betSizing * 0.95)
        }

        // Continuation bet with nothing, which is only profitable at some frequency.
        if view.heroWasPreflopAggressor, view.street == .flop, chance(parameters.cbetFrequency * 0.65) {
            return bet(view, fraction: parameters.betSizing * 0.85)
        }
        if chance(bluffFrequency * (1 - strength)) {
            return bet(view, fraction: parameters.betSizing * 0.9)
        }
        return .check
    }

    private mutating func foldCallOrRaise(_ view: DecisionView, read: HandAssessment, strength: Double, total: Double,
                                          callSlack: Double, bluffFrequency: Double, raiseThreshold: Double) -> PlayerAction {
        let required = view.potOdds
        let limit = limitTable(view)

        // Raise for value. At a limit table the raise is what the best hand is for — nobody folds
        // to it, so every extra bet is paid — and the good players make it far more often.
        if strength >= raiseThreshold, view.legal.canRaise,
           chance(limit ? min(0.97, parameters.aggression + 0.35) : parameters.aggression) {
            return raise(view, fraction: parameters.betSizing)
        }
        // The free card: last to act on the flop with a real draw, a limit player raises one
        // small bet and is usually checked to on the turn. The good players know the trick.
        if limit, view.street == .flop, read.outs >= 8, view.legal.canRaise,
           PositionAdjustment.actsLast(view.position, among: view.opponents.compactMap(\.position)),
           chance(parameters.aggression * 0.9 + 0.1) {
            return raise(view, fraction: parameters.betSizing)
        }
        // The river call-down: one bet against a pot of many, with any pair. The habit of the
        // careful player is to fold here, and the careful player is the one who loses at limit.
        if limit, view.street == .river, view.legal.canCall, read.category >= .underPair,
           Double(view.pot) / Double(max(1, view.toCall)) >= 6, chance(0.85 + parameters.callSlack) {
            return .call
        }
        // Raise as a semi-bluff with a big draw.
        if (total - strength) >= 0.24, view.legal.canRaise, chance(bluffFrequency * 0.8) {
            return raise(view, fraction: parameters.betSizing * 0.9)
        }
        // At a limit table the pot is offering the price for nearly any draw, and a player who
        // folds four outs to one small bet in a pot of six is the player the game is built to
        // beat. Only players good enough to count the pot do this — the slack is theirs.
        if limit, view.legal.canCall, read.outs >= 4, parameters.callSlack >= -0.02,
           Double(view.pot) / Double(max(1, view.toCall)) >= 5 {
            return .call
        }
        // Call if the price is right. `callSlack` is what makes a station a station: it is the
        // amount of equity they are willing to be short by. A limit price is a real price, so a
        // hand a little short of it still calls there.
        // On the river with no pair, the limit price does not help: the hand beats only a bluff.
        let limitSlack = limit && !(view.street == .river && read.category < .underPair) ? 0.05 : 0
        if total + callSlack + limitSlack >= required, view.legal.canCall {
            // Never call off a stack on a marginal hand just because the odds look close.
            let allInRisk = Double(view.toCall) >= Double(view.heroStack) * 0.85
            if !allInRisk || total >= required + 0.08 { return .call }
        }
        return .fold
    }

    // MARK: - Sizing

    /// Adds personality-appropriate imprecision. Weak players size erratically, and the occasional
    /// min-bet is a real tell rather than a bug.
    private mutating func jitter(_ amount: Double) -> Int {
        let noise = parameters.sizingNoise
        let factor = 1 + (roll() - 0.5) * noise * 0.8
        return max(1, Int((amount * factor).rounded()))
    }

    private mutating func bet(_ view: DecisionView, fraction: Double) -> PlayerAction {
        if parameters.sizingNoise > 0.5, chance(parameters.sizingNoise * 0.25) {
            return .bet(to: view.legal.minRaiseTo)          // the min-bet tell
        }
        let target = jitter(Double(view.pot) * fraction)
        return .bet(to: target.clamped(to: view.legal.minRaiseTo...view.legal.maxRaiseTo))
    }

    private mutating func raise(_ view: DecisionView, fraction: Double) -> PlayerAction {
        let potAfterCall = Double(view.pot + view.toCall)
        let target = Double(view.heroSeat.committedThisRound + view.toCall) + potAfterCall * fraction
        let jittered = jitter(target)
        return .raise(to: jittered.clamped(to: view.legal.minRaiseTo...view.legal.maxRaiseTo))
    }
}

public extension DecisionView {
    /// Did this seat make the last raise before the flop? That is who is expected to keep betting.
    var heroWasPreflopAggressor: Bool {
        let preflop = actions.filter { $0.street == .preflop && !$0.isForced }
        let lastRaise = preflop.last {
            if case .raise = $0.action { return true }
            if case .bet = $0.action { return true }
            return false
        }
        return lastRaise?.seat == hero
    }
}