← poker-tutor
import PokerCore
import TestKit
// MARK: - Helpers
func makeSeats(_ stacks: [Int]) -> [SeatState] {
stacks.enumerated().map { SeatState(seat: $0.offset, playerID: "P\($0.offset)", stack: $0.element) }
}
/// Builds a deck that deals exactly the requested hole cards and board, filling burn cards
/// from whatever is left over.
func stackDeck(holes: [[Card]], board: [Card], button: Int) -> [Card] {
let n = holes.count
let order = (1...n).map { (button + $0) % n }
var used = Set(holes.flatMap { $0 } + board)
var spare = Card.all.filter { !used.contains($0) }
func burn() -> Card {
let c = spare.removeFirst()
used.insert(c)
return c
}
var deck: [Card] = []
for round in 0..<2 {
for seat in order where holes[seat].count > round {
deck.append(holes[seat][round])
}
}
if board.count >= 3 {
deck.append(burn())
deck.append(contentsOf: board.prefix(3))
}
if board.count >= 4 {
deck.append(burn())
deck.append(board[3])
}
if board.count >= 5 {
deck.append(burn())
deck.append(board[4])
}
return deck
}
let noAnte = BlindLevel(smallBlind: 50, bigBlind: 100)
func engine(stacks: [Int], button: Int = 0, level: BlindLevel = noAnte, holes: [[Card]]? = nil, board: [Card] = [],
structure: BettingStructure = .noLimit) -> HandEngine {
let seats = makeSeats(stacks)
let config = HandConfig(level: level, buttonSeat: button, seed: 1, structure: structure)
if let holes {
return HandEngine(seats: seats, config: config, stackedDeck: stackDeck(holes: holes, board: board, button: button))
}
return HandEngine(seats: seats, config: config)
}
/// Plays out a hand with a callback choosing each action.
func playOut(_ e: HandEngine, choose: (LegalActions, HandEngine) -> PlayerAction) {
var guardCounter = 0
while !e.isComplete, let legal = e.legalActions() {
try! e.apply(choose(legal, e))
guardCounter += 1
precondition(guardCounter < 500, "hand did not terminate")
}
}
func alwaysCall(_ legal: LegalActions, _ e: HandEngine) -> PlayerAction {
legal.canCheck ? .check : .call
}
func alwaysFold(_ legal: LegalActions, _ e: HandEngine) -> PlayerAction {
legal.canCheck ? .check : .fold
}
// MARK: - Suites
func tableTests(_ t: TestRun) {
t.suite("blinds and position") { t in
t.test("blinds are posted left of the button") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
t.equal(e.smallBlindSeat, 1)
t.equal(e.bigBlindSeat, 2)
t.equal(e.seats[1].committedThisHand, 50)
t.equal(e.seats[2].committedThisHand, 100)
t.equal(e.pot, 150)
}
t.test("heads-up, the button posts the small blind") {
let e = engine(stacks: [1000, 1000], button: 0)
t.equal(e.smallBlindSeat, 0, "the button is the small blind heads-up")
t.equal(e.bigBlindSeat, 1)
t.equal(e.actor, 0, "and acts first before the flop")
}
t.test("under the gun acts first before the flop") {
let e = engine(stacks: [1000, 1000, 1000, 1000, 1000, 1000], button: 0)
t.equal(e.actor, 3, "the seat after the big blind")
}
t.test("the small blind acts first after the flop") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
playOut(e) { legal, eng in
eng.street == .preflop ? alwaysCall(legal, eng) : .check
}
// Verify by replaying to the flop only.
let e2 = engine(stacks: [1000, 1000, 1000], button: 0)
try! e2.apply(.call) // seat 0, under the gun three-handed
try! e2.apply(.call) // small blind completes
try! e2.apply(.check) // big blind's option
t.equal(e2.street, .flop)
t.equal(e2.actor, 1, "small blind leads after the flop")
}
t.test("positions are named correctly") {
let e = engine(stacks: Array(repeating: 1000, count: 9), button: 0)
let p = e.positions()
t.equal(p[0], .button)
t.equal(p[1], .smallBlind)
t.equal(p[2], .bigBlind)
t.equal(p[3], .underTheGun)
t.equal(p[8], .cutoff)
let six = engine(stacks: Array(repeating: 1000, count: 6), button: 2).positions()
t.equal(six[2], .button)
t.equal(six[3], .smallBlind)
t.equal(six[4], .bigBlind)
t.equal(six[5], .underTheGun)
let hu = engine(stacks: [1000, 1000], button: 1).positions()
t.equal(hu[1], .button)
t.equal(hu[0], .bigBlind)
}
t.test("a player with no chips is not dealt in") {
let e = engine(stacks: [1000, 0, 1000, 1000], button: 0)
t.equal(e.seats[1].status, .sittingOut)
t.equal(e.seats[1].hole.count, 0)
t.equal(e.smallBlindSeat, 2, "the blind skips the empty seat")
}
t.test("everyone is dealt two cards") {
let e = engine(stacks: Array(repeating: 1000, count: 6), button: 0)
for seat in e.seats { t.equal(seat.hole.count, 2, "seat \(seat.seat)") }
let all = e.seats.flatMap(\.hole)
t.equal(Set(all).count, 12, "no card is dealt twice")
}
}
t.suite("betting rules") { t in
t.test("the big blind gets an option after limps") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
try! e.apply(.call) // seat 0
try! e.apply(.call) // small blind
t.equal(e.actor, 2, "big blind still has a decision")
let legal = e.legalActions()!
t.expect(legal.canCheck, "the big blind may check its option")
t.expect(legal.canRaise, "the big blind may also raise")
try! e.apply(.check)
t.equal(e.street, .flop)
}
t.test("a minimum raise is the size of the last raise") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
t.equal(e.legalActions()!.minRaiseTo, 200, "min open-raise is twice the big blind")
try! e.apply(.raise(to: 300))
t.equal(e.legalActions()!.minRaiseTo, 500, "the raise was 200, so the next is to 500")
}
t.test("raising below the minimum is rejected") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
do {
try e.apply(.raise(to: 150))
t.expect(false, "a raise to 150 should have been rejected")
} catch {
t.expect(true, "")
}
}
t.test("checking with a bet outstanding is rejected") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
do {
try e.apply(.check)
t.expect(false, "checking under the gun should have been rejected")
} catch {
t.expect(true, "")
}
}
t.test("a short all-in does not reopen the betting") {
// Seat 0 raises, seat 1 shoves a stack too small to be a full raise,
// seat 2 calls, and seat 0 may then only call or fold.
let e = engine(stacks: [5000, 450, 5000], button: 2)
// Button is seat 2, so small blind is 0, big blind is 1, and seat 2 acts first.
try! e.apply(.raise(to: 300)) // seat 2 opens to 300
try! e.apply(.call) // seat 0 (small blind) calls 300
let short = e.legalActions()!
t.equal(short.seat, 1)
t.equal(short.maxRaiseTo, 450, "seat 1 can only get 450 in")
try! e.apply(.raise(to: 450)) // an undersized all-in raise
let reopened = e.legalActions()!
t.equal(reopened.seat, 2)
t.expect(!reopened.canRaise, "the original raiser may not re-raise an undersized all-in")
t.expect(reopened.canCall, "but may call the extra")
t.equal(reopened.callCost, 150)
}
t.test("a full all-in raise does reopen the betting") {
let e = engine(stacks: [5000, 900, 5000], button: 2)
try! e.apply(.raise(to: 300)) // seat 2 opens
try! e.apply(.call) // seat 0 calls
try! e.apply(.raise(to: 900)) // seat 1 shoves — a full 600 raise over 300
let reopened = e.legalActions()!
t.equal(reopened.seat, 2)
t.expect(reopened.canRaise, "a full raise reopens the action")
}
t.test("you cannot raise when everyone else is already all in") {
let e = engine(stacks: [1000, 200, 1000], button: 0)
try! e.apply(.raise(to: 1000)) // seat 0 shoves
try! e.apply(.call) // seat 1 calls all in for 200
let legal = e.legalActions()!
t.equal(legal.seat, 2)
t.expect(!legal.canRaise, "no one is left who could call a raise")
}
t.test("a betting round ends when everyone has checked") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
try! e.apply(.call); try! e.apply(.call); try! e.apply(.check)
t.equal(e.street, .flop)
try! e.apply(.check); try! e.apply(.check); try! e.apply(.check)
t.equal(e.street, .turn)
t.equal(e.board.count, 4)
}
t.test("the board fills out one street at a time") {
let e = engine(stacks: [1000, 1000], button: 0)
t.equal(e.board.count, 0)
playOut(e) { legal, eng in alwaysCall(legal, eng) }
t.equal(e.board.count, 5)
t.equal(Set(e.board).count, 5, "no duplicate community cards")
}
}
t.suite("ending a hand") { t in
t.test("everyone folds and the last player takes the pot") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
try! e.apply(.fold) // seat 0
try! e.apply(.fold) // small blind
t.expect(e.isComplete, "the hand should be over")
let result = e.result!
t.expect(!result.wentToShowdown, "no showdown when everyone folds")
t.equal(result.net[2], 50, "the big blind wins the small blind")
t.equal(e.seats[2].stack, 1050)
}
t.test("an uncalled bet comes back") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
try! e.apply(.raise(to: 800))
try! e.apply(.fold)
try! e.apply(.fold)
t.equal(e.seats[0].stack, 1150, "the 800 is returned, and the blinds are won")
t.equal(e.result!.net[0], 150)
}
t.test("an ante does not disguise an uncalled bet") {
// The big blind's ante is dead money. Measuring the refund across the whole hand
// instead of the current round would leave part of the uncalled bet in the pot.
let level = BlindLevel(smallBlind: 50, bigBlind: 100, ante: .bigBlindAnte(100))
let e = engine(stacks: [10_000, 10_000, 10_000], button: 0, level: level)
try! e.apply(.call) // seat 0 limps
try! e.apply(.call) // small blind completes
try! e.apply(.check) // big blind checks its option
t.equal(e.street, .flop)
t.equal(e.pot, 400, "300 of blinds and calls plus a 100 ante")
try! e.apply(.bet(to: 900)) // small blind leads big
try! e.apply(.fold)
try! e.apply(.fold)
t.equal(e.result!.pots.reduce(0) { $0 + $1.amount }, 400, "the whole 900 comes back")
t.equal(e.seats[1].stack, 10_300)
t.equal(e.seats.reduce(0) { $0 + $1.stack }, 30_000)
}
t.test("an uncalled bet on a later street comes back in full") {
let e = engine(stacks: [1000, 1000, 1000], button: 0)
try! e.apply(.call); try! e.apply(.call); try! e.apply(.check)
try! e.apply(.bet(to: 300)) // small blind bets the flop
try! e.apply(.fold)
try! e.apply(.fold)
// Started with 1000, put 100 in preflop and 300 on the flop. The 300 comes straight
// back and the 300 pot is won, for a net gain of the two opponents' 200.
t.equal(e.seats[1].stack, 1200, "the flop bet is returned and the pot is won")
t.equal(e.result!.net[1], 200)
}
t.test("the best hand wins at showdown") {
let holes = [[Card].parse("Ac Ad"), [Card].parse("Kc Kd"), [Card].parse("7h 2s")]
let e = engine(stacks: [1000, 1000, 1000], button: 0,
holes: holes, board: [Card].parse("Ah 9c 4d 3s 2c"))
playOut(e) { legal, eng in alwaysCall(legal, eng) }
t.equal(e.seats[0].hole.shorthand, "Ac Ad", "the stacked deck dealt what we asked for")
let winners = Set(e.result!.awards.map(\.seat))
t.equal(winners, Set([0]), "the set of aces wins")
t.equal(e.result!.net[0], 200)
}
t.test("a split pot divides evenly") {
// Both play the same straight on the board.
let holes = [[Card].parse("2c 3d"), [Card].parse("2h 3s"), [Card].parse("7h 8s")]
let e = engine(stacks: [1000, 1000, 1000], button: 0,
holes: holes, board: [Card].parse("As Kh Qd Jc Tc"))
playOut(e) { legal, eng in alwaysCall(legal, eng) }
let awards = e.result!.awards
t.equal(awards.count, 3, "all three chop")
t.equal(Set(awards.map(\.amount)), Set([100]))
}
t.test("the odd chip goes to the first seat left of the button") {
let holes = [[Card].parse("2c 3d"), [Card].parse("2h 3s")]
// 25-chip blinds make a 75-chip pot that two players cannot split evenly... force it
// with an odd pot: both players call an odd amount.
let level = BlindLevel(smallBlind: 25, bigBlind: 51)
let e = engine(stacks: [1000, 1000], button: 0, level: level,
holes: holes, board: [Card].parse("As Kh Qd Jc Tc"))
playOut(e) { legal, eng in alwaysCall(legal, eng) }
let awards = e.result!.awards.sorted { $0.seat < $1.seat }
t.equal(awards.count, 2)
t.equal(awards.map(\.amount).reduce(0, +), 102)
t.equal(awards[1].amount, 51, "seat 1 is left of the button and takes the odd chip")
}
}
}
func sidePotTests(_ t: TestRun) {
t.suite("side pots") { t in
t.test("three stacks all in build a main pot and one side pot") {
// Short stack 100, medium 300, big 1000. Everyone all in preflop.
let holes = [[Card].parse("Ac Ad"), [Card].parse("Kc Kd"), [Card].parse("Qc Qd")]
let level = BlindLevel(smallBlind: 10, bigBlind: 20)
let e = engine(stacks: [100, 300, 1000], button: 2, level: level,
holes: holes, board: [Card].parse("2h 5c 9d Js 3s"))
playOut(e) { legal, eng in
legal.canBetOrRaise ? .raise(to: legal.maxRaiseTo) : .call
}
let pots = e.result!.pots
t.equal(pots.count, 2, "one main pot and one side pot")
t.equal(pots[0].amount, 300, "main pot is 100 from each")
t.equal(Set(pots[0].eligible), Set([0, 1, 2]))
t.equal(pots[1].amount, 400, "side pot is 200 more from the two deeper stacks")
t.equal(Set(pots[1].eligible), Set([1, 2]))
// Aces take the main pot, kings take the side pot.
t.equal(e.seats[0].stack, 300)
t.equal(e.seats[1].stack, 400)
t.equal(e.seats[2].stack, 700)
}
t.test("a short stack cannot win chips it never matched") {
// The short stack has the best hand but only covers the main pot.
let holes = [[Card].parse("Ac Ad"), [Card].parse("Kc Kd"), [Card].parse("Qc Qd")]
let level = BlindLevel(smallBlind: 10, bigBlind: 20)
let e = engine(stacks: [100, 1000, 1000], button: 2, level: level,
holes: holes, board: [Card].parse("2h 5c 9d Js 3s"))
playOut(e) { legal, eng in
legal.canBetOrRaise ? .raise(to: legal.maxRaiseTo) : .call
}
t.equal(e.seats[0].stack, 300, "aces win only the 300 main pot")
t.equal(e.seats[1].stack, 1800, "kings take the 900 side pot")
t.equal(e.seats[2].stack, 0)
}
t.test("a folded player's chips stay in the pot") {
let level = BlindLevel(smallBlind: 10, bigBlind: 20)
let e = engine(stacks: [1000, 1000, 1000], button: 0, level: level)
try! e.apply(.raise(to: 100)) // seat 0
try! e.apply(.call) // seat 1, small blind
try! e.apply(.fold) // seat 2, big blind — 20 chips left behind
t.equal(e.pot, 220)
playOut(e) { legal, eng in alwaysCall(legal, eng) }
let total = e.seats.reduce(0) { $0 + $1.stack }
t.equal(total, 3000, "chips are conserved even with dead money")
}
t.test("an ante is dead money, not a wager") {
// If antes were treated as a bet, the big blind would be in for more than everyone
// else and would win a phantom side pot containing its own ante, every single hand.
let level = BlindLevel(smallBlind: 50, bigBlind: 100, ante: .bigBlindAnte(100))
let e = engine(stacks: [1000, 1000, 1000], button: 0, level: level)
t.equal(e.pot, 250, "small blind, big blind and the ante")
t.equal(e.deadChips, 100)
playOut(e) { legal, eng in alwaysCall(legal, eng) }
t.equal(e.result!.pots.count, 1, "one pot, no phantom side pot for the ante")
t.equal(e.result!.pots[0].amount, 400)
t.equal(Set(e.result!.pots[0].eligible), Set([0, 1, 2]))
t.equal(e.seats.reduce(0) { $0 + $1.stack }, 3000)
}
t.test("per-player antes are dead money too") {
let level = BlindLevel(smallBlind: 50, bigBlind: 100, ante: .perPlayer(25))
let e = engine(stacks: [1000, 1000, 1000], button: 0, level: level)
t.equal(e.deadChips, 75)
t.equal(e.pot, 225)
playOut(e) { legal, eng in alwaysCall(legal, eng) }
t.equal(e.result!.pots.count, 1)
t.equal(e.result!.pots[0].amount, 375)
}
t.test("a player all in for the ante can win the antes and nothing more") {
// The big blind cannot even cover the ante. It is all in for dead money only, so it
// can win the antes but none of the betting.
let level = BlindLevel(smallBlind: 50, bigBlind: 100, ante: .bigBlindAnte(100))
let holes = [[Card].parse("7c 2d"), [Card].parse("Kh Qs"), [Card].parse("Ac Ad")]
let e = engine(stacks: [1000, 1000, 60], button: 0, level: level,
holes: holes, board: [Card].parse("As 9h 4c 3d 8s"))
t.equal(e.seats[2].status, .allIn, "the ante put the big blind all in")
t.equal(e.deadChips, 60)
try! e.apply(.raise(to: 200)) // seat 0 opens
try! e.apply(.fold) // seat 1 folds
t.expect(e.isComplete, "no one is left to act")
let pots = e.result!.pots
t.equal(pots.count, 2)
t.equal(pots[0].amount, 60, "the ante pot")
t.equal(Set(pots[0].eligible), Set([0, 2]), "the all-in player can win the antes")
t.equal(Set(pots[1].eligible), Set([0]), "but not the money it never matched")
t.equal(e.seats[2].stack, 60, "aces win the antes back")
t.equal(e.seats[0].stack, 1050, "the uncalled raise is returned, the blinds are won")
t.equal(e.seats.reduce(0) { $0 + $1.stack }, 2060)
}
t.test("chips survive an all-in that only the ante could cover") {
// The exact shape that stalled a tournament: everyone short of the big blind.
let level = BlindLevel(smallBlind: 10_500, bigBlind: 21_000, ante: .bigBlindAnte(21_000))
let e = engine(stacks: [5_700, 6_300], button: 0, level: level)
t.expect(e.isComplete, "both players are all in before a card is turned")
t.equal(e.seats.reduce(0) { $0 + $1.stack }, 12_000, "chips are conserved")
t.expect(e.seats.contains { $0.stack == 0 }, "someone must bust — otherwise the tournament never ends")
}
t.test("pot slices with identical contenders merge into one") {
let level = BlindLevel(smallBlind: 10, bigBlind: 20)
let e = engine(stacks: [500, 500, 500], button: 0, level: level)
playOut(e) { legal, eng in
legal.canBetOrRaise ? .raise(to: legal.maxRaiseTo) : .call
}
t.equal(e.result!.pots.count, 1, "equal stacks make a single pot")
t.equal(e.result!.pots[0].amount, 1500)
}
t.test("chips are conserved across thousands of random hands") {
var rng = SeededRandom(seed: 2024)
var handsPlayed = 0
var showdowns = 0
for round in 0..<1_500 {
let count = 2 + Int(rng.next() % 7)
let stacks = (0..<count).map { _ in 20 + Int(rng.next() % 3000) }
let starting = stacks.reduce(0, +)
let level = BlindLevel(smallBlind: 10, bigBlind: 20,
ante: round % 3 == 0 ? .bigBlindAnte(20) : .none)
let seats = makeSeats(stacks)
let config = HandConfig(level: level, buttonSeat: Int(rng.next() % UInt64(count)), seed: rng.next())
let e = HandEngine(seats: seats, config: config)
var steps = 0
while !e.isComplete, let legal = e.legalActions() {
let roll = rng.next() % 100
let action: PlayerAction
if roll < 12 && !legal.canCheck {
action = .fold
} else if roll < 70 {
action = legal.canCheck ? .check : .call
} else if legal.canBetOrRaise {
let span = max(1, legal.maxRaiseTo - legal.minRaiseTo)
let to = legal.minRaiseTo + Int(rng.next() % UInt64(span))
action = legal.canBet ? .bet(to: to) : .raise(to: to)
} else {
action = legal.canCheck ? .check : .call
}
try! e.apply(action)
steps += 1
precondition(steps < 400, "hand failed to terminate")
}
let ending = e.seats.reduce(0) { $0 + $1.stack }
t.equal(ending, starting, "chips changed on hand \(round)")
t.expect(e.seats.allSatisfy { $0.stack >= 0 }, "a stack went negative on hand \(round)")
let potTotal = e.result!.pots.reduce(0) { $0 + $1.amount }
let awarded = e.result!.awards.reduce(0) { $0 + $1.amount }
t.equal(awarded, potTotal, "pot and awards disagree on hand \(round)")
handsPlayed += 1
if e.result!.wentToShowdown { showdowns += 1 }
}
t.equal(handsPlayed, 1_500)
t.greaterThan(showdowns, 100, "random play should reach showdown sometimes")
}
}
}