Advertisement

← poker-tutor

Sources/PokerAI/OpponentModel.swift

6.7 KB Download file
import Foundation
import PokerCore

/// Running observations of one player. These are the same statistics a tracking program shows a
/// serious player, and phase 3 reuses them to build the user's leak profile — a leak is just a
/// statistic that sits outside the band a competent player's would.
public struct PlayerStats: Sendable {
    public var handsDealt = 0
    public var voluntaryPreflop = 0        // put money in before the flop by choice
    public var preflopRaises = 0
    public var threeBetOpportunities = 0
    public var threeBets = 0

    public var betsAndRaises = 0
    public var callsAndChecks = 0

    public var facedBetPostflop = 0
    public var foldedToBetPostflop = 0

    public var sawFlop = 0
    public var wentToShowdown = 0

    public init() {}

    /// Share of hands played rather than folded before the flop.
    public var vpip: Double? { rate(voluntaryPreflop, handsDealt, minimum: 15) }
    /// Share of hands raised before the flop.
    public var pfr: Double? { rate(preflopRaises, handsDealt, minimum: 15) }
    public var threeBetRate: Double? { rate(threeBets, threeBetOpportunities, minimum: 12) }
    /// Bets and raises per passive action — the standard measure of aggression.
    ///
    /// The sample gate counts *all* actions, not just the passive ones. Gating on passive actions
    /// alone means the most aggressive players in the game never accumulate enough of them to be
    /// measured, so the one statistic that identifies a maniac stays permanently unavailable for
    /// exactly the players it exists to catch.
    public var aggressionFactor: Double? {
        guard betsAndRaises + callsAndChecks >= 20 else { return nil }
        return Double(betsAndRaises) / Double(max(1, callsAndChecks))
    }
    public var foldToBet: Double? { rate(foldedToBetPostflop, facedBetPostflop, minimum: 12) }
    public var showdownRate: Double? { rate(wentToShowdown, sawFlop, minimum: 12) }

    private func rate(_ part: Int, _ whole: Int, minimum: Int) -> Double? {
        guard whole >= minimum else { return nil }      // never report a rate off a tiny sample
        return Double(part) / Double(whole)
    }

    /// Folds to postflop bets far more than a competent player would — bluff them.
    public var foldsTooMuch: Bool { (foldToBet ?? 0) > 0.62 }
    /// Almost never folds — value bet thin and stop bluffing.
    public var callsTooMuch: Bool { (foldToBet ?? 1) < 0.32 || (vpip ?? 0) > 0.50 }
    public var isPassive: Bool { (aggressionFactor ?? 1) < 0.5 }
    /// Bets and raises relentlessly. Their bets mean far less than a normal player's, so hands
    /// that would be an easy fold against anyone else become clear calls.
    public var isHyperAggressive: Bool { (aggressionFactor ?? 0) > 1.35 && (vpip ?? 0) > 0.40 }
}

/// Everything the table has observed about everyone at it.
///
/// Only high-skill opponents consult this; a calling station does not notice that you never bluff.
/// Feeding a completed hand in is how observations accumulate.
public final class OpponentModels: @unchecked Sendable {
    private var stats: [String: PlayerStats] = [:]

    public init() {}

    public subscript(id: String) -> PlayerStats {
        get { stats[id] ?? PlayerStats() }
        set { stats[id] = newValue }
    }

    public var all: [String: PlayerStats] { stats }

    public func reset() { stats.removeAll() }

    /// Records what everyone did in a finished hand.
    public func observe(_ engine: HandEngine) {
        let ids = Dictionary(uniqueKeysWithValues: engine.seats.map { ($0.seat, $0.playerID) })
        var dealtIn = Set<Int>()
        for seat in engine.seats where seat.status != .sittingOut { dealtIn.insert(seat.seat) }
        for seat in dealtIn {
            guard let id = ids[seat] else { continue }
            stats[id, default: PlayerStats()].handsDealt += 1
        }

        var voluntary = Set<Int>()
        var raisedPreflop = Set<Int>()
        var sawFlop = Set<Int>()
        var raisesSoFarPreflop = 0

        for record in engine.actions where !record.isForced {
            guard let id = ids[record.seat] else { continue }

            // Anyone acting preflop with exactly one raise in front of them had the chance to
            // three-bet, whether or not they took it.
            if record.street == .preflop && raisesSoFarPreflop == 1 {
                stats[id, default: PlayerStats()].threeBetOpportunities += 1
            }

            switch record.action {
            case .fold:
                if record.street != .preflop {
                    // Facing a bet is implied: you cannot fold when checking is free.
                    stats[id, default: PlayerStats()].facedBetPostflop += 1
                    stats[id, default: PlayerStats()].foldedToBetPostflop += 1
                }
            case .check:
                stats[id, default: PlayerStats()].callsAndChecks += 1
            case .call:
                stats[id, default: PlayerStats()].callsAndChecks += 1
                if record.street == .preflop { voluntary.insert(record.seat) }
                else { stats[id, default: PlayerStats()].facedBetPostflop += 1 }
            case .bet, .raise:
                stats[id, default: PlayerStats()].betsAndRaises += 1
                if record.street == .preflop {
                    voluntary.insert(record.seat)
                    raisedPreflop.insert(record.seat)
                    // A raise facing an existing raise is a three-bet.
                    if raisesSoFarPreflop >= 1 {
                        stats[id, default: PlayerStats()].threeBets += 1
                    }
                    raisesSoFarPreflop += 1
                } else {
                    stats[id, default: PlayerStats()].facedBetPostflop += 1
                }
            }

            if record.street != .preflop { sawFlop.insert(record.seat) }
        }

        for seat in voluntary {
            guard let id = ids[seat] else { continue }
            stats[id, default: PlayerStats()].voluntaryPreflop += 1
        }
        for seat in raisedPreflop {
            guard let id = ids[seat] else { continue }
            stats[id, default: PlayerStats()].preflopRaises += 1
        }
        for seat in sawFlop {
            guard let id = ids[seat] else { continue }
            stats[id, default: PlayerStats()].sawFlop += 1
        }
        if let result = engine.result, result.wentToShowdown {
            for seat in engine.seats where seat.isInHand {
                guard let id = ids[seat.seat] else { continue }
                stats[id, default: PlayerStats()].wentToShowdown += 1
            }
        }
    }
}