← poker-tutor
import Foundation
public enum PlayerAction: Sendable, Equatable, CustomStringConvertible {
case fold
case check
case call
/// Opening wager, given as the total this seat will have in for the round.
case bet(to: Int)
/// Increase over an existing wager, given as the total this seat will have in for the round.
case raise(to: Int)
public var description: String {
switch self {
case .fold: return "fold"
case .check: return "check"
case .call: return "call"
case .bet(let to): return "bet \(to)"
case .raise(let to): return "raise to \(to)"
}
}
}
/// What the seat to act is allowed to do right now.
public struct LegalActions: Sendable, Equatable {
public let seat: Int
public let canFold: Bool
public let canCheck: Bool
public let canCall: Bool
/// Chips that must be added to call. Zero when checking is free.
public let callCost: Int
/// True when calling would put the seat all in.
public let callIsAllIn: Bool
public let canBet: Bool // no wager yet this round
public let canRaise: Bool // a wager exists and this seat may increase it
public let minRaiseTo: Int
/// The largest legal bet or raise, as the total this seat would have in for the round.
///
/// No-limit: the whole stack. Pot-limit: the pot after the call, which is usually smaller
/// than the stack. Fixed-limit: the one size a bet has, so it equals `minRaiseTo`.
public let maxRaiseTo: Int
/// Everything this seat has: what it has already put in this round plus its stack.
public let allInTo: Int
/// Fixed-limit only: the round has had its bet and three raises, so nobody may raise again.
public let capped: Bool
public var canBetOrRaise: Bool { canBet || canRaise }
/// True when the most this seat may bet is everything it has.
public var maxIsAllIn: Bool { maxRaiseTo >= allInTo }
/// True when there is exactly one legal size — fixed-limit, or a stack too short to choose.
public var sizeIsFixed: Bool { minRaiseTo == maxRaiseTo }
public init(seat: Int, canFold: Bool, canCheck: Bool, canCall: Bool, callCost: Int,
callIsAllIn: Bool, canBet: Bool, canRaise: Bool, minRaiseTo: Int, maxRaiseTo: Int,
allInTo: Int? = nil, capped: Bool = false) {
self.seat = seat
self.canFold = canFold
self.canCheck = canCheck
self.canCall = canCall
self.callCost = callCost
self.callIsAllIn = callIsAllIn
self.canBet = canBet
self.canRaise = canRaise
self.minRaiseTo = minRaiseTo
self.maxRaiseTo = maxRaiseTo
self.allInTo = allInTo ?? maxRaiseTo
self.capped = capped
}
}
public struct ActionRecord: Sendable, Equatable {
public let seat: Int
public let street: Street
public let action: PlayerAction
/// Chips this action moved from the stack to the pot.
public let chipsAdded: Int
public let potAfter: Int
public let isForced: Bool // blinds and antes
}
public struct HandResult: Sendable {
public let pots: [Pot]
public let awards: [PotAward]
public let wentToShowdown: Bool
/// Net chips won or lost for the hand, keyed by seat.
public let net: [Int: Int]
public let finalStacks: [Int: Int]
public let board: [Card]
}
/// Runs one hand of hold'em — no-limit, pot-limit or fixed-limit, as `HandConfig.structure`
/// says: blinds, betting, side pots, showdown.
///
/// The engine is the single source of truth for what is legal. Bots, the coach and the UI all
/// read `legalActions()` and call `apply(_:)`; none of them re-implement the rules.
public final class HandEngine {
public let config: HandConfig
public private(set) var seats: [SeatState]
public private(set) var street: Street = .preflop
public private(set) var board: [Card] = []
public private(set) var actions: [ActionRecord] = []
public private(set) var result: HandResult?
/// Highest total any seat has committed this round.
public private(set) var currentBet = 0
/// Size of the last full raise; the next legal raise must be at least this much more.
public private(set) var minRaiseIncrement = 0
/// Bet level set by the last *full* raise. An all-in that falls short of a full raise does not
/// move this, which is what stops it from reopening the betting.
private var lastFullRaiseTo = 0
/// Fixed-limit: bets and full raises made this round, counting the big blind as the first.
/// A bet and three raises is the cap, except heads-up.
private var betsThisRound = 0
private var deck: Deck
private var actorSeat: Int?
public var seatCount: Int { seats.count }
public var buttonSeat: Int { config.buttonSeat }
public var pot: Int { seats.reduce(0) { $0 + $1.committedThisHand + $1.antePaid } }
/// Antes: in the pot, but nobody's wager.
public var deadChips: Int { seats.reduce(0) { $0 + $1.antePaid } }
public var isComplete: Bool { result != nil }
/// The seat that must act, or nil if the hand is between streets or over.
public var actor: Int? { actorSeat }
public init(seats initialSeats: [SeatState], config: HandConfig) {
self.config = config
self.seats = initialSeats
var rng = SeededRandom(seed: config.seed)
self.deck = Deck(rng: &rng)
begin()
}
/// Builds a hand with a known deck order, for tests and for replaying a recorded hand.
public init(seats initialSeats: [SeatState], config: HandConfig, stackedDeck: [Card]) {
self.config = config
self.seats = initialSeats
self.deck = Deck(stacked: stackedDeck)
begin()
}
// MARK: - Setup
private func begin() {
// Seat numbers double as array indexes throughout the engine.
precondition(seats.enumerated().allSatisfy { $0.offset == $0.element.seat }, "seats must be numbered 0..<n in order")
for i in seats.indices where seats[i].stack <= 0 {
seats[i].status = .sittingOut
}
precondition(livePlayers.count >= 2, "a hand needs at least two players with chips")
postAntes()
postBlinds()
dealHoleCards()
minRaiseIncrement = config.level.bigBlind
lastFullRaiseTo = config.level.bigBlind
// A big blind posted short — a 30-chip stack at 25/50 — does not lower the price for
// everyone else. The bet level stays at the full big blind: the others call the whole
// blind, the short seat plays for the part it could post, and the first raise is still
// to two big blinds rather than to some odd number the short stack happened to set.
let posted = seats.map(\.committedThisRound).max() ?? 0
currentBet = bigBlindSeat != nil ? max(posted, config.level.bigBlind) : posted
// Fixed-limit counts the blind as the round's first bet.
betsThisRound = bigBlindSeat != nil ? 1 : 0
actorSeat = firstToActPreflop()
// Antes or blinds can put everyone all in before a card is turned.
if actorSeat == nil || playersAbleToAct.count < 2 { finishBettingEarly() }
}
private var livePlayers: [SeatState] { seats.filter { $0.status != .sittingOut } }
private var playersInHand: [SeatState] { seats.filter { $0.isInHand } }
private var playersAbleToAct: [SeatState] { seats.filter { $0.canAct } }
private func postAntes() {
switch config.level.ante {
case .none:
return
case .perPlayer(let amount):
for seat in livePlayers { payAnte(seat: seat.seat, amount: amount) }
case .bigBlindAnte(let amount):
guard let bb = bigBlindSeat else { return }
payAnte(seat: bb, amount: amount)
}
}
/// An ante is not a bet. It never counts toward the round's betting level, is never matched,
/// and cannot create a side pot — it is simply dead money in the middle.
private func payAnte(seat: Int, amount: Int) {
let paid = min(amount, seats[seat].stack)
guard paid > 0 else { return }
seats[seat].stack -= paid
seats[seat].antePaid += paid
if seats[seat].stack == 0 { seats[seat].status = .allIn }
actions.append(ActionRecord(
seat: seat, street: .preflop, action: .bet(to: paid),
chipsAdded: paid, potAfter: pot, isForced: true
))
}
private func postBlinds() {
if let sb = smallBlindSeat {
commit(seat: sb, chips: min(config.level.smallBlind, seats[sb].stack), forced: true)
}
if let bb = bigBlindSeat {
commit(seat: bb, chips: min(config.level.bigBlind, seats[bb].stack), forced: true)
}
}
private func dealHoleCards() {
// Two rounds of one card each, starting left of the button, the way a dealer does it.
for _ in 0..<2 {
for seat in orderedSeats(startingAfter: config.buttonSeat) where seats[seat].status != .sittingOut {
seats[seat].hole.append(deck.deal())
}
}
}
// MARK: - Positions
public var smallBlindSeat: Int? {
let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
guard !live.isEmpty else { return nil }
// Heads-up, the button posts the small blind.
return live.count == 2 ? config.buttonSeat : live[0]
}
public var bigBlindSeat: Int? {
let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
guard live.count >= 2 else { return nil }
return live.count == 2 ? live.first { $0 != config.buttonSeat } : live[1]
}
/// Names every live seat by its position at the table.
public func positions() -> [Int: Position] {
let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
let names = Position.names(forPlayerCount: live.count)
var map: [Int: Position] = [:]
if live.count == 2 {
// live[0] is left of the button, which heads-up is the big blind.
map[live[0]] = .bigBlind
map[config.buttonSeat] = .button
return map
}
for (seat, name) in zip(live, names) { map[seat] = name }
return map
}
/// Seat indexes in clockwise order beginning with the seat after `seat`.
private func orderedSeats(startingAfter seat: Int) -> [Int] {
(1...seats.count).map { (seat + $0) % seats.count }
}
private func firstToActPreflop() -> Int? {
guard let bb = bigBlindSeat else { return nil }
return orderedSeats(startingAfter: bb).first { seats[$0].canAct }
}
private func firstToActPostflop() -> Int? {
orderedSeats(startingAfter: config.buttonSeat).first { seats[$0].canAct }
}
// MARK: - Legal actions
public func legalActions() -> LegalActions? {
guard let seat = actorSeat, !isComplete else { return nil }
let s = seats[seat]
let owed = currentBet - s.committedThisRound
let callCost = min(owed, s.stack)
let allInTo = s.committedThisRound + s.stack
let maxRaiseTo: Int
let minRaiseTo: Int
var capped = false
switch config.structure {
case .noLimit:
maxRaiseTo = allInTo
// Minimum legal raise, capped by the stack so a short all-in is always offered.
let rawMin = currentBet == 0 ? minRaiseIncrement : currentBet + minRaiseIncrement
minRaiseTo = min(max(rawMin, currentBet + 1), maxRaiseTo)
case .potLimit:
// The most a player may raise BY is the pot as it would stand after their call —
// everything in the middle, including antes and this round's bets, plus the call
// itself. As a raise-to that is the current bet plus that pot: at 25/50 the first
// player may make it 175, and if they do, the next may make it 600.
maxRaiseTo = min(allInTo, currentBet + pot + owed)
let rawMin = currentBet == 0 ? minRaiseIncrement : currentBet + minRaiseIncrement
minRaiseTo = min(max(rawMin, currentBet + 1), maxRaiseTo)
case .fixedLimit:
// One size: the next full bet level. After a short all-in that did not make a full
// raise the level is still the one above the last full bet; after one that did, it
// is the next multiple of the bet above it.
let unit = fixedBet
let nextLevel = currentBet == 0
? unit
: max(lastFullRaiseTo + unit, ((currentBet + unit - 1) / unit) * unit)
maxRaiseTo = min(allInTo, nextLevel)
minRaiseTo = maxRaiseTo
// The cap lifts when only two players can still act — the usual house rule counts a
// seat that is all in as out of the betting, not as a third player.
capped = betsThisRound >= BettingStructure.fixedLimitCap && playersAbleToAct.count > 2
}
// An undersized all-in doesn't reopen the betting: a seat that already acted at the
// current full-raise level may only call or fold.
let reopened = s.lastActedAtBet == nil || s.lastActedAtBet! < lastFullRaiseTo
let hasChipsToRaise = maxRaiseTo > currentBet
let others = seats.filter { $0.seat != seat && $0.canAct }.count > 0
let canBet = currentBet == 0 && hasChipsToRaise && others && !capped
let canRaise = currentBet > 0 && hasChipsToRaise && reopened && others && !capped
return LegalActions(
seat: seat,
canFold: true,
canCheck: owed == 0,
canCall: owed > 0,
callCost: callCost,
callIsAllIn: owed >= s.stack,
canBet: canBet,
canRaise: canRaise,
minRaiseTo: minRaiseTo,
maxRaiseTo: maxRaiseTo,
allInTo: allInTo,
capped: capped
)
}
/// The size of a fixed-limit bet on the current street; the big blind elsewhere, where it
/// only ever serves as the minimum increment.
private var fixedBet: Int {
config.structure.fixedBet(bigBlind: config.level.bigBlind, street: street) ?? config.level.bigBlind
}
// MARK: - Applying actions
@discardableResult
public func apply(_ action: PlayerAction) throws -> ActionRecord {
guard !isComplete else { throw HandError.handComplete }
guard let seat = actorSeat, let legal = legalActions() else { throw HandError.handComplete }
var chipsAdded = 0
switch action {
case .fold:
seats[seat].status = .folded
case .check:
guard legal.canCheck else { throw HandError.illegal("there is a bet to answer") }
case .call:
guard legal.canCall else { throw HandError.illegal("there is nothing to call") }
chipsAdded = legal.callCost
commit(seat: seat, chips: chipsAdded, forced: false)
case .bet(let to):
guard legal.canBet else { throw HandError.illegal("cannot open a bet here") }
guard to >= legal.minRaiseTo || to == legal.maxRaiseTo else {
throw HandError.illegal("bet must be at least \(legal.minRaiseTo)")
}
guard to <= legal.maxRaiseTo else { throw HandError.illegal(tooBig("bet", to, legal)) }
chipsAdded = to - seats[seat].committedThisRound
registerAggression(to: to) // must read the old bet level, so this comes first
commit(seat: seat, chips: chipsAdded, forced: false)
case .raise(let to):
guard legal.canRaise else {
throw HandError.illegal(legal.capped ? "the betting is capped this round" : "cannot raise here")
}
guard to >= legal.minRaiseTo || to == legal.maxRaiseTo else {
throw HandError.illegal("raise must be to at least \(legal.minRaiseTo)")
}
guard to <= legal.maxRaiseTo else { throw HandError.illegal(tooBig("raise", to, legal)) }
chipsAdded = to - seats[seat].committedThisRound
registerAggression(to: to) // must read the old bet level, so this comes first
commit(seat: seat, chips: chipsAdded, forced: false)
}
seats[seat].hasActedThisRound = true
seats[seat].lastActedAtBet = currentBet
let record = ActionRecord(
seat: seat,
street: street,
action: action,
chipsAdded: chipsAdded,
potAfter: pot,
isForced: false
)
actions.append(record)
advance()
return record
}
/// Why a bet or raise was refused, in the words of the structure that refused it.
private func tooBig(_ what: String, _ to: Int, _ legal: LegalActions) -> String {
if to > legal.allInTo { return "\(what) exceeds the stack" }
switch config.structure {
case .noLimit: return "\(what) exceeds the stack"
case .potLimit: return "\(what) exceeds the pot limit"
case .fixedLimit: return "\(what) must be exactly \(legal.maxRaiseTo) at fixed-limit"
}
}
/// Records a bet or raise and works out whether it was a full raise.
private func registerAggression(to: Int) {
let increment = to - currentBet
switch config.structure {
case .fixedLimit:
// The increment never grows: a full bet is a full bet. What counts towards the cap
// is reaching the next level; a short all-in that falls between levels does not.
if increment >= minRaiseIncrement || to >= lastFullRaiseTo + fixedBet {
lastFullRaiseTo = to
betsThisRound += 1
}
case .noLimit, .potLimit:
if increment >= minRaiseIncrement {
minRaiseIncrement = increment
lastFullRaiseTo = to
}
}
// A short all-in still sets the price; it just doesn't reopen raising.
currentBet = max(currentBet, to)
}
private func commit(seat: Int, chips: Int, forced: Bool) {
guard chips > 0 else {
if seats[seat].stack == 0 && seats[seat].status == .active { seats[seat].status = .allIn }
return
}
let amount = min(chips, seats[seat].stack)
seats[seat].stack -= amount
seats[seat].committedThisRound += amount
seats[seat].committedThisHand += amount
if seats[seat].stack == 0 { seats[seat].status = .allIn }
if forced {
actions.append(ActionRecord(
seat: seat, street: street, action: .bet(to: seats[seat].committedThisRound),
chipsAdded: amount, potAfter: pot, isForced: true
))
}
}
// MARK: - Flow
private func advance() {
// Everyone else folded.
if playersInHand.count <= 1 {
actorSeat = nil
finish(showdown: false)
return
}
if let next = nextActor() {
actorSeat = next
return
}
// Betting round is over.
actorSeat = nil
if playersAbleToAct.count < 2 {
finishBettingEarly()
} else if street == .river {
finish(showdown: true)
} else {
openNextStreet()
}
}
/// The next seat owing an action this round, or nil when the round is settled.
private func nextActor() -> Int? {
let from = actorSeat ?? config.buttonSeat
for seat in orderedSeats(startingAfter: from) {
let s = seats[seat]
guard s.canAct else { continue }
if s.committedThisRound < currentBet { return seat }
if !s.hasActedThisRound { return seat } // the big blind's option, or a check-around
}
return nil
}
private func openNextStreet() {
guard let next = Street(rawValue: street.rawValue + 1), next != .showdown else {
finish(showdown: true)
return
}
street = next
deck.burn()
board.append(contentsOf: deck.deal(next.cardsDealt))
for i in seats.indices {
seats[i].committedThisRound = 0
seats[i].hasActedThisRound = false
seats[i].lastActedAtBet = nil
}
currentBet = 0
minRaiseIncrement = fixedBet
lastFullRaiseTo = 0
betsThisRound = 0
if playersAbleToAct.count < 2 {
finishBettingEarly()
return
}
actorSeat = firstToActPostflop()
if actorSeat == nil { finishBettingEarly() }
}
/// Fewer than two players can still bet, so deal the rest of the board and go to showdown.
private func finishBettingEarly() {
actorSeat = nil
if playersInHand.count <= 1 {
finish(showdown: false)
return
}
if case .allInEquity(let trials) = config.settlement, board.count < 5 {
settleByEquity(trials: trials)
return
}
while board.count < 5 {
guard let next = Street(rawValue: street.rawValue + 1), next != .showdown else { break }
street = next
deck.burn()
board.append(contentsOf: deck.deal(next.cardsDealt))
}
finish(showdown: true)
}
// MARK: - Settling up
/// Hands back the part of the last bet nobody called. Without this, a seat that bets 500 into
/// a 100-chip stack would lose 400 chips it was never actually risking.
///
/// This works on the current betting round only. Earlier rounds are always fully matched, and
/// measuring across the whole hand would let dead money — an ante, or a folded player's
/// chips — disguise part of the overbet.
private func returnUncalledBet() {
let byRound = seats.filter { $0.committedThisRound > 0 }.map(\.committedThisRound).sorted(by: >)
guard let highest = byRound.first else { return }
let secondHighest = byRound.count > 1 ? byRound[1] : 0
guard highest > secondHighest else { return }
guard let index = seats.firstIndex(where: { $0.committedThisRound == highest }) else { return }
let refund = highest - secondHighest
seats[index].committedThisRound -= refund
seats[index].committedThisHand -= refund
seats[index].stack += refund
if seats[index].status == .allIn && seats[index].stack > 0 { seats[index].status = .active }
}
/// Pays out every pot by each contender's chance of winning it, leaving the board where it is.
/// Chips stay integers: fractional shares are floored and the remainder handed out clockwise
/// from the button, so nothing is created or destroyed.
private func settleByEquity(trials: Int) {
returnUncalledBet()
let pots = PotBuilder.build(from: seats, dead: deadChips)
var awards: [PotAward] = []
var rng = SeededRandom(seed: config.seed &* 6_364_136_223_846_793_005 &+ 1)
for (index, pot) in pots.enumerated() {
let contenders = pot.eligible.filter { seats[$0].isInHand && seats[$0].hole.count == 2 }
guard !contenders.isEmpty else { continue }
if contenders.count == 1 {
awards.append(PotAward(potIndex: index, seat: contenders[0], amount: pot.amount, hand: nil))
continue
}
var shares: [Double] = []
for seat in contenders {
let others = contenders.filter { $0 != seat }.map { seats[$0].hole }
let equity = EquityCalculator.equity(
hero: seats[seat].hole, opponents: others, board: board, trials: trials, rng: &rng
).equity
shares.append(equity)
}
let total = shares.reduce(0, +)
guard total > 0 else { continue }
var paid = 0
var amounts: [Int] = []
for share in shares {
let amount = Int(Double(pot.amount) * share / total)
amounts.append(amount)
paid += amount
}
// Hand the rounding remainder out clockwise from the button.
var remainder = pot.amount - paid
let order = contenders.indices.sorted {
PotBuilder.seatsAfterButton(contenders[$0], button: config.buttonSeat, count: seats.count)
< PotBuilder.seatsAfterButton(contenders[$1], button: config.buttonSeat, count: seats.count)
}
for i in order where remainder > 0 {
amounts[i] += 1
remainder -= 1
}
for (i, seat) in contenders.enumerated() where amounts[i] > 0 {
awards.append(PotAward(potIndex: index, seat: seat, amount: amounts[i], hand: nil))
}
}
for award in awards { seats[award.seat].stack += award.amount }
var net: [Int: Int] = [:]
for seat in seats {
let won = awards.filter { $0.seat == seat.seat }.reduce(0) { $0 + $1.amount }
net[seat.seat] = won - seat.committedThisHand - seat.antePaid
}
result = HandResult(
pots: pots, awards: awards, wentToShowdown: true, net: net,
finalStacks: seats.reduce(into: [Int: Int]()) { $0[$1.seat] = $1.stack },
board: board
)
}
private func finish(showdown: Bool) {
returnUncalledBet()
let pots = PotBuilder.build(from: seats, dead: deadChips)
let awards = PotBuilder.award(
pots: pots,
seats: seats,
board: board,
buttonSeat: config.buttonSeat,
seatCount: seats.count
)
for award in awards {
seats[award.seat].stack += award.amount
}
var net: [Int: Int] = [:]
for seat in seats {
// A refunded bet was never at risk, so this measures only what stayed in the pot.
let won = awards.filter { $0.seat == seat.seat }.reduce(0) { $0 + $1.amount }
net[seat.seat] = won - seat.committedThisHand - seat.antePaid
}
result = HandResult(
pots: pots,
awards: awards,
wentToShowdown: showdown && playersInHand.count > 1,
net: net,
finalStacks: seats.reduce(into: [Int: Int]()) { $0[$1.seat] = $1.stack },
board: board
)
}
}