← poker-tutor
import PokerCore
import PokerAI
import TestKit
func botPlayTests(_ t: TestRun) {
t.suite("bots at the table") { t in
t.test("bots never produce an illegal action") {
// The engine throws on anything illegal, so this plays a large number of varied hands
// and asserts none of them wedged or threw.
var rng = SeededRandom(seed: 606)
var handsPlayed = 0
var showdowns = 0
for round in 0..<400 {
let count = 2 + Int(rng.next() % 7)
var lineupRng = SeededRandom(seed: rng.next())
let lineup = TableComposition.lineup(difficulty: Int(rng.next() % 101), opponents: count, rng: &lineupRng)
let bots = lineup.enumerated().map {
Bot(id: "S\($0.offset)", personality: $0.element.personality, skill: $0.element.skill, seed: rng.next())
}
let stacks = (0..<count).map { _ in 300 + Int(rng.next() % 20_000) }
let starting = stacks.reduce(0, +)
let seats = stacks.enumerated().map { SeatState(seat: $0.offset, playerID: "S\($0.offset)", stack: $0.element) }
let level = BlindLevel(smallBlind: 50, bigBlind: 100,
ante: round % 3 == 0 ? .bigBlindAnte(100) : .none)
let config = HandConfig(level: level, buttonSeat: Int(rng.next() % UInt64(count)), seed: rng.next())
let engine = HandEngine(seats: seats, config: config)
let table = BotTable(bots: bots, seed: rng.next())
table.play(engine)
t.expect(engine.isComplete, "hand \(round) did not finish")
t.equal(engine.seats.reduce(0) { $0 + $1.stack }, starting, "chips changed on hand \(round)")
handsPlayed += 1
if engine.result?.wentToShowdown == true { showdowns += 1 }
}
t.equal(handsPlayed, 400)
t.greaterThan(showdowns, 40, "bots should reach showdown sometimes")
}
t.test("a bot cannot see anyone else's cards") {
let seats = makeSeats([10_000, 10_000, 10_000])
let engine = HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: 0, seed: 5))
let seat = engine.actor!
let view = engine.view(for: seat)!
t.equal(view.holeCards.count, 2)
for other in view.seats where other.seat != seat {
t.expect(other.hole == nil, "seat \(other.seat)'s cards leaked into the view")
}
t.expect(view.seats.first { $0.seat == seat }?.hole != nil, "a player can see its own cards")
}
t.test("the view reports the table honestly") {
let seats = makeSeats([10_000, 4_000, 10_000])
let engine = HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: 0, seed: 5))
let view = engine.currentView!
t.equal(view.pot, 150)
t.equal(view.effectiveStack, 10_000, "under the gun is covered by the big stacks behind")
t.equal(view.opponentCount, 2)
t.equal(view.playersDealtIn, 3)
t.expect(view.isUnopenedPot)
t.equal(view.preflopRaiseCount, 0)
t.close(view.effectiveStackInBB, 100, 0.01)
try! engine.apply(.raise(to: 300))
let next = engine.currentView!
t.equal(next.preflopRaiseCount, 1)
t.equal(next.lastAggressor, 0)
t.expect(!next.isUnopenedPot)
t.equal(next.toCall, 250, "the small blind already has 50 in")
t.close(next.potOdds, 250.0 / 700.0, 0.001)
}
t.test("styles show up in the statistics") {
// Play each style at a full table and check its measured tendencies match its label.
func measure(_ personality: Personality, skill: Int) -> PlayerStats {
var bots = [Bot(id: "hero", personality: personality, skill: skill, seed: 11)]
for i in 1..<6 {
bots.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 55, seed: UInt64(i) &* 313))
}
let table = BotTable(bots: bots, seed: 77)
var rng = SeededRandom(seed: 424)
for hand in 0..<400 {
let seats = bots.enumerated().map { SeatState(seat: $0.offset, playerID: $0.element.id, stack: 10_000) }
let config = HandConfig(level: noAnte, buttonSeat: hand % 6, seed: rng.next())
table.play(HandEngine(seats: seats, config: config))
}
return table.models["hero"]
}
let nit = measure(.nit, skill: 30)
let station = measure(.callingStation, skill: 20)
let lag = measure(.looseAggressive, skill: 60)
t.lessThan(nit.vpip ?? 1, 0.25, "a nit should play well under a quarter of hands")
t.greaterThan(station.vpip ?? 0, 0.45, "a station should play most hands")
t.greaterThan(lag.vpip ?? 0, nit.vpip ?? 1, "a loose player plays more hands than a nit")
t.greaterThan(lag.pfr ?? 0, station.pfr ?? 1, "an aggressive player raises more than a station")
t.lessThan(station.foldToBet ?? 1, 0.45, "a station does not fold")
t.greaterThan(nit.foldToBet ?? 0, station.foldToBet ?? 1, "a nit folds more than a station")
}
t.test("aggression is measurable on the players who have it") {
// The maniac's aggression statistic once could not be computed at all, because the
// sample gate counted only passive actions and a maniac barely makes any. The one
// statistic that identifies the most aggressive player at the table was permanently
// unavailable for exactly that player.
var bots = [Bot(id: "maniac", personality: .maniac, skill: 15, seed: 3)]
for i in 1..<6 { bots.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 60, seed: UInt64(i) &* 71)) }
let table = BotTable(bots: bots, seed: 5)
var rng = SeededRandom(seed: 9)
for hand in 0..<300 {
let seats = bots.enumerated().map { SeatState(seat: $0.offset, playerID: $0.element.id, stack: 10_000) }
table.play(HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: hand % 6, seed: rng.next())))
}
let maniac = table.models["maniac"]
t.expect(maniac.aggressionFactor != nil, "a maniac's aggression must be measurable")
t.greaterThan(maniac.aggressionFactor ?? 0, 1.3, "and it should be high")
t.expect(maniac.isHyperAggressive, "a maniac should be recognised as one")
t.expect(!table.models["V1"].isHyperAggressive, "a solid regular should not be")
}
}
}
func shootoutTests(_ t: TestRun) {
t.suite("the opponents at a fixed-limit table") { t in
// The cash app offers fixed-limit, where a bet is one size and the edges are smaller by
// nature. Strategy tuning for it is Phase 3; this only asks that the bots play it without
// wedging and that a good player still beats a bad table there. Measured 2026-09-11 with
// `pokerlab shootout --structure fixedLimit --hands 6000`: solid regular (90) against a
// table of calling stations +18 bb/100, against loose passives +13, against maniacs +17.
t.test("a solid regular beats a table of calling stations at fixed-limit") {
var total = 0.0
for seed in [5, 6, 7] as [UInt64] {
let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 31)
let r = Shootout.ring(hero, versus: .callingStation, villainSkill: 15, seats: 6,
hands: 600, seed: seed, structure: .fixedLimit)
total += r.bigBlindsPer100
}
t.greaterThan(total / 3, 5, "three seeds of 600 hands average \(total / 3) bb/100")
}
}
t.suite("does skill actually mean anything") { t in
// If a higher skill number does not win money, "the table gets harder as you improve" is a
// label on a number that changes nothing, and every phase built on top inherits that.
//
// These use duplicated matches: every hand is dealt to both sides, so the cards cancel out
// and what is left is how the hands were played. Without that, the noise from the deck is
// larger than any real edge for tens of thousands of hands.
t.test("a competent player beats every recreational style at a full table") {
// Six-handed, which is the format the app is being built for. The margins here are
// large and hold at every seed — this is the claim the difficulty ladder rests on.
let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 5)
let cases: [(Personality, Int, Double)] = [
(.callingStation, 15, 30.0),
(.loosePassive, 15, 15.0),
(.maniac, 15, 30.0),
(.nit, 25, 0.0),
]
for (style, skill, floor) in cases {
let r = Shootout.ring(hero, versus: style, villainSkill: skill, seats: 6, hands: 300, seed: 57)
t.greaterThan(r.bigBlindsPer100, floor,
"a solid regular should beat a table of \(style.name.lowercased()) (got \(String(format: "%.1f", r.bigBlindsPer100)) bb/100)")
}
}
t.test("a better player beats a worse one heads-up") {
let strong = Bot(id: "strong", personality: .solidRegular, skill: 95, seed: 1)
let weak = Bot(id: "weak", personality: .callingStation, skill: 5, seed: 2)
let result = Shootout.duplicate(strong, weak, hands: 800, seed: 31)
t.greaterThan(result.bigBlindsPer100, 20.0,
"a strong regular should crush a calling station (got \(String(format: "%.1f", result.bigBlindsPer100)) bb/100)")
}
t.test("skill pays within a style") {
// Measured across styles rather than one at a time. A single matchup at a testable
// number of hands cannot resolve an edge of a few big blinds per hundred — the sum
// across independent matchups can, and it is the honest way to state the claim:
// being better at the same style wins money, by an amount that is small compared to
// the difference between styles.
var total = 0.0
var wins = 0
let styles: [Personality] = [.callingStation, .nit, .loosePassive, .tightAggressive, .solidRegular, .looseAggressive]
for personality in styles {
let band = personality.skillRange
let expert = Bot(id: "hero", personality: personality, skill: band.upperBound, seed: 11)
let r = Shootout.ring(expert, versus: personality, villainSkill: band.lowerBound,
seats: 6, hands: 250, seed: 57)
total += r.bigBlindsPer100
if r.bigBlindsPer100 > 0 { wins += 1 }
}
t.greaterThan(total, 20.0, "skill should be worth real money across styles (got \(String(format: "%.1f", total)) bb/100 in total)")
t.greaterThan(wins, 3, "and should win in most of them individually (\(wins) of \(styles.count))")
}
t.test("misreading your own hand is what costs the money") {
// The backbone of the skill dial. Every other parameter describes a style; this one
// describes being worse at poker. Two players identical but for how accurately they
// judge their hand — the accurate one must win.
//
// Measured over several seeds, and enough hands to mean something. This was a single
// 700-hand match, which turned out to swing between 1 and 26 bb/100 depending only on
// the seed — it passed on luck. Duplication removes the luck in the cards; it does not
// remove the luck in a few hundred decisions.
//
// The sample and the bars below were both measured rather than picked. A sweep of 24
// seeds at 2,500 hands ran from -2.6 to +38.4 bb/100 — one seed in twenty-four had the
// accurate reader losing — so the old bar of "every seed above nought" was not merely
// loose, it was a coin toss away from failing on a build that had nothing wrong with
// it. The same 24 seeds at 6,000 hands ran from 12.8 to 37.7 with a mean of 23.8, which
// is the 20-odd bb/100 the README records. So: six seeds at 6,000 hands, a per-seed bar
// at 5.0 — under half the worst of the 24 measured — and a mean bar at 15.0, which is
// three and a half standard errors under the measured mean. A regression that took two
// thirds off the skill dial lands well below both; ordinary seed luck does not.
var edges: [Double] = []
for seed in [8, 44, 512, 3, 77, 1234] as [UInt64] {
var accurate = Bot(id: "accurate", personality: .solidRegular, skill: 70, seed: 21)
accurate.parameters.readError = 0.0
var blurry = Bot(id: "blurry", personality: .solidRegular, skill: 70, seed: 22)
blurry.parameters.readError = 0.30
edges.append(Shootout.duplicate(accurate, blurry, hands: 6_000, seed: seed).bigBlindsPer100)
}
let printed = edges.map { String(format: "%.1f", $0) }.joined(separator: ", ")
let mean = edges.reduce(0, +) / Double(edges.count)
for edge in edges {
t.greaterThan(edge, 5.0,
"reading hands accurately must beat reading them badly (got \(printed) bb/100)")
}
t.greaterThan(mean, 15.0,
"and by an amount worth having (mean of \(printed) bb/100)")
// Printed on the way past, so the number the whole skill dial rests on is visible
// without anybody having to make the test fail to see it.
print(" readError is worth " + String(format: "%.1f", mean)
+ " bb/100 (six seeds: \(printed))")
}
t.test("the result is not an artefact of who sat where") {
// Swapping the arguments must flip the sign, or the harness is measuring seats.
let strong = Bot(id: "strong", personality: .solidRegular, skill: 90, seed: 5)
let weak = Bot(id: "weak", personality: .loosePassive, skill: 10, seed: 6)
let forward = Shootout.duplicate(strong, weak, hands: 500, seed: 88)
let reversed = Shootout.duplicate(weak, strong, hands: 500, seed: 88)
t.greaterThan(forward.bigBlindsPer100, 0)
t.lessThan(reversed.bigBlindsPer100, 0)
}
t.test("duplicate matching cancels the cards") {
// Two identical bots must measure at exactly zero. The seat's random seed is reset
// each hand and follows the seat rather than the player, so the second pass is an
// exact mirror of the first. If this ever drifts, every other number here is luck.
let a = Bot(id: "a", personality: .solidRegular, skill: 70, seed: 41)
let b = Bot(id: "b", personality: .solidRegular, skill: 70, seed: 41)
let duplicated = Shootout.duplicate(a, b, hands: 400, seed: 13)
t.equal(duplicated.netChips, 0, "identical bots must break exactly even, not approximately")
}
t.test("a soft table pays better than a tough one") {
let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 9)
let easy = Shootout.ring(hero, versus: .callingStation, villainSkill: 10, seats: 6, hands: 250, seed: 12)
let hard = Shootout.ring(hero, versus: .trickyRegular, villainSkill: 100, seats: 6, hands: 250, seed: 12)
t.greaterThan(easy.bigBlindsPer100, hard.bigBlindsPer100 + 20,
"beating a soft table should pay far better than beating a tough one")
t.greaterThan(easy.bigBlindsPer100, 0, "a soft table should be beatable")
}
t.test("the ladder has a top") {
// The promotion mechanic depends on there being a difficulty at which a given level of
// play stops winning. Otherwise the ladder never ends.
let hero = Bot(id: "hero", personality: .tightAggressive, skill: 40, seed: 33)
let soft = Shootout.ring(hero, versus: .loosePassive, villainSkill: 15, seats: 6, hands: 250, seed: 44)
let tough = Shootout.ring(hero, versus: .trickyRegular, villainSkill: 100, seats: 6, hands: 250, seed: 44)
t.greaterThan(soft.bigBlindsPer100, 10, "a mediocre player should beat a bad table")
t.lessThan(tough.bigBlindsPer100, soft.bigBlindsPer100 - 15, "and do much worse against a good one")
}
t.test("results are reproducible") {
let a = Bot(id: "a", personality: .tightAggressive, skill: 60, seed: 21)
let b = Bot(id: "b", personality: .nit, skill: 25, seed: 22)
t.equal(Shootout.duplicate(a, b, hands: 200, seed: 4242).netChips,
Shootout.duplicate(a, b, hands: 200, seed: 4242).netChips,
"the same seed must give the same match")
}
}
}