← poker-tutor
import Foundation
import PokerCore
/// Runs hands between bots. The app will sit a human in one seat; everything else is the same.
public final class BotTable {
public private(set) var bots: [String: Bot]
public let models: OpponentModels
private var rng: SeededRandom
/// A seat the bots do not control — the human, when there is one.
public var humanSeat: Int?
/// `sharedModels` lets several tables accumulate observations of the same players, which is
/// how a duplicate match keeps one memory across both passes.
public init(bots: [Bot], seed: UInt64, humanSeat: Int? = nil, sharedModels: OpponentModels? = nil) {
self.bots = Dictionary(uniqueKeysWithValues: bots.map { ($0.id, $0) })
self.rng = SeededRandom(seed: seed)
self.humanSeat = humanSeat
self.models = sharedModels ?? OpponentModels()
}
public func bot(_ id: String) -> Bot? { bots[id] }
/// Sits a new player down — a cash table's replacement for somebody who busted twice. The
/// others' reads of everyone else are kept; there is simply one more person to read.
public func seat(_ bot: Bot) { bots[bot.id] = bot }
/// Plays a hand to completion using the bots for every seat. Stops early and returns the
/// engine if it reaches the human's turn.
@discardableResult
public func play(_ engine: HandEngine, observe: Bool = true) -> HandEngine {
var steps = 0
while step(engine) != nil {
steps += 1
precondition(steps < 500, "hand failed to terminate")
}
if observe { observeFinished(engine) }
return engine
}
/// Plays exactly one opponent decision and returns what they did, or nil if it is not an
/// opponent's turn. The app deals them out one at a time so a person can watch the hand
/// happen; everything else calls `play` and gets the same sequence in one go.
@discardableResult
public func step(_ engine: HandEngine) -> PlayerAction? {
guard !engine.isComplete, let seat = engine.actor, seat != humanSeat,
let view = engine.view(for: seat) else { return nil }
let passive: PlayerAction = view.legal.canCheck ? .check : .fold
let id = engine.seats[seat].playerID
guard var bot = bots[id] else {
try? engine.apply(passive)
return passive
}
let action = bot.decide(view, models: models)
bots[id] = bot // keep the bot's random state moving
do {
try engine.apply(action)
return action
} catch {
// The bot cannot produce an illegal action, but if it ever did, the hand must not
// wedge. Fall back to the most passive legal option.
try? engine.apply(passive)
return passive
}
}
/// Lets the opponents update what they think of everyone. Must be called once per finished
/// hand — twice would count the same hand twice in their reads.
public func observeFinished(_ engine: HandEngine) {
guard engine.isComplete else { return }
models.observe(engine)
}
public func nextSeed() -> UInt64 { rng.next() }
}
/// Head-to-head measurement of whether one opponent is genuinely better than another.
///
/// This is the check that the skill dial means something. If a bot at skill 80 does not reliably
/// beat the same style at skill 20, then "the table gets harder" is a label on a number that
/// changes nothing, and every phase built on top of it would inherit the lie.
public enum Shootout {
public struct Result: Sendable {
public let hands: Int
/// Big blinds won per 100 hands by the first bot. Positive means it is winning.
public let bigBlindsPer100: Double
public let netChips: Int
}
/// Plays a heads-up match, resetting stacks each hand so the result measures skill rather
/// than who happened to win the first big pot. The button alternates.
public static func headsUp(_ a: Bot, _ b: Bot, hands: Int, seed: UInt64, startingStack: Int = 10_000,
structure: BettingStructure = .noLimit) -> Result {
let level = BlindLevel(smallBlind: 50, bigBlind: 100)
var table = BotTable(bots: [a, b], seed: seed)
var net = 0
var rng = SeededRandom(seed: seed &* 31 &+ 7)
for hand in 0..<hands {
let seats = [
SeatState(seat: 0, playerID: a.id, stack: startingStack),
SeatState(seat: 1, playerID: b.id, stack: startingStack),
]
let config = HandConfig(level: level, buttonSeat: hand % 2, seed: rng.next(), structure: structure)
let engine = HandEngine(seats: seats, config: config)
table.play(engine)
net += engine.result?.net[0] ?? 0
}
let per100 = Double(net) / Double(level.bigBlind) / Double(hands) * 100
return Result(hands: hands, bigBlindsPer100: per100, netChips: net)
}
/// Plays every hand twice, with the two bots swapping seats and the deck unchanged.
///
/// Poker results are mostly noise. Over four thousand ordinary hands the swing from cards
/// alone is larger than any real skill edge below about ten big blinds per hundred, so a
/// straight match cannot tell "slightly better" from "got the aces". Dealing each hand to
/// both players cancels the cards out: whatever luck one seat got, the other got it too, and
/// what is left is the difference in how they played it. This is the same trick duplicate
/// bridge uses, and it makes a small edge measurable in a fraction of the hands.
public static func duplicate(_ a: Bot, _ b: Bot, hands: Int, seed: UInt64, startingStack: Int = 10_000,
settlement: Settlement = .allInEquity(trials: 400),
structure: BettingStructure = .noLimit) -> Result {
let level = BlindLevel(smallBlind: 50, bigBlind: 100)
var net = 0
var rng = SeededRandom(seed: seed &* 31 &+ 7)
let models = OpponentModels()
for hand in 0..<hands {
let deckSeed = rng.next()
let seatSeeds = [rng.next(), rng.next()]
let button = hand % 2
// The random seed follows the *seat*, not the player, and is reset for every hand.
// That makes the second pass an exact mirror of the first: whoever sits in seat one
// holds the same cards and draws the same coin flips as the player who sat there
// before. Two identical bots then measure at precisely zero, which is the only way
// to know the harness is reporting skill rather than luck.
func seat(_ index: Int, _ bot: Bot) -> Bot {
Bot(copying: bot, seed: seatSeeds[index])
}
// Pass one: A in seat 0.
let first = HandEngine(
seats: [SeatState(seat: 0, playerID: a.id, stack: startingStack),
SeatState(seat: 1, playerID: b.id, stack: startingStack)],
config: HandConfig(level: level, buttonSeat: button, seed: deckSeed, settlement: settlement, structure: structure)
)
BotTable(bots: [seat(0, a), seat(1, b)], seed: deckSeed, sharedModels: models).play(first)
net += first.result?.net[0] ?? 0
// Pass two: same cards, same seat randomness, seats swapped.
let second = HandEngine(
seats: [SeatState(seat: 0, playerID: b.id, stack: startingStack),
SeatState(seat: 1, playerID: a.id, stack: startingStack)],
config: HandConfig(level: level, buttonSeat: button, seed: deckSeed, settlement: settlement, structure: structure)
)
BotTable(bots: [seat(0, b), seat(1, a)], seed: deckSeed, sharedModels: models).play(second)
net += second.result?.net[1] ?? 0
}
let played = hands * 2
let per100 = Double(net) / Double(level.bigBlind) / Double(played) * 100
return Result(hands: played, bigBlindsPer100: per100, netChips: net)
}
/// Seats one bot against a table of another, rotating it through every seat on the same deal.
///
/// The same idea as the heads-up duplicate, extended to a full table: each hand is played once
/// per seat, so the measured player holds every set of cards exactly once. Position and card
/// luck both wash out and what is left is how the hands were played.
public static func ring(_ hero: Bot, versus villain: Personality, villainSkill: Int, seats: Int,
hands: Int, seed: UInt64, startingStack: Int = 10_000,
settlement: Settlement = .allInEquity(trials: 400),
structure: BettingStructure = .noLimit) -> Result {
let level = BlindLevel(smallBlind: 50, bigBlind: 100)
let models = OpponentModels()
var net = 0
var rng = SeededRandom(seed: seed &* 17 &+ 3)
for hand in 0..<hands {
let deckSeed = rng.next()
let seatSeeds = (0..<seats).map { _ in rng.next() }
let button = hand % seats
for heroSeat in 0..<seats {
var lineup: [Bot] = []
var ids: [String] = []
var villainIndex = 0
for index in 0..<seats {
if index == heroSeat {
lineup.append(Bot(copying: hero, seed: seatSeeds[index]))
ids.append(hero.id)
} else {
let id = "V\(villainIndex)"
villainIndex += 1
lineup.append(Bot(id: id, personality: villain, skill: villainSkill, seed: seatSeeds[index]))
ids.append(id)
}
}
let states = ids.enumerated().map { SeatState(seat: $0.offset, playerID: $0.element, stack: startingStack) }
let engine = HandEngine(
seats: states,
config: HandConfig(level: level, buttonSeat: button, seed: deckSeed, settlement: settlement, structure: structure)
)
BotTable(bots: lineup, seed: deckSeed, sharedModels: models).play(engine)
net += engine.result?.net[heroSeat] ?? 0
}
}
let played = hands * seats
let per100 = Double(net) / Double(level.bigBlind) / Double(played) * 100
return Result(hands: played, bigBlindsPer100: per100, netChips: net)
}
}