← poker-tutor
import Foundation
import PokerCore
/// Every knob that defines how an opponent plays. Fractions are of all starting hands unless
/// noted; thresholds are on the 0-1 hand-strength scale from `HandReader`.
public struct BotParameters: Sendable {
public var openRange: Double // opened from a neutral seat, before position adjusts it
public var threeBetRange: Double
public var callVsRaiseRange: Double
public var blindDefenseRange: Double
public var foldToThreeBet: Double
public var cbetFrequency: Double
public var bluffFrequency: Double
public var aggression: Double // how often a strong hand raises rather than calls
public var callSlack: Double // equity below the correct price they will still call with
public var valueThreshold: Double // strength needed to bet for value
public var raiseThreshold: Double // strength needed to raise a bet
public var betSizing: Double // preferred bet as a fraction of the pot
public var sizingNoise: Double // 0 disciplined, 1 erratic
public var limpTendency: Double // chance of limping rather than raising or folding
public var pushFoldDepth: Double // big blinds at which they switch to shove-or-fold; 0 never
public var adaptation: Double // how much they exploit what they have seen you do
/// How badly they misjudge the strength of their own hand. Zero is a perfect read; a quarter
/// means routinely treating middle pair as top pair or a good hand as a marginal one. This is
/// the one thing on this list that is unambiguously a mistake at every table, in every style,
/// which is what makes it the backbone of the skill dial.
public var readError: Double
/// Blends two parameter sets. `t` of 0 is all `a`, 1 is all `b`.
static func blend(_ a: BotParameters, _ b: BotParameters, _ t: Double) -> BotParameters {
func mix(_ x: Double, _ y: Double) -> Double { x + (y - x) * t }
return BotParameters(
openRange: mix(a.openRange, b.openRange),
threeBetRange: mix(a.threeBetRange, b.threeBetRange),
callVsRaiseRange: mix(a.callVsRaiseRange, b.callVsRaiseRange),
blindDefenseRange: mix(a.blindDefenseRange, b.blindDefenseRange),
foldToThreeBet: mix(a.foldToThreeBet, b.foldToThreeBet),
cbetFrequency: mix(a.cbetFrequency, b.cbetFrequency),
bluffFrequency: mix(a.bluffFrequency, b.bluffFrequency),
aggression: mix(a.aggression, b.aggression),
callSlack: mix(a.callSlack, b.callSlack),
valueThreshold: mix(a.valueThreshold, b.valueThreshold),
raiseThreshold: mix(a.raiseThreshold, b.raiseThreshold),
betSizing: mix(a.betSizing, b.betSizing),
sizingNoise: mix(a.sizingNoise, b.sizingNoise),
limpTendency: mix(a.limpTendency, b.limpTendency),
pushFoldDepth: mix(a.pushFoldDepth, b.pushFoldDepth),
adaptation: mix(a.adaptation, b.adaptation),
readError: mix(a.readError, b.readError)
)
}
}
/// The kinds of players sitting at the table.
///
/// Personality is *style*; skill is how well the style is executed. The two are separate on
/// purpose, but they are not independent: a calling station is a losing style by definition, so
/// it only exists at low skill. That is why each personality declares the skill band it lives in,
/// and why a table gets harder in two ways at once — the opponents get better at what they do, and
/// the loose recreational players are replaced by competent ones.
public enum Personality: String, CaseIterable, Sendable, Codable {
case callingStation
case nit
case loosePassive
case maniac
case tightAggressive
case looseAggressive
case solidRegular
case trickyRegular
public var name: String {
switch self {
case .callingStation: return "Calling station"
case .nit: return "Nit"
case .loosePassive: return "Loose passive"
case .maniac: return "Maniac"
case .tightAggressive: return "Tight aggressive"
case .looseAggressive: return "Loose aggressive"
case .solidRegular: return "Solid regular"
case .trickyRegular: return "Tricky regular"
}
}
/// "3 calling stations", "1 maniac" — how a cast is listed when a room is being described.
///
/// Written out rather than pluralised by adding an s, because half of these names are
/// adjectives: "3 loose passives" is not English, and "3 loose passive players" is.
public func counted(_ count: Int) -> String {
let one = count == 1
let word: String
switch self {
case .callingStation: word = one ? "calling station" : "calling stations"
case .nit: word = one ? "nit" : "nits"
case .maniac: word = one ? "maniac" : "maniacs"
case .loosePassive: word = one ? "loose passive player" : "loose passive players"
case .tightAggressive: word = one ? "tight aggressive player" : "tight aggressive players"
case .looseAggressive: word = one ? "loose aggressive player" : "loose aggressive players"
case .solidRegular: word = one ? "solid regular" : "solid regulars"
case .trickyRegular: word = one ? "tricky regular" : "tricky regulars"
}
return "\(count) \(word)"
}
/// Plain English, for the table and for the coach's explanations.
public var summary: String {
switch self {
case .callingStation: return "Calls far too much and almost never raises. You cannot bluff them, but you can bet every good hand."
case .nit: return "Plays only premium hands. When they put money in, believe them — and steal their blinds relentlessly."
case .loosePassive: return "Plays too many hands and rarely raises. Lets you see cheap cards and pays you off."
case .maniac: return "Raises constantly with anything. Wait for a hand and let them bet into you."
case .tightAggressive: return "Patient, then aggressive. Solid but predictable — their range is readable from their line."
case .looseAggressive: return "Applies constant pressure with a wide range. Hard to play against out of position."
case .solidRegular: return "Balanced and steady. Few obvious mistakes and few obvious tells."
case .trickyRegular: return "Balanced, and watching you. Punishes any pattern you fall into."
}
}
/// The skill band this style occupies. Losing styles do not exist at high skill.
public var skillRange: ClosedRange<Int> {
switch self {
case .callingStation: return 0...35
case .loosePassive: return 5...40
case .maniac: return 5...45
case .nit: return 10...50
case .tightAggressive: return 30...80
case .looseAggressive: return 40...85
case .solidRegular: return 45...95
case .trickyRegular: return 60...100
}
}
/// How this style plays at the bottom of its skill band.
var novice: BotParameters {
switch self {
case .callingStation:
return BotParameters(openRange: 0.48, threeBetRange: 0.010, callVsRaiseRange: 0.55, blindDefenseRange: 0.70,
foldToThreeBet: 0.25, cbetFrequency: 0.20, bluffFrequency: 0.02, aggression: 0.10,
callSlack: 0.30, valueThreshold: 0.72, raiseThreshold: 0.88, betSizing: 0.35,
sizingNoise: 0.8, limpTendency: 0.75, pushFoldDepth: 0, adaptation: 0, readError: 0.26)
case .loosePassive:
return BotParameters(openRange: 0.50, threeBetRange: 0.015, callVsRaiseRange: 0.42, blindDefenseRange: 0.60,
foldToThreeBet: 0.45, cbetFrequency: 0.28, bluffFrequency: 0.05, aggression: 0.18,
callSlack: 0.18, valueThreshold: 0.68, raiseThreshold: 0.84, betSizing: 0.38,
sizingNoise: 0.7, limpTendency: 0.70, pushFoldDepth: 0, adaptation: 0, readError: 0.24)
case .maniac:
return BotParameters(openRange: 0.62, threeBetRange: 0.22, callVsRaiseRange: 0.35, blindDefenseRange: 0.65,
foldToThreeBet: 0.30, cbetFrequency: 0.85, bluffFrequency: 0.55, aggression: 0.90,
callSlack: 0.12, valueThreshold: 0.35, raiseThreshold: 0.50, betSizing: 0.90,
sizingNoise: 0.9, limpTendency: 0.05, pushFoldDepth: 0, adaptation: 0, readError: 0.25)
case .nit:
return BotParameters(openRange: 0.075, threeBetRange: 0.020, callVsRaiseRange: 0.045, blindDefenseRange: 0.09,
foldToThreeBet: 0.80, cbetFrequency: 0.45, bluffFrequency: 0.04, aggression: 0.45,
callSlack: -0.10, valueThreshold: 0.66, raiseThreshold: 0.80, betSizing: 0.55,
sizingNoise: 0.4, limpTendency: 0.20, pushFoldDepth: 0, adaptation: 0, readError: 0.19)
case .tightAggressive:
return BotParameters(openRange: 0.15, threeBetRange: 0.040, callVsRaiseRange: 0.075, blindDefenseRange: 0.20,
foldToThreeBet: 0.65, cbetFrequency: 0.70, bluffFrequency: 0.12, aggression: 0.60,
callSlack: -0.04, valueThreshold: 0.58, raiseThreshold: 0.75, betSizing: 0.60,
sizingNoise: 0.35, limpTendency: 0.05, pushFoldDepth: 8, adaptation: 0.05, readError: 0.16)
case .looseAggressive:
return BotParameters(openRange: 0.30, threeBetRange: 0.090, callVsRaiseRange: 0.13, blindDefenseRange: 0.34,
foldToThreeBet: 0.50, cbetFrequency: 0.78, bluffFrequency: 0.28, aggression: 0.75,
callSlack: 0.02, valueThreshold: 0.52, raiseThreshold: 0.70, betSizing: 0.68,
sizingNoise: 0.30, limpTendency: 0.02, pushFoldDepth: 9, adaptation: 0.10, readError: 0.15)
case .solidRegular:
return BotParameters(openRange: 0.20, threeBetRange: 0.055, callVsRaiseRange: 0.10, blindDefenseRange: 0.28,
foldToThreeBet: 0.58, cbetFrequency: 0.65, bluffFrequency: 0.18, aggression: 0.62,
callSlack: -0.02, valueThreshold: 0.55, raiseThreshold: 0.72, betSizing: 0.62,
sizingNoise: 0.25, limpTendency: 0.02, pushFoldDepth: 10, adaptation: 0.15, readError: 0.14)
case .trickyRegular:
return BotParameters(openRange: 0.23, threeBetRange: 0.070, callVsRaiseRange: 0.11, blindDefenseRange: 0.32,
foldToThreeBet: 0.52, cbetFrequency: 0.62, bluffFrequency: 0.22, aggression: 0.65,
callSlack: 0.00, valueThreshold: 0.53, raiseThreshold: 0.71, betSizing: 0.60,
sizingNoise: 0.20, limpTendency: 0.01, pushFoldDepth: 11, adaptation: 0.35, readError: 0.12)
}
}
/// How this style plays at the top of its skill band. The leaks shrink; the style remains.
///
/// Skill only ever removes *mistakes* — erratic bet sizing, calling at the wrong price,
/// limping instead of raising, not knowing when a stack is short enough to move in, failing
/// to notice what an opponent is doing. It deliberately does not widen ranges or add bluffs.
/// Those look like sophistication and measure as losses: a wider range is only profitable if
/// you can play the extra hands well after the flop, so handing them to a bot whose postflop
/// game is unchanged just donates money. Tuning it the other way produced a skill dial that
/// ran backwards — the "expert" version of every competent style lost to its own novice.
var expert: BotParameters {
var p = novice
switch self {
case .callingStation:
p.callSlack = 0.16; p.valueThreshold = 0.62; p.aggression = 0.25; p.limpTendency = 0.55
p.sizingNoise = 0.55; p.bluffFrequency = 0.06; p.openRange = 0.42; p.readError = 0.15
case .loosePassive:
p.callSlack = 0.08; p.valueThreshold = 0.60; p.aggression = 0.35; p.limpTendency = 0.45
p.sizingNoise = 0.45; p.bluffFrequency = 0.10; p.openRange = 0.44; p.threeBetRange = 0.030; p.readError = 0.13
case .maniac:
p.bluffFrequency = 0.38; p.valueThreshold = 0.46; p.raiseThreshold = 0.62; p.betSizing = 0.75
p.sizingNoise = 0.55; p.openRange = 0.48; p.foldToThreeBet = 0.42; p.callSlack = 0.05; p.readError = 0.12
case .nit:
p.openRange = 0.125; p.blindDefenseRange = 0.18; p.threeBetRange = 0.035; p.callSlack = -0.04
p.cbetFrequency = 0.58; p.bluffFrequency = 0.10; p.sizingNoise = 0.20; p.pushFoldDepth = 8
p.foldToThreeBet = 0.68; p.readError = 0.09
case .tightAggressive:
p.sizingNoise = 0.12; p.limpTendency = 0.01; p.callSlack = -0.015
p.pushFoldDepth = 11; p.adaptation = 0.25; p.readError = 0.05
case .looseAggressive:
p.sizingNoise = 0.10; p.limpTendency = 0.0; p.callSlack = -0.005
p.pushFoldDepth = 12; p.adaptation = 0.40; p.readError = 0.045
case .solidRegular:
p.sizingNoise = 0.06; p.limpTendency = 0.0; p.callSlack = -0.005
p.pushFoldDepth = 12; p.adaptation = 0.55; p.readError = 0.03
case .trickyRegular:
p.sizingNoise = 0.05; p.limpTendency = 0.0; p.callSlack = 0.0
p.pushFoldDepth = 13; p.adaptation = 0.85; p.readError = 0.02
}
return p
}
/// Parameters for this style at a given skill level.
public func parameters(skill: Int) -> BotParameters {
let band = skillRange
let clamped = Double(skill.clamped(to: band.lowerBound...band.upperBound))
let span = Double(band.upperBound - band.lowerBound)
let t = span <= 0 ? 1.0 : (clamped - Double(band.lowerBound)) / span
return BotParameters.blend(novice, expert, t)
}
/// How likely this style is to put money in before you get to decide.
///
/// Not a rating of how good they are — a maniac scores highest and is the worst player on the
/// list. It answers one narrow question: if this person is sitting where they have to speak
/// before you do, are you going to be facing a raise or a folded pot? That is what decides
/// which kind of decision you get handed, which is why the assessment sits people by it.
public func pressure(skill: Int) -> Double {
let p = parameters(skill: skill)
return p.openRange * (1 - p.limpTendency) + p.threeBetRange * 2 + p.aggression * 0.4
}
/// Personalities that can plausibly appear at this difficulty.
public static func available(atDifficulty difficulty: Int) -> [Personality] {
allCases.filter { $0.skillRange.contains(difficulty.clamped(to: 0...100)) }
}
}
/// How much wider or tighter a seat plays than its baseline range. Late position is worth far
/// more than most amateurs act like it is.
public enum PositionAdjustment {
public static func openMultiplier(_ position: Position) -> Double {
switch position {
case .underTheGun: return 0.55
case .underTheGunPlus1: return 0.65
case .middle: return 0.78
case .lojack: return 0.88
case .hijack: return 1.00
case .cutoff: return 1.30
case .button: return 1.85
case .smallBlind: return 1.25
case .bigBlind: return 1.00
}
}
/// How much wider every range gets as the table shortens.
///
/// Ranges are written for a full table, but the fewer players there are, the fewer hands can
/// beat you and the more often you are in a blind. Heads-up, the big blind is getting better
/// than three to one to continue and folding it away is the single most expensive thing a
/// player can do. A bot that plays its nine-handed range three-handed bleeds blinds until it
/// dies, which is exactly what tournaments do to it as the table breaks up.
public static func tableSizeMultiplier(playerCount: Int) -> Double {
switch max(2, playerCount) {
case 2: return 2.4
case 3: return 1.8
case 4: return 1.5
case 5: return 1.3
case 6: return 1.15
case 7, 8: return 1.05
default: return 1.0
}
}
/// Being out of position for the rest of the hand costs money; ranges tighten to pay for it.
public static func isInPosition(_ position: Position, versus other: Position) -> Bool {
order(position) > order(other)
}
/// Whether this seat acts after every one of the others once the cards are out — the seat
/// that gets to see what everybody else does first.
public static func actsLast(_ position: Position, among others: [Position]) -> Bool {
others.allSatisfy { order(position) > order($0) }
}
static func order(_ position: Position) -> Int {
switch position {
case .smallBlind: return 0
case .bigBlind: return 1
case .underTheGun: return 2
case .underTheGunPlus1: return 3
case .middle: return 4
case .lojack: return 5
case .hijack: return 6
case .cutoff: return 7
case .button: return 8
}
}
}