← poker-tutor
import PokerCore
import PokerAI
import TestKit
/// The two betting structures the cash apps add. No-limit is covered by "betting rules"; every
/// number below was worked out by hand from the rule before the code was asked.
func structureTests(_ t: TestRun) {
t.suite("pot-limit betting") { t in
// The most you may raise BY is the pot as it stands after your call, so the most you may
// raise TO is the current bet plus that pot. At 25/50 that makes the first raise 175
// (3.5 big blinds) and a re-raise of a pot raise 600 (12 big blinds).
let pl = BlindLevel(smallBlind: 25, bigBlind: 50)
func pot(_ stacks: [Int], button: Int = 0, level: BlindLevel = pl) -> HandEngine {
engine(stacks: stacks, button: button, level: level, structure: .potLimit)
}
t.test("the first raise may be three and a half big blinds") {
let e = pot([5000, 5000, 5000, 5000])
let legal = e.legalActions()!
t.equal(legal.seat, 3, "under the gun")
t.equal(legal.maxRaiseTo, 175, "50 to call, then 75 + 50 = 125 more: raise to 175")
t.equal(legal.minRaiseTo, 100)
t.equal(legal.allInTo, 5000)
t.expect(!legal.maxIsAllIn)
}
t.test("re-raising a pot raise is twelve big blinds") {
let e = pot([5000, 5000, 5000, 5000])
try! e.apply(.raise(to: 175))
let legal = e.legalActions()!
t.equal(legal.callCost, 175)
t.equal(legal.maxRaiseTo, 600, "175 to call makes the pot 425; 425 more on top: 600")
try! e.apply(.raise(to: 600))
let next = e.legalActions()!
t.equal(next.callCost, 575)
t.equal(next.maxRaiseTo, 2025, "call 575 into 850 makes 1425; 1425 more: 2025")
}
t.test("a big blind posted short does not lower the price for everyone else") {
let e = pot([5000, 5000, 30])
let legal = e.legalActions()!
t.equal(legal.seat, 0)
t.equal(legal.callCost, 50, "the whole big blind, not the short one")
t.equal(legal.minRaiseTo, 100, "a raise is to two big blinds, whatever the short seat posted")
try! e.apply(.call)
try! e.apply(.call)
let pots = PotBuilder.build(from: e.seats, dead: 0)
t.equal(pots.map(\.amount), [90, 40], "30 from each of three, then 20 more from the two who could")
t.expect(!pots[1].eligible.contains(2), "the short seat cannot win chips it did not match")
}
t.test("raising above the pot is refused, calling and potting are not") {
let e = pot([5000, 5000, 5000, 5000])
do {
try e.apply(.raise(to: 176))
t.expect(false, "176 is one chip over the pot and should have been refused")
} catch {
t.expect("\(error)".contains("pot limit"), "the refusal names the pot limit: \(error)")
}
try! e.apply(.raise(to: 175))
t.equal(e.currentBet, 175)
}
t.test("an opening bet after the flop is the size of the pot") {
let e = pot([5000, 5000, 5000])
try! e.apply(.call); try! e.apply(.call); try! e.apply(.check)
t.equal(e.street, .flop)
t.equal(e.pot, 150)
let legal = e.legalActions()!
t.expect(legal.canBet)
t.equal(legal.maxRaiseTo, 150, "a bet of the pot")
t.equal(legal.minRaiseTo, 50)
try! e.apply(.bet(to: 150))
let raise = e.legalActions()!
t.equal(raise.maxRaiseTo, 600, "call 150 into 300 makes 450; 450 more: 600")
}
t.test("antes count as part of the pot") {
let level = BlindLevel(smallBlind: 25, bigBlind: 50, ante: .bigBlindAnte(50))
let e = pot([5000, 5000, 5000, 5000], level: level)
t.equal(e.pot, 125)
t.equal(e.legalActions()!.maxRaiseTo, 225, "the ante is in the middle, so the raise is bigger")
}
t.test("a stack shorter than the pot may still go all in") {
let e = pot([5000, 5000, 5000, 120])
let legal = e.legalActions()!
t.equal(legal.seat, 3)
t.equal(legal.allInTo, 120)
t.equal(legal.maxRaiseTo, 120, "less than the 175 the pot allows")
t.expect(legal.maxIsAllIn)
try! e.apply(.raise(to: 120))
t.equal(e.seats[3].status, .allIn)
}
t.test("a raise over a short all-in is capped by the pot that call makes") {
let e = pot([5000, 5000, 5000, 120])
try! e.apply(.raise(to: 120))
let legal = e.legalActions()!
t.equal(legal.callCost, 120)
t.equal(legal.maxRaiseTo, 435, "call 120 into 195 makes 315; 315 more: 435")
t.equal(legal.minRaiseTo, 190, "120 was a raise of 70 over the blind, a full raise, so the minimum is 70 more again")
}
t.test("heads-up the button may make it three big blinds") {
let e = pot([5000, 5000])
let legal = e.legalActions()!
t.equal(legal.seat, 0, "the button posts the small blind and acts first")
t.equal(legal.callCost, 25)
t.equal(legal.maxRaiseTo, 150, "25 to call makes 100; 100 more: 150")
}
t.test("the maximum is always at least the minimum and never above the stack") {
var rng = SeededRandom(seed: 3)
for seed in 1...40 {
let stacks = (0..<6).map { _ in Int(200 + rng.next() % 3000) }
let e = HandEngine(seats: makeSeats(stacks),
config: HandConfig(level: pl, buttonSeat: seed % 6, seed: UInt64(seed), structure: .potLimit))
playOut(e) { legal, _ in
if legal.canBetOrRaise {
t.expect(legal.maxRaiseTo >= legal.minRaiseTo, "max \(legal.maxRaiseTo) below min \(legal.minRaiseTo)")
t.expect(legal.maxRaiseTo <= legal.allInTo, "the cap never exceeds the stack")
}
if rng.next() % 3 == 0, legal.canBetOrRaise {
return legal.canBet ? .bet(to: legal.maxRaiseTo) : .raise(to: legal.maxRaiseTo)
}
return legal.canCheck ? .check : .call
}
t.equal(e.result!.finalStacks.values.reduce(0, +), stacks.reduce(0, +), "chips conserved")
}
}
}
t.suite("fixed-limit betting") { t in
// At 50/100 the small bet is 100 (before the flop and on it) and the big bet 200 (turn
// and river). A bet and three raises cap a round — 100, 200, 300, 400 before the flop,
// where the blind is the first bet — unless only two players are left in the hand.
let fl = BlindLevel(smallBlind: 50, bigBlind: 100)
func fixed(_ stacks: [Int], button: Int = 0, level: BlindLevel = fl) -> HandEngine {
engine(stacks: stacks, button: button, level: level, structure: .fixedLimit)
}
func toFlop(_ e: HandEngine) {
// Everyone calls, the big blind checks.
while e.street == .preflop, let legal = e.legalActions() {
try! e.apply(legal.canCheck ? .check : .call)
}
}
t.test("there is one size, and it is the big blind before the flop") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
let legal = e.legalActions()!
t.equal(legal.seat, 3)
t.equal(legal.callCost, 100)
t.equal(legal.minRaiseTo, 200)
t.equal(legal.maxRaiseTo, 200, "a raise is to exactly two bets")
t.expect(legal.sizeIsFixed)
t.expect(!legal.capped)
t.equal(legal.allInTo, 10_000)
}
t.test("a raise of any other size is refused") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
for wrong in [150, 250, 400] {
do {
try e.apply(.raise(to: wrong))
t.expect(false, "\(wrong) is not the fixed size and should have been refused")
} catch {
t.expect("\(error)".contains("200"), "the refusal names the one legal size: \(error)")
}
}
try! e.apply(.raise(to: 200))
t.equal(e.currentBet, 200)
}
t.test("the blind, a raise, a re-raise and a cap: 100, 200, 300, 400, then nobody may raise") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
try! e.apply(.raise(to: 200)) // seat 3
t.equal(e.legalActions()!.maxRaiseTo, 300, "seat 0 may make it three bets")
try! e.apply(.raise(to: 300)) // seat 0
t.equal(e.legalActions()!.maxRaiseTo, 400, "seat 1 may cap it at four")
try! e.apply(.raise(to: 400)) // seat 1, the cap
let capped = e.legalActions()! // seat 2
t.equal(capped.seat, 2)
t.expect(capped.capped, "four bets are in: the round is capped")
t.expect(!capped.canRaise)
t.expect(capped.canCall)
t.equal(capped.callCost, 300)
do {
try e.apply(.raise(to: 500))
t.expect(false, "a fifth bet should have been refused")
} catch {
t.expect("\(error)".contains("capped"), "the refusal says why: \(error)")
}
}
t.test("heads-up there is no cap") {
let e = fixed([10_000, 10_000])
var level = 200
for _ in 0..<7 { // well past four bets
let legal = e.legalActions()!
t.expect(legal.canRaise, "raising is still open at \(level)")
t.expect(!legal.capped)
t.equal(legal.maxRaiseTo, level)
try! e.apply(.raise(to: level))
level += 100
}
}
t.test("the cap lifts the moment only two players are left in the hand") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
try! e.apply(.raise(to: 200)) // seat 3
try! e.apply(.raise(to: 300)) // seat 0
try! e.apply(.fold) // seat 1
try! e.apply(.fold) // seat 2
try! e.apply(.raise(to: 400)) // seat 3, the fourth bet
let legal = e.legalActions()! // seat 0, heads-up now
t.expect(legal.canRaise, "two left in the hand, so the cap does not apply")
t.expect(!legal.capped)
t.equal(legal.maxRaiseTo, 500)
}
t.test("a short all-in under the size of a bet is offered, and does not count towards the cap") {
let e = fixed([10_000, 10_000, 10_000, 130])
let short = e.legalActions()!
t.equal(short.seat, 3)
t.equal(short.maxRaiseTo, 130, "all it has")
t.equal(short.minRaiseTo, 130)
t.expect(short.maxIsAllIn)
try! e.apply(.raise(to: 130))
let next = e.legalActions()! // seat 0
t.equal(next.callCost, 130)
t.equal(next.maxRaiseTo, 200, "the next full level is still two bets, not 230")
try! e.apply(.raise(to: 200))
try! e.apply(.raise(to: 300))
let fourth = e.legalActions()! // seat 2
t.equal(fourth.maxRaiseTo, 400, "the short all-in was not a bet, so 400 is still the cap, not 300")
try! e.apply(.raise(to: 400))
t.expect(e.legalActions()!.capped)
}
t.test("a stack that covers the bet raises exactly the bet and keeps the rest") {
// There is no such thing as an all-in for more than the fixed size: a 250 stack
// makes it 200 and has 50 behind, however much it would like to shove.
let e = fixed([10_000, 10_000, 10_000, 250])
let legal = e.legalActions()!
t.equal(legal.maxRaiseTo, 200)
t.expect(!legal.maxIsAllIn)
do {
try e.apply(.raise(to: 250))
t.expect(false, "250 is not a fixed-limit size and should have been refused")
} catch {
t.expect("\(error)".contains("200"), "\(error)")
}
try! e.apply(.raise(to: 200))
t.equal(e.seats[3].stack, 50)
t.equal(e.legalActions()!.maxRaiseTo, 300, "the next level is three bets")
}
t.test("the bet doubles on the turn and stays doubled on the river") {
let e = fixed([10_000, 10_000, 10_000])
toFlop(e)
t.equal(e.street, .flop)
t.equal(e.legalActions()!.maxRaiseTo, 100, "small bet on the flop")
try! e.apply(.check); try! e.apply(.check); try! e.apply(.check)
t.equal(e.street, .turn)
let turn = e.legalActions()!
t.expect(turn.canBet)
t.equal(turn.maxRaiseTo, 200, "big bet on the turn")
try! e.apply(.bet(to: 200))
t.equal(e.legalActions()!.maxRaiseTo, 400, "a raise on the turn is two big bets")
try! e.apply(.call); try! e.apply(.call)
t.equal(e.street, .river)
t.equal(e.legalActions()!.maxRaiseTo, 200, "big bet on the river")
}
t.test("the flop caps at four bets too: 100, 200, 300, 400") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
toFlop(e)
try! e.apply(.bet(to: 100)); try! e.apply(.raise(to: 200))
try! e.apply(.raise(to: 300)); try! e.apply(.raise(to: 400))
let legal = e.legalActions()!
t.expect(legal.capped)
t.expect(!legal.canRaise)
}
t.test("the big blind keeps its option to raise when everyone just calls") {
let e = fixed([10_000, 10_000, 10_000, 10_000])
try! e.apply(.call); try! e.apply(.call); try! e.apply(.call) // seats 3, 0, 1
let bb = e.legalActions()!
t.equal(bb.seat, 2)
t.expect(bb.canCheck)
t.expect(bb.canRaise)
t.equal(bb.maxRaiseTo, 200)
}
t.test("an ante is dead money and changes no bet size") {
let level = BlindLevel(smallBlind: 50, bigBlind: 100, ante: .perPlayer(25))
let e = fixed([10_000, 10_000, 10_000, 10_000], level: level)
let legal = e.legalActions()!
t.equal(legal.callCost, 100)
t.equal(legal.maxRaiseTo, 200)
}
t.test("chips are neither created nor destroyed at fixed-limit") {
var rng = SeededRandom(seed: 88)
for hand in 0..<40 {
let stacks = (0..<5).map { _ in 150 + Int(rng.next() % 2000) }
let e = HandEngine(seats: makeSeats(stacks),
config: HandConfig(level: fl, buttonSeat: hand % 5, seed: rng.next(),
settlement: .allInEquity(trials: 60), structure: .fixedLimit))
playOut(e) { legal, _ in
if legal.canBetOrRaise {
t.expect(legal.minRaiseTo == legal.maxRaiseTo, "one size at fixed-limit")
t.expect(legal.maxRaiseTo <= legal.allInTo)
}
if legal.canBetOrRaise, rng.next() % 2 == 0 {
return legal.canBet ? .bet(to: legal.maxRaiseTo) : .raise(to: legal.maxRaiseTo)
}
return legal.canCheck ? .check : .call
}
t.equal(e.result!.finalStacks.values.reduce(0, +), stacks.reduce(0, +), "hand \(hand)")
}
}
t.test("the opponents play legal fixed-limit and legal pot-limit") {
// Every action a bot produces goes straight to the engine, which throws on anything
// illegal. `BotTable` would have hidden a throw behind a passive fallback.
for structure in [BettingStructure.fixedLimit, .potLimit] {
var bots = Personality.allCases.prefix(5).enumerated().map { i, style in
Bot(id: "b\(i)", personality: style, skill: 30 + i * 12, seed: UInt64(i + 1))
}
var illegal = 0
var decisions = 0
for hand in 0..<120 {
let seats = (0..<5).map { SeatState(seat: $0, playerID: "b\($0)", stack: 6_000) }
let e = HandEngine(seats: seats,
config: HandConfig(level: fl, buttonSeat: hand % 5, seed: UInt64(hand + 7),
structure: structure))
var steps = 0
while !e.isComplete, let seat = e.actor, let view = e.view(for: seat) {
let action = bots[seat].decide(view, models: nil)
decisions += 1
do { try e.apply(action) } catch { illegal += 1; try! e.apply(view.legal.canCheck ? .check : .fold) }
steps += 1
precondition(steps < 400)
}
}
t.equal(illegal, 0, "\(structure.name): \(illegal) illegal actions in \(decisions)")
t.greaterThan(decisions, 1000, "\(structure.name): enough decisions to mean anything")
}
}
}
}