← poker-tutor
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
}
}
}
}