← poker-tutor
import PokerCore
import PokerAI
import PokerSession
import TestKit
private func legal(min: Int, max: Int, canRaise: Bool = true) -> LegalActions {
LegalActions(seat: 0, canFold: true, canCheck: false, canCall: true, callCost: 100,
callIsAllIn: false, canBet: false, canRaise: canRaise,
minRaiseTo: min, maxRaiseTo: max)
}
func chipTrayTests(_ t: TestRun) {
t.suite("betting with chips") { t in
t.test("the chips are the ones you can afford") {
let deep = ChipTray(minimum: 100, maximum: 10_000)
t.equal(deep.offered.map(\.value), [25, 50, 100, 500, 1_000, 5_000, 10_000])
let short = ChipTray(minimum: 100, maximum: 600)
t.equal(short.offered.map(\.value), [25, 50, 100, 500],
"a chip bigger than the stack is a button that can never be pressed")
let tiny = ChipTray(minimum: 20, maximum: 30)
t.equal(tiny.offered.map(\.value), [25])
}
t.test("counting a bet out") {
// The example that started this: two hundreds and a fifty is 250.
let tray = ChipTray(minimum: 100, maximum: 10_000)
var amount = 0
amount = tray.adding(.hundred, to: amount)
amount = tray.adding(.hundred, to: amount)
amount = tray.adding(.fifty, to: amount)
t.equal(amount, 250)
t.expect(tray.isLegal(amount))
}
t.test("you cannot bet more than you have") {
let tray = ChipTray(minimum: 100, maximum: 1_200)
t.expect(tray.canAdd(.thousand, to: 0))
t.expect(!tray.canAdd(.thousand, to: 500), "1,500 out of a 1,200 stack")
t.equal(tray.adding(.thousand, to: 500), 500, "an impossible chip must change nothing")
t.expect(tray.canAdd(.hundred, to: 1_100))
t.expect(!tray.canAdd(.hundred, to: 1_150))
t.expect(!tray.isLegal(1_300))
}
t.test("a bet under the minimum is not ready yet") {
let tray = ChipTray(minimum: 300, maximum: 10_000)
t.expect(!tray.isLegal(250))
t.equal(tray.shortfall(250), 50)
t.equal(tray.shortfall(275), 25)
t.expect(tray.shortfall(300) == nil, "exactly the minimum is a legal bet")
t.expect(tray.shortfall(9_000) == nil)
t.expect(tray.isLegal(300))
}
t.test("counting out the all in, and the odd minimum") {
let tray = ChipTray(minimum: 100, maximum: 6_575)
let all = tray.quickest(tray.maximum)
t.equal(tray.total(all), 6_575, "the all in has to be exact")
t.expect(tray.isLegal(tray.total(all)))
t.equal(all.count, 5, "5,000 + 1,000 + 500 + 50 + 25 is the shortest way to 6,575")
// A minimum raise that chips cannot hit exactly must round up to a legal bet, never
// down to an illegal one.
let awkward = ChipTray(minimum: 137, maximum: 10_000)
let counted = awkward.quickest(awkward.minimum)
t.expect(awkward.total(counted) >= 137, "counted out less than the minimum raise")
t.expect(awkward.isLegal(awkward.total(counted)))
t.equal(awkward.total(counted), 150)
}
t.test("counting out never exceeds the stack") {
let tray = ChipTray(minimum: 90, maximum: 90)
let counted = tray.quickest(90)
t.expect(tray.total(counted) <= 90, "counted out more chips than the player has")
for chip in Chip.allCases {
t.expect(!tray.canAdd(chip, to: 90), "\(chip.label) fitted into a full stack")
}
}
t.test("chips read the way they are said") {
t.equal(Chip.twentyFive.label, "25")
t.equal(Chip.hundred.label, "100")
t.equal(Chip.thousand.label, "1K")
t.equal(Chip.fiveThousand.label, "5K")
t.equal(Chip.tenThousand.label, "10K")
}
t.test("there is no tray when raising is not allowed") {
t.expect(ChipTray(legal: legal(min: 0, max: 0, canRaise: false)) == nil)
t.expect(ChipTray(legal: legal(min: 200, max: 4_000)) != nil)
}
t.test("every blind level is payable in the chips on the table") {
let ladder = TournamentStructure.blindLadder
t.equal(ladder.first?.small, 25)
t.equal(ladder.first?.big, 50)
t.equal(Array(ladder.prefix(8).map(\.big)),
[50, 100, 200, 400, 1_000, 2_000, 4_000, 8_000],
"the published ladder")
let smallest = Chip.twentyFive.value
for level in ladder {
t.equal(level.small % smallest, 0, "a \(level.small) small blind cannot be paid in chips")
t.equal(level.big % smallest, 0, "a \(level.big) big blind cannot be paid in chips")
t.equal(level.big, level.small * 2, "the big blind is two small blinds")
}
for (a, b) in zip(ladder, ladder.dropFirst()) {
t.lessThan(a.big, b.big, "the blinds must always go up")
}
// And the same for the ante, which is charged as a big blind.
let structure = TournamentStructure.standard()
for level in structure.levels {
if case .bigBlindAnte(let ante) = level.ante {
t.equal(ante % smallest, 0, "a \(ante) ante cannot be paid in chips")
}
}
}
t.test("nobody at the table bets in chips that do not exist") {
// The point of the whole exercise: an opponent must never push out an amount the
// player could not have counted out themselves. Checked at the moment of the decision,
// because an all in that nobody calls is refunded afterwards and stops looking like one.
let smallest = Chip.twentyFive.value
var checked = 0
var allIns = 0
for seed in [3, 19, 77] as [UInt64] {
let structure = TournamentStructure.standard()
let ids = (0..<9).map { "P\($0)" }
let tournament = Tournament(structure: structure, playerIDs: ids, seed: seed)
let bots = ids.map { Bot(id: $0, personality: .solidRegular, skill: 55, seed: seed &+ 7) }
let table = BotTable(bots: bots, seed: seed)
var hands = 0
while let (seats, config) = tournament.nextHandSeats(), hands < 120 {
let engine = HandEngine(seats: seats, config: config)
while !engine.isComplete, let seat = engine.actor {
let shove = engine.view(for: seat)?.legal.maxRaiseTo ?? 0
guard let action = table.step(engine) else { break }
switch action {
case .bet(let to), .raise(let to):
if to == shove {
// A stack is whatever it is, so an all in can land anywhere.
allIns += 1
} else {
t.equal(to % smallest, 0,
"a bet of \(to) at \(config.level.bigBlind) blinds cannot be made from chips")
checked += 1
}
default: break
}
}
table.observeFinished(engine)
let seatIDs = Dictionary(uniqueKeysWithValues: engine.seats.map { ($0.seat, $0.playerID) })
tournament.recordHand(finalStacks: engine.result!.finalStacks, seatIDs: seatIDs)
hands += 1
}
}
t.expect(checked > 200, "only \(checked) ordinary opponent bets were checked")
t.expect(allIns > 0, "no all ins happened, so that exemption went untested")
}
t.test("a real hand always offers a way to make a legal bet") {
let session = TableSession(seed: 88)
session.pacedByCaller = true
session.start(.tournament)
var spots = 0
var guardRail = 0
while session.isPlaying, session.handNumber <= 40, spots < 25 {
guardRail += 1
precondition(guardRail < 40_000)
if session.stepOpponent() != nil { continue }
guard let legal = session.legal else {
if session.review != nil, session.engine?.isComplete == true {
if session.handNumber >= 40 { break }
session.continueAfterReview()
continue
}
break
}
if legal.canBetOrRaise {
guard let tray = session.chipTray else {
t.expect(false, "no chips offered in a spot that allows raising")
break
}
t.expect(!tray.offered.isEmpty, "a raise was legal but no chip was small enough")
// Whatever the spot, the player must be able to build something legal — the
// minimum raise at the very least.
let counted = tray.total(tray.quickest(tray.minimum))
t.expect(tray.isLegal(counted),
"cannot count out a legal bet: min \(tray.minimum), max \(tray.maximum)")
spots += 1
} else {
t.expect(session.chipTray == nil, "chips offered when raising was not allowed")
}
session.act(legal.canCheck ? .check : .fold)
}
t.expect(spots > 0, "never reached a spot where raising was possible")
}
}
}