← poker-tutor
import Foundation
/// A blind schedule plus the payout ladder. Everything that makes a tournament different
/// from a cash game lives here or in `ICM`.
public struct TournamentStructure: Sendable {
public let levels: [BlindLevel]
public let handsPerLevel: Int
public let startingStack: Int
/// Share of the prize pool for each finishing place, biggest first. Must sum to 1.
public let payouts: [Double]
public init(levels: [BlindLevel], handsPerLevel: Int, startingStack: Int, payouts: [Double]) {
precondition(!levels.isEmpty, "a structure needs at least one level")
precondition(abs(payouts.reduce(0, +) - 1.0) < 0.0001, "payouts must sum to 1")
self.levels = levels
self.handsPerLevel = handsPerLevel
self.startingStack = startingStack
self.payouts = payouts
}
public func level(atHand hand: Int) -> BlindLevel {
let index = min(hand / handsPerLevel, levels.count - 1)
return levels[index]
}
/// How many players get paid.
public var placesPaid: Int { payouts.count }
/// The blind ladder, written out rather than generated.
///
/// Every level is payable in the chips that are actually on the table — a 150 blind is not a
/// level anyone would run when the smallest chip is 25 and a rack of them is a nuisance to
/// count. This is close to what a real clock looks like: it roughly doubles, and where doubling
/// would land on an awkward number it goes to the next round one instead.
public static let blindLadder: [(small: Int, big: Int)] = [
(25, 50),
(50, 100),
(100, 200),
(200, 400),
(500, 1_000),
(1_000, 2_000),
(2_000, 4_000),
(4_000, 8_000),
(5_000, 10_000),
(10_000, 20_000),
(20_000, 40_000),
(50_000, 100_000),
]
/// A standard escalating structure, on the published ladder, with a big-blind ante from the
/// third level on.
public static func standard(startingStack: Int = 10_000, handsPerLevel: Int = 12, placesPaid: Int = 3, entrants: Int = 9) -> TournamentStructure {
let levels = blindLadder.enumerated().map { step, level in
BlindLevel(smallBlind: level.small,
bigBlind: level.big,
ante: step >= 2 ? .bigBlindAnte(level.big) : .none)
}
return TournamentStructure(
levels: levels,
handsPerLevel: handsPerLevel,
startingStack: startingStack,
payouts: payoutLadder(places: min(placesPaid, entrants))
)
}
/// Snaps blinds to values a dealer would actually use.
public static func roundToChip(_ value: Int) -> Int {
let magnitude = max(1, Int(pow(10.0, floor(log10(Double(value))) - 1)))
return max(2, (value / magnitude) * magnitude)
}
/// Top-heavy but not brutal, which is roughly how small-field tournaments pay.
public static func payoutLadder(places: Int) -> [Double] {
switch max(1, places) {
case 1: return [1.0]
case 2: return [0.65, 0.35]
case 3: return [0.50, 0.30, 0.20]
case 4: return [0.42, 0.26, 0.19, 0.13]
default:
var weights = (1...places).map { 1.0 / pow(Double($0), 0.9) }
let total = weights.reduce(0, +)
weights = weights.map { $0 / total }
return weights
}
}
}
/// Independent Chip Model: what a stack is worth in prize money rather than in chips.
///
/// This is why tournament play differs from cash. Doubling your stack does not double your
/// equity, so marginal calls that print money in a cash game lose money on a bubble. The coach
/// uses this to judge decisions near the money.
public enum ICM {
/// Each stack's share of the prize pool, in the same order as `stacks`.
/// Uses the standard recursive finish-order model, capped for larger fields.
public static func equity(stacks: [Int], payouts: [Double]) -> [Double] {
let total = stacks.reduce(0, +)
guard total > 0 else { return stacks.map { _ in 0 } }
let places = min(payouts.count, stacks.count)
guard places > 0 else { return stacks.map { _ in 0 } }
// The exact recursion is factorial in the number of paid places; past six it costs more
// than the extra precision is worth.
let depth = min(places, 6)
var result = [Double](repeating: 0, count: stacks.count)
let chips = stacks.map(Double.init)
// Probability of finishing in each place is that player's share of the chips still in play,
// recursed down the ladder.
func walk(remaining: [Int], place: Int, probability: Double) {
guard place < depth, probability > 1e-9 else { return }
let sum = remaining.reduce(0.0) { $0 + chips[$1] }
guard sum > 0 else { return }
for player in remaining {
let chance = chips[player] / sum
result[player] += probability * chance * payouts[place]
if place + 1 < depth {
walk(remaining: remaining.filter { $0 != player },
place: place + 1,
probability: probability * chance)
}
}
}
walk(remaining: Array(stacks.indices), place: 0, probability: 1.0)
// Anything the truncated recursion left unassigned is split by chip share, which is what
// deep-ladder equity converges to anyway.
let leftover = payouts.prefix(places).reduce(0, +) - result.reduce(0, +)
if leftover > 1e-9 {
for i in stacks.indices { result[i] += leftover * Double(stacks[i]) / Double(total) }
}
return result
}
}
/// One player's standing in a running tournament.
public struct TournamentPlayer: Sendable {
public let id: String
public var stack: Int
public var finishedPlace: Int?
public init(id: String, stack: Int, finishedPlace: Int? = nil) {
self.id = id
self.stack = stack
self.finishedPlace = finishedPlace
}
/// Still has chips. The eventual winner keeps a stack *and* a finishing place of 1.
public var isAlive: Bool { stack > 0 }
}
/// Runs a single-table tournament to completion: blinds go up, players bust, the button moves.
public final class Tournament {
public let structure: TournamentStructure
public private(set) var players: [TournamentPlayer]
public private(set) var handNumber = 0
public private(set) var buttonSeat = 0
private var rng: SeededRandom
/// Stacks as they stood when the current hand was dealt, so simultaneous bustouts can be
/// placed in the right finishing order.
private var stacksBeforeHand: [String: Int] = [:]
public init(structure: TournamentStructure, playerIDs: [String], seed: UInt64) {
self.structure = structure
self.players = playerIDs.map { TournamentPlayer(id: $0, stack: structure.startingStack) }
self.rng = SeededRandom(seed: seed)
self.buttonSeat = 0
}
public var alive: [TournamentPlayer] { players.filter(\.isAlive) }
public var isComplete: Bool { alive.count <= 1 }
public var winner: TournamentPlayer? { players.first { $0.finishedPlace == 1 } }
public var currentLevel: BlindLevel { structure.level(atHand: handNumber) }
/// Prize-pool share for every player still alive, in seat order.
public func currentEquity() -> [String: Double] {
let living = alive
let equities = ICM.equity(stacks: living.map(\.stack), payouts: structure.payouts)
return Dictionary(uniqueKeysWithValues: zip(living.map(\.id), equities))
}
/// Builds the seat list for the next hand. Returns nil once the tournament is over.
public func nextHandSeats() -> (seats: [SeatState], config: HandConfig)? {
guard !isComplete else { return nil }
let living = players.enumerated().filter { $0.element.isAlive }
let seats = living.enumerated().map { index, entry in
SeatState(seat: index, playerID: entry.element.id, stack: entry.element.stack)
}
stacksBeforeHand = Dictionary(uniqueKeysWithValues: living.map { ($0.element.id, $0.element.stack) })
// The button walks one live seat per hand.
let button = seats.isEmpty ? 0 : buttonSeat % seats.count
let config = HandConfig(level: currentLevel, buttonSeat: button, seed: rng.next())
return (seats, config)
}
/// Applies a finished hand's stacks, busts anyone at zero, and moves the button.
public func recordHand(finalStacks: [Int: Int], seatIDs: [Int: String]) {
for (seat, stack) in finalStacks {
guard let id = seatIDs[seat], let index = players.firstIndex(where: { $0.id == id }) else { continue }
players[index].stack = stack
}
// Anyone out of chips finishes in the next place down. When two bust on the same hand,
// the one who started it with fewer chips finishes lower, as at a real table.
let busted = players.indices.filter { players[$0].finishedPlace == nil && players[$0].stack <= 0 }
if !busted.isEmpty {
let remaining = players.filter { $0.finishedPlace == nil }.count
let order = busted.sorted { stacksBeforeHand[players[$0].id] ?? 0 < stacksBeforeHand[players[$1].id] ?? 0 }
for (offset, index) in order.enumerated() {
players[index].finishedPlace = remaining - offset
}
}
handNumber += 1
buttonSeat += 1
if isComplete, let winner = players.firstIndex(where: { $0.isAlive }) {
players[winner].finishedPlace = 1
}
}
/// What each player actually took home, as a share of the prize pool.
public func payouts() -> [String: Double] {
var out: [String: Double] = [:]
for player in players {
guard let place = player.finishedPlace, place <= structure.payouts.count else { continue }
out[player.id] = structure.payouts[place - 1]
}
return out
}
}