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