Advertisement

← poker-tutor

Sources/PokerTests/TableTests.swift

23.9 KB Download file
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")
        }
    }
}