← poker-tutor
import Foundation
import PokerCore
import PokerAI
/// What a competent player does before the flop.
///
/// This is the coach's yardstick, and it is deliberately not a solver output. It is a sound,
/// conventional set of ranges of the kind a winning amateur plays — which is the standard worth
/// holding someone to when the goal is to stop losing money, not to beat professionals. Fractions
/// are shares of all starting hands, so 0.15 means the strongest fifteen percent.
public enum ReferenceRanges {
/// Hands worth opening with a raise when nobody has entered the pot.
public static func open(position: Position, players: Int, format: CoachFormat = .tournament) -> Double {
if format == .limit { return Limit.open(position: position, players: players) }
let base: Double
switch position {
case .underTheGun: base = 0.12
case .underTheGunPlus1: base = 0.14
case .middle: base = 0.16
case .lojack: base = 0.19
case .hijack: base = 0.22
case .cutoff: return 0.28 * PositionAdjustment.tableSizeMultiplier(playerCount: players)
case .button: return 0.42 * PositionAdjustment.tableSizeMultiplier(playerCount: players)
case .smallBlind: return 0.38 * PositionAdjustment.tableSizeMultiplier(playerCount: players)
case .bigBlind: return 0.0 // nobody to open against
}
return base * PositionAdjustment.tableSizeMultiplier(playerCount: players)
}
/// Hands worth re-raising with against a single opener.
public static func threeBet(from position: Position, versus opener: Position, players: Int,
format: CoachFormat = .tournament) -> Double {
if format == .limit { return Limit.threeBet(from: position, versus: opener, players: players) }
// Wider against a late opener, whose range is far weaker.
let openerWidth = open(position: opener, players: players)
let base = 0.045 + 0.10 * max(0, openerWidth - 0.15)
let positional = position == .button || position == .cutoff ? 1.35 : 1.0
let blind = position.isBlind ? 0.85 : 1.0 // out of position for the rest of the hand
return (base * positional * blind).clamped(to: 0.02...0.28)
}
/// Hands worth calling an open with, over and above the ones worth re-raising.
public static func callVersusOpen(from position: Position, versus opener: Position, players: Int,
format: CoachFormat = .tournament) -> Double {
if position == .bigBlind { return defendBigBlind(versus: opener, players: players, format: format) }
if format == .limit { return Limit.callVersusOpen(from: position, versus: opener, players: players) }
let openerWidth = open(position: opener, players: players)
let base = 0.06 + 0.22 * max(0, openerWidth - 0.15)
let inPosition = PositionAdjustment.isInPosition(position, versus: opener) ? 1.5 : 1.0
return (base * inPosition).clamped(to: 0.03...0.35)
}
/// The big blind is already half-invested and getting a price, so it continues far wider than
/// anywhere else. Folding it too often is the most common and most expensive amateur habit.
public static func defendBigBlind(versus opener: Position, players: Int,
format: CoachFormat = .tournament) -> Double {
if format == .limit { return Limit.defendBigBlind(versus: opener, players: players) }
let openerWidth = open(position: opener, players: players)
switch opener {
case .button, .smallBlind: return 0.55
case .cutoff: return 0.45
default: return (0.28 + openerWidth).clamped(to: 0.28...0.42)
}
}
public static func fourBet(from position: Position, players: Int, format: CoachFormat = .tournament) -> Double {
if format == .limit { return Limit.fourBet(from: position) }
return position.isBlind ? 0.022 : 0.030
}
public static func callVersusThreeBet(from position: Position, players: Int,
format: CoachFormat = .tournament) -> Double {
if format == .limit { return Limit.callVersusThreeBet(from: position) }
return position.isBlind ? 0.045 : 0.070
}
/// What a sound, conventional fixed-limit player does before the flop.
///
/// Everything is wider than at no-limit, and for one reason: a raise is one extra small bet,
/// so nobody can be priced out of a pot and nobody can be made to risk a stack for a mistake.
/// The money is made by having the better hand more often over thousands of small pots, so a
/// hand that plays well multiway — a pair, a suited ace, two big cards — is worth a call
/// behind limpers where it would be a fold at no-limit; and a hand worth playing is worth
/// raising with, because a raise for value collects from everyone still to act. The one thing
/// the limit player does less is cold-call a raise: two bets cold with a hand that is not
/// good enough to re-raise is the classic way to be second best for a whole pot.
public enum Limit {
public static func open(position: Position, players: Int) -> Double {
let table = PositionAdjustment.tableSizeMultiplier(playerCount: players)
let base: Double
switch position {
case .underTheGun: base = 0.16
case .underTheGunPlus1: base = 0.18
case .middle: base = 0.20
case .lojack: base = 0.24
case .hijack: base = 0.28
case .cutoff: base = 0.36
case .button: base = 0.50
case .smallBlind: base = 0.42
case .bigBlind: return 0.0
}
return (base * table).clamped(to: 0.05...0.95)
}
/// Re-raising is for value: the top of the deck against a wide opener, less against a
/// tight one. Isolating a raiser with a marginal hand wins nothing at limit, because the
/// raise cannot make anyone fold.
public static func threeBet(from position: Position, versus opener: Position, players: Int) -> Double {
let openerWidth = open(position: opener, players: players)
let base = 0.07 + 0.12 * max(0, openerWidth - 0.15)
return base.clamped(to: 0.04...0.30)
}
/// Cold-calling — two bets with nobody else in — wants a hand that plays: pairs, big
/// suited cards, a suited ace. Behind callers the pot is bigger and the price better.
public static func callVersusOpen(from position: Position, versus opener: Position, players: Int) -> Double {
let openerWidth = open(position: opener, players: players)
let base = 0.06 + 0.12 * max(0, openerWidth - 0.15)
let inPosition = PositionAdjustment.isInPosition(position, versus: opener) ? 1.4 : 1.0
return (base * inPosition).clamped(to: 0.04...0.25)
}
/// One more small bet to see a flop with money already in: three and a half to one or
/// better, so the big blind carries on with most of what it is dealt.
public static func defendBigBlind(versus opener: Position, players: Int) -> Double {
let openerWidth = open(position: opener, players: players)
switch opener {
case .button, .smallBlind: return 0.70
case .cutoff: return 0.60
default: return (0.45 + openerWidth).clamped(to: 0.45...0.60)
}
}
/// The third and fourth bets are for the best hands only.
public static func fourBet(from position: Position) -> Double { position.isBlind ? 0.03 : 0.04 }
/// Calling a re-raise is one more small bet in a pot that already holds five or six;
/// far cheaper than at no-limit, so far wider.
public static func callVersusThreeBet(from position: Position) -> Double { position.isBlind ? 0.09 : 0.12 }
}
/// Below this many big blinds, raising to anything less than all in only invites trouble:
/// the stack is short enough that any raise commits it anyway.
public static let pushFoldDepth: Double = 12
/// Hands worth moving all in with, by stack depth. Shallower stacks shove wider because the
/// blinds and antes are worth more relative to the risk.
public static func shove(depth: Double, position: Position, players: Int, facingRaise: Bool) -> Double {
let base: Double
switch depth {
case ..<3: base = 0.62
case ..<5: base = 0.42
case ..<8: base = 0.28
case ..<10: base = 0.20
default: base = 0.14
}
if facingRaise {
// Calling off or re-shoving into a raise needs a real hand.
return (base * 0.30).clamped(to: 0.02...0.35)
}
let positional = PositionAdjustment.openMultiplier(position)
return (base * positional * PositionAdjustment.tableSizeMultiplier(playerCount: players)).clamped(to: 0.04...0.92)
}
/// The opening raise a competent player makes, in chips.
public static func openSize(bigBlind: Int, limpers: Int, players: Int) -> Int {
let multiple = (players <= 3 ? 2.5 : 2.3) + 1.0 * Double(limpers)
return Int((Double(bigBlind) * multiple).rounded())
}
}