Advertisement

← poker-tutor

Sources/PokerCore/Tournament.swift

10.2 KB Download file
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
    }
}