Advertisement

← poker-tutor

Sources/PokerSession/TableSession.swift

50.2 KB Download file
import Foundation
import PokerCore
import PokerAI
import PokerCoach

public enum GameMode: String, Sendable {
    case tournament = "Tournament"
    case cash = "Cash game"
    /// A measured sitting rather than a game: about forty hands, each one set up to look at
    /// whatever the coach is least sure about. See `AssessmentRun`.
    case assessment = "Assessment"
    /// Practice rather than a game: the same situation over and over until one habit has
    /// demonstrably stopped. See `DrillRun`.
    case drill = "Drill"
    /// The check that runs days after a drill cleared, to find out whether the fix is still there.
    case retest = "Re-test"
}

/// How a cash table is set up: the blinds, which never move, and how betting is limited.
public struct CashSetup: Sendable, Equatable {
    public var stakes: BlindLevel
    public var structure: BettingStructure

    public init(stakes: BlindLevel, structure: BettingStructure) {
        self.stakes = stakes
        self.structure = structure
    }

    /// The blind levels on offer, in the chips the app already deals in. No antes at a cash table.
    public static let stakesOnOffer: [BlindLevel] = [
        BlindLevel(smallBlind: 25, bigBlind: 50),
        BlindLevel(smallBlind: 50, bigBlind: 100),
        BlindLevel(smallBlind: 100, bigBlind: 200),
        BlindLevel(smallBlind: 500, bigBlind: 1000),
    ]
    public static let standard = CashSetup(stakes: stakesOnOffer[0], structure: .noLimit)

    /// A hundred big blinds — what everybody sits down with, and buys back in for.
    public static let buyInInBigBlinds = 100
    public var buyIn: Int { stakes.bigBlind * Self.buyInInBigBlinds }
    public var stakesLabel: String { "\(stakes.smallBlind)/\(stakes.bigBlind)" }
}

/// What a cash sitting came to, worked out from the hands it wrote down.
public struct SessionResult: Sendable, Equatable {
    public let hands: Int
    public let netChips: Int
    public let netBB: Double
    public let rebuys: Int
    public let setup: CashSetup

    public var bbPer100: Double { hands > 0 ? netBB / Double(hands) * 100 : 0 }

    public init(hands: Int, netChips: Int, netBB: Double, rebuys: Int, setup: CashSetup) {
        self.hands = hands
        self.netChips = netChips
        self.netBB = netBB
        self.rebuys = rebuys
        self.setup = setup
    }
}

/// One sitting at a table: seats the opponents, deals hand after hand, and puts every decision the
/// player makes in front of the coach before it reaches the engine.
///
/// This is deliberately not a SwiftUI type. The Mac app wraps it and the iPhone app will wrap the
/// same class, which is the whole reason the app layer holds no poker logic. It is also what makes
/// a session testable: the suites drive it exactly as a person would, by calling `act`.
public final class TableSession {

    /// Nine seats is a full ring, which is the game the tournament ladder is built around.
    public static let seatsAtTable = 9
    public static let startingStack = 10_000

    public struct Opponent: Sendable {
        public let id: String
        public let personality: Personality
        public let skill: Int
    }

    public let coach: Coach
    public let heroID: String
    /// Every hand played here is written down as it ends. The analysis screens read this, not the
    /// coach's in-memory profile, which only knows about the current sitting.
    public let history: HistoryStore
    public private(set) var sessionID = UUID().uuidString
    public private(set) var sessionStarted = Date()

    public private(set) var mode: GameMode = .tournament
    public private(set) var engine: HandEngine?
    public private(set) var handNumber = 0
    public private(set) var level = BlindLevel(smallBlind: 50, bigBlind: 100)
    public private(set) var playersLeft = 0
    public private(set) var tableDifficulty = 40
    public private(set) var statusLine = ""
    public private(set) var showdown = false
    public private(set) var lastVerdict: Verdict?
    public private(set) var review: HandReview?
    /// The rung of the ladder this sitting is being played at, and nil for an assessment — an
    /// assessment arranges its own table and its hands are deliberately not results.
    public private(set) var tier: LadderTier?

    /// Keyed by player, never by seat. Seats are rebuilt every hand as players bust, so anything
    /// remembered against a seat number ends up describing the wrong person.
    public private(set) var opponents: [String: Opponent] = [:]

    /// When true the caller drives the opponents one decision at a time with `stepOpponent`,
    /// rather than the session running them all the moment it becomes their turn. The app sets
    /// this so a hand can be watched; the tests and the command line leave it off.
    public var pacedByCaller = false

    /// The free allowance, counted here because this is the one place a hand is ever written down.
    ///
    /// Deliberately hung off the session rather than counted by the screens: a screen that forgets
    /// to count is a screen giving away free hands, and there are several ways to reach a table.
    /// Nil for the command line and for tests that are not about the allowance — nothing is counted
    /// unless somebody is playing in the app.
    public var tally: TrialTally?

    private var tournament: Tournament?
    private var table: BotTable?
    private var rng: SeededRandom
    /// Cash games only: who sits where, and what they are sitting behind. Stacks carry from hand
    /// to hand — that is what a cash game is — and a player who busts buys back in.
    private var cashSeatOrder: [String] = []
    private var cashStacks: [String: Int] = [:]
    /// How the cash table is set up. Chosen before `start(.cash)`; remembered by the app.
    /// The lab's and the suite's way of sitting a player in a chosen room regardless of what the
    /// history says: a sweep has to measure a bar at every rung, not only the one a fresh history
    /// starts at. Nil — always, in the app — means the ladder decides.
    public var forcedTier: LadderTier?
    /// The lab's way of dealing every assessment hand at one arrangement, to measure what that
    /// arrangement actually produces. Nil — always, in the app — means the planner chooses.
    public var pinnedSetup: AssessmentSetup?
    /// The coach's game follows the structure the moment it is chosen — before any sitting —
    /// so the lessons, the rating scale and the assessment's arrangements a fixed-limit player
    /// reads on a fresh launch are the limit ones, not the no-limit ones they used to be until
    /// the first hand was dealt.
    public var cashSetup: CashSetup = .standard {
        didSet { if variant.format == .cash { applyCoachFormat() } }
    }
    /// Rebuys this sitting, by player. A bot that busts a second time leaves instead.
    public private(set) var rebuys: [String: Int] = [:]
    /// What the last cash sitting came to, worked out when the player cashed out.
    public private(set) var lastCashResult: SessionResult?
    private var run: AssessmentRun?
    private var drillRun: DrillRun?
    /// The assessment's eight opponents, most likely to put money in first. The order is what the
    /// seating is built out of, so it is worked out once when they sit down.
    private var castByPressure: [String] = []

    /// The seed everything reproducible runs on: the suite, `pokerlab`, every shootout and sweep.
    /// A measurement that cannot be repeated measures nothing, so this does not move.
    ///
    /// The shipping app deliberately does NOT use it — see `AppState.freshSeed`. It dealt the same
    /// tournament on every launch for as long as it did.
    public static let defaultSeed: UInt64 = 20260730

    /// Which product this sitting belongs to. The cash app's rules — carry-over stacks, rebuys,
    /// no blinds climbing — key on its format; the tournament app is what it always was.
    public let variant: AppVariant

    public init(coach: Coach = Coach(), heroID: String = "You",
                seed: UInt64 = TableSession.defaultSeed,
                history: HistoryStore = .inMemory(), now: @escaping () -> Date = Date.init,
                variant: AppVariant = .pokerTutor) {
        self.variant = variant
        self.coach = coach
        // The coach judges the game this product is, and the catalogue and rating follow it.
        AppVariant.use(variant)
        coach.format = variant.coachFormat
        self.heroID = heroID
        self.history = history
        self.now = now
        self.rng = SeededRandom(seed: seed)
        self.sessionStarted = now()
    }

    /// Injected so a test can write a history without waiting for the clock to move.
    private let now: () -> Date

    /// The game the coach judges: the product's, except that a fixed-limit cash table is its own
    /// game (`CoachFormat.limit`) — the catalogue, the rating scale, the lessons and the
    /// assessment's arrangements all come in a limit edition. Applied whenever a sitting starts,
    /// because the player picks the structure on the setup screen between sittings.
    public var coachFormat: CoachFormat {
        variant.format == .cash && cashSetup.structure == .fixedLimit ? .limit : variant.coachFormat
    }

    /// The betting at an arranged hand — an assessment or a drill. A cash product arranges its
    /// hands at the structure the player chose; a tournament is no-limit.
    private var arrangedStructure: BettingStructure {
        variant.format == .cash ? cashSetup.structure : .noLimit
    }

    private func applyCoachFormat() {
        coach.format = coachFormat
        LeakCatalogue.format = coachFormat
    }

    // MARK: - Starting and leaving

    public func start(_ mode: GameMode) {
        if mode == .assessment { startAssessment(); return }
        guard mode != .drill, mode != .retest else { return }   // a drill needs to know which habit
        self.mode = mode
        applyCoachFormat()
        // Anything half decided in the hand that was on the table belongs to that hand and to
        // nothing else. See `Coach.abandonHand`.
        coach.abandonHand()

        // Which room the player walks into is read off the hands already written down, never off
        // anything held in memory: the coach's profile starts empty every launch, and a ladder
        // that reset itself every morning would be decoration. The rung is settled once here and
        // held for the whole sitting — nobody is moved to a different table mid-tournament.
        let standing = LadderStanding(of: history.hands(in: .allTime))
        // At a cash table the rung IS the stakes: the player sits at the blinds they chose, and
        // the ladder judges them there. A tournament's rung is whatever the standing says.
        let room = forcedTier ?? (mode == .cash
            ? (LadderTier.cash(forStakes: cashSetup.stakes) ?? standing.tier)
            : standing.tier)
        tier = room
        tableDifficulty = room.skill

        var lineupRNG = SeededRandom(seed: rng.next())
        let lineup = TableComposition.lineup(roster: room.roster,
                                             skill: room.skill, rng: &lineupRNG)
        seat(lineup)
        run = nil
        drillRun = nil

        let ids = [heroID] + lineup.map(\.name)
        if mode == .tournament {
            let structure = TournamentStructure.standard(startingStack: Self.startingStack,
                                                         handsPerLevel: 14,
                                                         placesPaid: 3,
                                                         entrants: Self.seatsAtTable)
            tournament = Tournament(structure: structure, playerIDs: ids, seed: rng.next())
            playersLeft = Self.seatsAtTable
            cashSeatOrder = []
            cashStacks = [:]
        } else {
            tournament = nil
            cashSeatOrder = ids
            cashStacks = Dictionary(uniqueKeysWithValues: ids.map { ($0, cashSetup.buyIn) })
            rebuys = [:]
            playersLeft = ids.count
        }

        handNumber = 0
        statusLine = ""
        review = nil
        sessionID = UUID().uuidString
        sessionStarted = now()
        dealNextHand()
    }

    /// Sits a lineup down: the bots that play them, the styles the coach reads them by, and the
    /// order in which they are likely to put money in before the player has to decide.
    private func seat(_ lineup: [TableComposition.Assignment]) {
        opponents = [:]
        var bots: [Bot] = []
        for assignment in lineup {
            var bot = Bot(id: assignment.name, personality: assignment.personality,
                          skill: assignment.skill, seed: rng.next())
            bot.playsImpliedOdds = mode == .cash
            bots.append(bot)
            opponents[assignment.name] = Opponent(id: assignment.name,
                                                  personality: assignment.personality,
                                                  skill: assignment.skill)
        }
        table = BotTable(bots: bots, seed: rng.next(), humanSeat: heroSeat)
        castByPressure = lineup
            .sorted { $0.personality.pressure(skill: $0.skill) > $1.personality.pressure(skill: $1.skill) }
            .map(\.name)
    }

    public func leave() {
        if mode == .cash, handNumber > 0 { lastCashResult = cashOut() }
        // Walking out mid-hand: the verdicts on the decisions made in it go with it, or the next
        // hand to finish anywhere in the app inherits them. See `Coach.abandonHand`.
        coach.abandonHand()
        lastActions.removeAll()
        engine = nil
        run = nil
        drillRun = nil
        tournament = nil
        table = nil
        cashSeatOrder = []
        cashStacks = [:]
        showdown = false
        lastVerdict = nil
    }

    /// The sitting's result, off the hands it wrote down — never off a running total that could
    /// disagree with them.
    private func cashOut() -> SessionResult {
        let played = history.hands(in: .session(sessionID))
        return SessionResult(hands: played.count,
                             netChips: played.reduce(0) { $0 + $1.netChips },
                             netBB: played.reduce(0.0) { $0 + $1.netBB },
                             rebuys: rebuys[heroID] ?? 0,
                             setup: cashSetup)
    }

    /// Cash tables: anyone who busted buys back in for the buy-in. A bot that busts a second
    /// time leaves, and somebody new — a different style from the same rung — takes the seat, so
    /// a long evening changes the table the way a real one changes.
    private func rebuyTheBusted() {
        for (index, id) in cashSeatOrder.enumerated() where (cashStacks[id] ?? 0) <= 0 {
            if id != heroID, (rebuys[id] ?? 0) >= 1 {
                let newcomer = replacement(for: id)
                cashSeatOrder[index] = newcomer.name
                cashStacks[id] = nil
                cashStacks[newcomer.name] = cashSetup.buyIn
                var bot = Bot(id: newcomer.name, personality: newcomer.personality,
                              skill: newcomer.skill, seed: rng.next())
                bot.playsImpliedOdds = true
                table?.seat(bot)
                opponents[newcomer.name] = Opponent(id: newcomer.name, personality: newcomer.personality,
                                                    skill: newcomer.skill)
            } else {
                cashStacks[id] = cashSetup.buyIn
                rebuys[id, default: 0] += 1
            }
        }
    }

    private func replacement(for departing: String) -> TableComposition.Assignment {
        let leaving = opponents[departing]?.personality
        let roster = (tier?.roster ?? Personality.allCases).filter { $0 != leaving }
        let pool = roster.isEmpty ? Personality.allCases : roster
        let personality = pool[Int(rng.next() % UInt64(pool.count))]
        let taken = Set(cashSeatOrder + [heroID] + Array(opponents.keys))
        let name = TableComposition.spareName(notIn: taken, rng: &rng)
        return TableComposition.Assignment(name: name, personality: personality,
                                           skill: tier?.skill ?? tableDifficulty)
    }

    /// Who is at the cash table right now, in seat order. Names change as players bust and leave.
    public var seatedIDs: [String] { cashSeatOrder }

    /// How betting is limited at this table: what the cash setup says, or no-limit anywhere else.
    public var bettingStructure: BettingStructure { engine?.config.structure ?? (mode == .cash ? cashSetup.structure : .noLimit) }

    // MARK: - The assessment

    /// How the sitting is going, or nil when this is an ordinary game.
    public var assessment: AssessmentRun? { run }

    /// The blinds an assessment is played at. They never move: a level that climbed would change
    /// how deep everybody is under the measurement, which is one of the three things the sitting
    /// is meant to be holding still.
    public static let assessmentLevel = BlindLevel(smallBlind: 50, bigBlind: 100)

    /// Starts a fresh assessment: a fixed cast with one of every kind of player at it, and a
    /// planner that decides where the player sits for each hand.
    public func startAssessment(hands: Int = AssessmentRun.standardLength) {
        mode = .assessment
        applyCoachFormat()
        coach.abandonHand()
        tier = nil
        tableDifficulty = assessmentDifficulty(ignoring: nil)
        seat(TableComposition.spread(difficulty: tableDifficulty, opponents: Self.seatsAtTable - 1))
        tournament = nil
        cashSeatOrder = []
        cashStacks = [:]
        playersLeft = Self.seatsAtTable
        run = AssessmentRun(target: hands)
        drillRun = nil
        handNumber = 0
        statusLine = ""
        review = nil
        sessionID = UUID().uuidString
        sessionStarted = now()
        dealNextHand()
    }

    /// A sitting that was started and not finished, if there is one. Walking away from an
    /// assessment costs nothing: the hands played stay in the history as the ordinary evidence
    /// they are, and the run is rebuilt from them when it is picked back up.
    ///
    /// Only the most recent assessment is ever offered, and that is the whole of the rule. Looking
    /// for the newest *unfinished* one instead meant that walking away three hands in, once, and
    /// then sitting a full forty-hand assessment afterwards left Home offering "Carry on" from the
    /// three-hand stub for ever — with no route to the report of the one that was finished, because
    /// the screen only shows that while there is nothing to carry on with. An assessment that has
    /// been superseded is history, not an offer. And a sitting that is on the table right now is
    /// not something to be offered either; the player is already in it.
    public func unfinishedAssessment(target: Int = AssessmentRun.standardLength) -> SessionSummary? {
        guard let latest = history.sessions().first(where: {
            $0.mode == GameMode.assessment.rawValue && $0.hands > 0
        }) else { return nil }
        guard latest.hands < target else { return nil }
        guard !(mode == .assessment && latest.id == sessionID) else { return nil }
        return latest
    }

    /// Picks a half-finished sitting back up where it was left.
    @discardableResult
    public func resumeAssessment(_ id: String, target: Int = AssessmentRun.standardLength) -> Bool {
        let played = history.hands(in: .session(id))
        guard !played.isEmpty, played.count < target else { return false }
        mode = .assessment
        coach.abandonHand()
        tier = nil
        tableDifficulty = assessmentDifficulty(ignoring: id)
        // The cast is fixed rather than drawn, so the same eight people come back.
        seat(TableComposition.spread(difficulty: tableDifficulty, opponents: Self.seatsAtTable - 1))
        tournament = nil
        cashSeatOrder = []
        cashStacks = [:]
        playersLeft = Self.seatsAtTable
        run = AssessmentRun.resuming(from: played, target: target)
        drillRun = nil
        handNumber = played.map(\.handNumber).max() ?? played.count
        statusLine = ""
        review = nil
        sessionID = id
        sessionStarted = played.first?.sessionStarted ?? now()
        dealNextHand()
        return true
    }

    /// How good the eight opponents in an arranged sitting are, read off the hands played *before*
    /// that sitting started.
    ///
    /// The assessment holds three things still — the seat, the stacks and who sits where — and this
    /// is the fourth. Sizing the table off the rating as it stands would seat a different eight
    /// people when a half-finished sitting is picked back up than it seated when the sitting began,
    /// because forty hands of assessment have gone into the rating in between. So the hands from
    /// the sitting itself are left out, which makes the answer the same at the start and at the
    /// resume.
    private func assessmentDifficulty(ignoring sitting: String?) -> Int {
        let before = history.hands(in: .allTime).filter { $0.sessionID != sitting }
        return TableComposition.difficulty(forRating: LeakScorecard(of: before).rating.value)
    }

    /// What the current sitting concluded. Built from the hands on disk rather than from memory,
    /// so it reads the same tomorrow as it does the moment it finishes.
    public var assessmentReport: AssessmentReport? {
        guard let run else { return nil }
        return AssessmentReport(of: history.hands(in: .session(sessionID)), target: run.target)
    }

    /// The most recent assessment that ran to the end, for the Home screen to show later on.
    public func lastFinishedAssessment(target: Int = AssessmentRun.standardLength) -> AssessmentReport? {
        guard let summary = history.sessions().first(where: {
            $0.mode == GameMode.assessment.rawValue && $0.hands >= target
        }) else { return nil }
        return AssessmentReport(of: history.hands(in: .session(summary.id)), target: target)
    }

    /// Builds the table for one arranged hand — an assessment hand or a drill hand: the player in
    /// the seat the planner asked for, everybody the same depth, and the opponents arranged so the
    /// ones who put money in are either in front of the player or behind them.
    ///
    /// The seat is chosen by working backwards from where the button has to be. `positions()`
    /// names the seat after the button as the small blind and counts on from there, so putting
    /// somebody in the nth named seat is a matter of subtraction rather than of shuffling anyone.
    func arrangedSeats(for setup: AssessmentSetup) -> (seats: [SeatState], buttonSeat: Int) {
        let count = Self.seatsAtTable
        let names = Position.names(forPlayerCount: count)
        let index = names.firstIndex(of: setup.seat) ?? 0
        let button = ((heroSeat - 1 - index) % count + count) % count
        func seatNumber(of position: Position) -> Int {
            let at = names.firstIndex(of: position) ?? 0
            return (button + 1 + at) % count
        }

        // Preflop the first person to speak is the one after the big blind, and the blinds speak
        // last. That order is what decides whether a raise has already happened by the time the
        // player has to answer, so it is the order the opponents are ranked into.
        let speakingOrder = Array(names[2...]) + Array(names[0..<2])
        let heroSlot = speakingOrder.firstIndex(of: setup.seat) ?? 0
        let before = speakingOrder.enumerated().filter { $0.offset < heroSlot }.map(\.element).reversed()
        let after = speakingOrder.enumerated().filter { $0.offset > heroSlot }.map(\.element)

        var ranked = castByPressure
        switch setup.lean {
        case .busy: break                       // busiest first, and the first slots filled speak first
        case .quiet: ranked.reverse()
        case .mixed:
            // Alternate from both ends, so neither side of the player is all of one kind.
            var interleaved: [String] = []
            var (low, high) = (0, ranked.count - 1)
            while low <= high {
                interleaved.append(ranked[low]); low += 1
                if low <= high { interleaved.append(ranked[high]); high -= 1 }
            }
            ranked = interleaved
        }

        var byPosition: [Position: String] = [:]
        var next = ranked.makeIterator()
        for position in Array(before) + after {
            guard let id = next.next() else { break }
            byPosition[position] = id
        }

        let stack = setup.depthInBB * Self.assessmentLevel.bigBlind
        var seats = (0..<count).map { SeatState(seat: $0, playerID: "", stack: stack) }
        seats[heroSeat] = SeatState(seat: heroSeat, playerID: heroID, stack: stack)
        for (position, id) in byPosition {
            let number = seatNumber(of: position)
            seats[number] = SeatState(seat: number, playerID: id, stack: stack)
        }
        return (seats, button)
    }

    private func finishAssessment() {
        engine = nil
        statusLine = "Assessment finished — \(run?.handsPlayed ?? 0) hands."
    }

    // MARK: - Drills

    /// The drill on the table, or nil when this is not one.
    public var drill: DrillRun? { drillRun }

    /// Every habit that has been drilled, what it did, and what is owed on it. Counted off the
    /// saved hands on each read, exactly like the ladder — there is no second copy of a re-test
    /// date that could disagree with the hands it was worked out from.
    public var drills: DrillBook { DrillBook(of: history.hands(in: .allTime), now: now()) }

    /// Starts a focused set of hands aimed at one habit.
    ///
    /// The opponents are the ones from the player's own rung of the ladder, because practising
    /// against a table that plays nothing like the one they sit at teaches the wrong answer. What
    /// the hands do *not* carry is the rung itself: the seat and the stacks were chosen here, so
    /// these hands can never move anybody up or down.
    public func startDrill(_ leak: Leak, retest: Bool = false) {
        startDrill(DrillTarget(habit: leak), retest: retest)
    }

    /// Starts a drill at a seat: the player sits there every hand. The table is leaned towards
    /// the habit the diagnosis says the seat is worst at, where it says one.
    public func startSeatDrill(_ position: Position, retest: Bool = false) {
        let diagnosis = SeatDiagnosis(of: history.hands(in: .allTime))
        let report = diagnosis.report(for: position)
        let target = DrillTarget(seat: position, tableSize: report?.tableSize ?? Self.seatsAtTable,
                                 leaning: report?.leading.first?.leak)
        startDrill(target, retest: retest)
    }

    public func startDrill(_ target: DrillTarget, retest: Bool = false) {
        prepareDrill(target, retest: retest)
        dealNextHand()
    }

    private func prepareDrill(_ target: DrillTarget, retest: Bool) {
        mode = retest ? .retest : .drill
        applyCoachFormat()
        coach.abandonHand()
        tier = nil
        let standing = LadderStanding(of: history.hands(in: .allTime))
        tableDifficulty = standing.tier.skill
        var lineupRNG = SeededRandom(seed: rng.next())
        seat(TableComposition.lineup(roster: standing.tier.roster, skill: standing.tier.skill,
                                     rng: &lineupRNG))
        tournament = nil
        cashSeatOrder = []
        cashStacks = [:]
        playersLeft = Self.seatsAtTable
        run = nil
        drillRun = DrillRun(target: target, retest: retest)
        handNumber = 0
        statusLine = ""
        review = nil
        sessionID = UUID().uuidString
        sessionStarted = now()
    }

    /// Picks a drill that was walked away from back up where it was left. The progress is replayed
    /// out of the hands themselves, so there is nothing else to restore.
    @discardableResult
    public func resumeDrill(_ leak: Leak, sessionID id: String) -> Bool {
        resumeDrill(DrillTarget(habit: leak), sessionID: id)
    }

    @discardableResult
    public func resumeDrill(_ target: DrillTarget, sessionID id: String) -> Bool {
        let played = history.hands(in: .session(id)).filter { $0.drill == target.id }
        guard !played.isEmpty else { return false }
        let retest = played.contains(where: \.isRetest)
        let resumed = DrillRun.resuming(from: played, target: target, retest: retest)
        guard !resumed.isFinished else { return false }
        prepareDrill(target, retest: retest)
        drillRun = resumed
        handNumber = played.map(\.handNumber).max() ?? played.count
        sessionID = id
        sessionStarted = played.first?.sessionStarted ?? now()
        dealNextHand()
        return true
    }

    private func finishDrill() {
        engine = nil
        statusLine = drillRun?.outcomeTitle ?? ""
    }

    // MARK: - Hands

    public func dealNextHand() {
        showdown = false
        lastVerdict = nil
        statusLine = ""
        lastActions.removeAll()
        streetOnScreen = .preflop

        switch mode {
        case .tournament:
            guard let tournament, let (seats, config) = tournament.nextHandSeats() else {
                finishTournament()
                return
            }
            level = config.level
            playersLeft = tournament.alive.count
            engine = HandEngine(seats: seats, config: config)
        case .cash:
            // Stacks carry over; the busted buy back in; the blinds stay where they were set.
            rebuyTheBusted()
            let seats = cashSeatOrder.enumerated().map {
                SeatState(seat: $0.offset, playerID: $0.element, stack: cashStacks[$0.element] ?? cashSetup.buyIn)
            }
            guard !seats.isEmpty else { engine = nil; return }
            level = cashSetup.stakes
            engine = HandEngine(seats: seats,
                                config: HandConfig(level: level,
                                                   buttonSeat: handNumber % seats.count,
                                                   seed: rng.next(),
                                                   structure: cashSetup.structure))
            playersLeft = seats.count
        case .assessment:
            guard let run, !run.isFinished else { finishAssessment(); return }
            level = Self.assessmentLevel
            let (seats, button) = arrangedSeats(for: pinnedSetup ?? run.setup)
            engine = HandEngine(seats: seats,
                                config: HandConfig(level: level, buttonSeat: button, seed: rng.next(),
                                                   structure: arrangedStructure))
            playersLeft = seats.count
        case .drill, .retest:
            guard let drillRun, !drillRun.isFinished else { finishDrill(); return }
            level = Self.assessmentLevel
            let (seats, button) = arrangedSeats(for: drillRun.setup)
            engine = HandEngine(seats: seats,
                                config: HandConfig(level: level, buttonSeat: button, seed: rng.next(),
                                                   structure: arrangedStructure))
            playersLeft = seats.count
        }

        handNumber += 1
        // A hand can be over the moment it is dealt. Once a stack is short enough that the blind
        // puts it all in and everybody else can cover it, nobody is left with a decision to make,
        // so nothing ever steps the hand and nothing closes it: no review, no row in the history,
        // and — the expensive part — the tournament is never told anybody busted. The table then
        // deals hand after hand that finish before they start, for ever. Closing it here is the
        // whole fix, and it has to sit outside `advanceOpponents`, which does nothing at all while
        // the app is pacing the opponents itself.
        if engine?.isComplete == true {
            concludeHand()
        } else {
            advanceOpponents()
        }
    }

    /// Lets the opponents act until it is the player's turn or the hand is over.
    private func advanceOpponents() {
        guard !pacedByCaller else { return }
        while stepOpponent() != nil {}
    }

    /// Whether the player still has cards. Once they fold, the hand is over as far as they are
    /// concerned, whatever the opponents are still doing.
    public var heroInHand: Bool { seat(heroSeat)?.isInHand ?? false }

    /// Runs the rest of the hand out at once, ignoring pacing. Used the moment the player folds:
    /// watching eight opponents play a pot you are not in is not a thing anyone wants to sit
    /// through, and the coach's feedback is waiting on the other side of it.
    public func finishHandNow() {
        while stepOpponent() != nil {}
    }

    /// True while an opponent still owes a decision in this hand.
    public var opponentToAct: Bool {
        guard let engine, !engine.isComplete, let actor = engine.actor else { return false }
        return actor != heroSeat
    }

    /// Plays exactly one opponent decision. The app calls this on a timer so the hand unfolds at a
    /// speed a person can follow; everything else lets `advanceOpponents` run them all at once.
    @discardableResult
    public func stepOpponent() -> AppliedAction? {
        guard let engine, let table, opponentToAct, let seat = engine.actor else { return nil }
        guard let action = table.step(engine) else { return nil }
        let applied = record(action, by: seat, in: engine)
        if engine.isComplete { concludeHand() }
        return applied
    }

    /// One decision, as it should be shown and heard: who made it, what it was, and what it cost.
    public struct AppliedAction: Sendable, Equatable {
        public let seat: Int
        public let playerID: String
        public let action: PlayerAction
        public let street: Street
        public let chipsAdded: Int
        public let isHero: Bool

        public init(seat: Int, playerID: String, action: PlayerAction, street: Street,
                    chipsAdded: Int, isHero: Bool) {
            self.seat = seat
            self.playerID = playerID
            self.action = action
            self.street = street
            self.chipsAdded = chipsAdded
            self.isHero = isHero
        }

        public var isFold: Bool { if case .fold = action { return true } else { return false } }
        public var isPassive: Bool {
            switch action {
            case .fold, .check: return true
            default: return false
            }
        }

        /// What appears next to the seat: "Raise 400", "Call 100", "Fold".
        public var caption: String {
            switch action {
            case .fold: return "Fold"
            case .check: return "Check"
            case .call: return chipsAdded > 0 ? "Call \(chipsAdded)" : "Call"
            case .bet(let to): return "Bet \(to)"
            case .raise(let to): return "Raise \(to)"
            }
        }
    }

    /// What each seat did most recently. Cleared when the street changes, the way a dealer sweeps
    /// the chips in — an action from two streets ago is not news.
    public private(set) var lastActions: [Int: AppliedAction] = [:]

    private func record(_ action: PlayerAction, by seat: Int, in engine: HandEngine) -> AppliedAction {
        let applied = AppliedAction(
            seat: seat,
            playerID: engine.seats.first { $0.seat == seat }?.playerID ?? "",
            action: action,
            street: engine.actions.last?.street ?? engine.street,
            chipsAdded: engine.actions.last?.chipsAdded ?? 0,
            isHero: seat == heroSeat
        )
        if applied.street != streetOnScreen {
            streetOnScreen = applied.street
            lastActions.removeAll()
        }
        lastActions[seat] = applied
        return applied
    }

    private var streetOnScreen: Street = .preflop

    /// Applies one of the player's decisions. Returns false — changing nothing — if the action was
    /// not legal, so a mis-click can never be recorded against the player as a mistake.
    @discardableResult
    public func act(_ action: PlayerAction) -> Bool {
        guard let engine, heroToAct, let view = engine.view(for: heroSeat) else { return false }
        do {
            try engine.apply(action)
        } catch {
            return false
        }
        // Judged on the view captured before the action landed: the verdict has to rest on what
        // the player could actually see when they decided.
        lastVerdict = coach.judge(view, action: action)
        record(action, by: heroSeat, in: engine)
        if engine.isComplete { concludeHand() } else { advanceOpponents() }
        return true
    }

    private func concludeHand() {
        guard let engine, let result = engine.result else { return }
        table?.observeFinished(engine)
        showdown = true
        review = coach.finishHand(engine: engine, heroSeat: heroSeat, handNumber: handNumber)

        switch mode {
        case .tournament:
            if let tournament {
                let seatIDs = Dictionary(uniqueKeysWithValues: engine.seats.map { ($0.seat, $0.playerID) })
                tournament.recordHand(finalStacks: result.finalStacks, seatIDs: seatIDs)
                playersLeft = tournament.alive.count
            }
        case .cash:
            for seat in engine.seats {
                cashStacks[seat.playerID] = result.finalStacks[seat.seat] ?? seat.stack
            }
        case .assessment, .drill, .retest:
            // Nobody busts and nobody gets deeper: every hand starts from the depth the planner
            // asked for, which is the only way "how you play a short stack" stays a question
            // about a short stack.
            break
        }

        let net = result.net[heroSeat] ?? 0
        statusLine = net > 0 ? "You won \(net)." : (net < 0 ? "You lost \(-net)." : "Chopped.")
        let written = recordOf(engine, result: result, net: net)
        history.append(written)
        // Counted against the free allowance here, beside the write, so the two can never disagree
        // about whether a hand happened. `TrialTally` decides which hands count.
        tally?.record(written)
        run?.observe(written)
        drillRun?.observe(written)
    }

    /// Turns the finished hand into the row the analysis screens read.
    private func recordOf(_ engine: HandEngine, result: HandResult, net: Int) -> HandRecord {
        let mine = engine.actions.filter { $0.seat == heroSeat && !$0.isForced }
        let preflop = mine.filter { $0.street == .preflop }
        let foldedPreflop = preflop.contains { if case .fold = $0.action { return true } else { return false } }

        return HandRecord(
            sessionID: sessionID,
            sessionStarted: sessionStarted,
            playedAt: now(),
            handNumber: handNumber,
            mode: mode.rawValue,
            bigBlind: engine.config.level.bigBlind,
            position: engine.positions()[heroSeat]?.rawValue ?? "",
            holeCards: engine.seats.first { $0.seat == heroSeat }?.hole.shorthand ?? "",
            board: engine.board.shorthand,
            netChips: net,
            wentToShowdown: result.wentToShowdown && engine.seats.first { $0.seat == heroSeat }?.isInHand == true,
            sawFlop: engine.board.count >= 3 && !foldedPreflop,
            // Blinds are posted, not chosen, so they are filtered out above — otherwise every
            // hand in the big blind would look like a hand the player wanted to play.
            putMoneyInPreflop: preflop.contains { action in
                switch action.action {
                case .call, .bet, .raise: return true
                case .fold, .check: return false
                }
            },
            raisedPreflop: preflop.contains { action in
                switch action.action {
                case .bet, .raise: return true
                default: return false
                }
            },
            verdicts: (review?.verdicts ?? []).map(VerdictRecord.init),
            // Kept so the seat can be numbered when the hand is read back: a tournament table
            // shrinks, and the same name is a different seat number at a different size.
            tableSize: engine.positions().count,
            smallBlind: engine.config.level.smallBlind,
            buttonSeat: engine.config.buttonSeat,
            players: playerLines(engine, result: result),
            actions: actionLines(engine),
            // Written down rather than worked out later, so the file itself says which hands were
            // arranged and how. A measurement nobody can audit is a claim.
            kind: Self.kindOf(mode),
            setup: (run != nil ? (pinnedSetup ?? run!.setup).id : nil) ?? drillRun?.setup.id,
            // The rung goes in the hand itself, so the ladder is countable off the file rather
            // than kept in a second place that could disagree with it.
            tier: tier?.id,
            // And the habit, so a drill and the check that follows it days later are countable off
            // the file too. See `DrillBook`.
            drill: drillRun?.target.id,
            // Cash hands say how the table was limited and how often the player has bought back
            // in, so the sitting's result can be counted off the file like everything else.
            structure: mode == .cash ? engine.config.structure.rawValue : nil,
            rebuys: mode == .cash ? (rebuys[heroID] ?? 0) : nil
        )
    }

    /// What the file calls this sort of hand. Plain words rather than the mode's own type, like
    /// everything else written down.
    private static func kindOf(_ mode: GameMode) -> String? {
        switch mode {
        case .assessment: return HandRecord.assessmentKind
        case .drill: return HandRecord.drillKind
        case .retest: return HandRecord.retestKind
        case .tournament, .cash: return nil
        }
    }

    /// Everyone who was dealt in, as the replay needs them.
    ///
    /// Starting stacks are worked back out of the finish rather than remembered from the start:
    /// what a seat had at the end minus what the hand did to it is what it sat down with, and
    /// deriving it means there is no second copy to fall out of step.
    private func playerLines(_ engine: HandEngine, result: HandResult) -> [PlayerLine] {
        let positions = engine.positions()
        let shown = result.wentToShowdown
        return engine.seats.filter { $0.status != .sittingOut }.map { seat in
            let net = result.net[seat.seat] ?? 0
            // Cards are only written down where they were actually turned over. The player's own
            // are always theirs to see; nobody else's are, unless the hand went to the end.
            let visible = seat.seat == heroSeat || (shown && seat.isInHand)
            return PlayerLine(
                seat: seat.seat,
                name: seat.playerID,
                position: positions[seat.seat]?.rawValue ?? "",
                startingStack: (result.finalStacks[seat.seat] ?? seat.stack) - net,
                cards: visible ? seat.hole.shorthand : "",
                net: net,
                isHero: seat.seat == heroSeat
            )
        }
    }

    /// The hand's transcript, in order.
    ///
    /// Antes are all posted before the blinds are, so the first few forced entries are the antes and
    /// whatever is left is the blinds. Telling them apart matters only for the wording — "posts the
    /// big blind" reads as the rule it is, where "posts 100" reads as a decision somebody made.
    private func actionLines(_ engine: HandEngine) -> [ActionLine] {
        var antesLeft = engine.seats.filter { $0.antePaid > 0 }.count
        return engine.actions.map { record in
            var verb: String
            var to = 0
            switch record.action {
            case .fold: verb = "fold"
            case .check: verb = "check"
            case .call: verb = "call"
            case .bet(let amount): verb = "bet"; to = amount
            case .raise(let amount): verb = "raise"; to = amount
            }
            if record.isForced {
                if antesLeft > 0 { verb = "ante"; antesLeft -= 1 } else { verb = "blind" }
            }
            return ActionLine(seat: record.seat, street: record.street.name, action: verb,
                              to: to, chipsAdded: record.chipsAdded, potAfter: record.potAfter,
                              forced: record.isForced)
        }
    }

    /// Called once the player has read the review. Deals on, unless the session is over.
    public func continueAfterReview() {
        if mode == .assessment {
            if run?.isFinished == true { finishAssessment(); return }
            dealNextHand()
            return
        }
        if mode == .drill || mode == .retest {
            if drillRun?.isFinished == true { finishDrill(); return }
            dealNextHand()
            return
        }
        if mode == .tournament, let tournament {
            let hero = tournament.players.first { $0.id == heroID }
            if (hero?.stack ?? 0) <= 0 {
                statusLine = "You busted in \(Self.placeText(hero?.finishedPlace)). "
                    + "\(tournament.alive.count) players left."
                engine = nil
                return
            }
        }
        dealNextHand()
    }

    private func finishTournament() {
        guard let tournament else { engine = nil; return }
        if tournament.winner?.id == heroID {
            statusLine = "You won the tournament."
        } else {
            let place = tournament.players.first { $0.id == heroID }?.finishedPlace
            statusLine = "Tournament over — you finished \(Self.placeText(place))."
        }
        engine = nil
    }

    static func placeText(_ place: Int?) -> String {
        guard let place else { return "the tournament" }
        switch place {
        case 1: return "1st"
        case 2: return "2nd"
        case 3: return "3rd"
        default: return "\(place)th"
        }
    }

    // MARK: - Reading the table

    /// The hero sits in the first seat and stays there: the tournament rebuilds seats in entry
    /// order and the hero was entered first, so they only leave seat zero by busting.
    public var heroSeat: Int { 0 }

    public var isPlaying: Bool { engine != nil }
    public var isOver: Bool { engine == nil && handNumber > 0 }
    /// What the player is worth, off every hand that was written down.
    ///
    /// There is one rating in this app and this is it. `Coach.rating` is the same arithmetic over
    /// the coach's own memory, which is the right thing for judging a hand as it is played and the
    /// wrong thing for anything anybody reads: it starts empty every launch, so it told a player
    /// with a year of hands behind them that they were an unknown quantity, and it counted drill
    /// hands, so a habit could be practised off the number without a hand of poker being played
    /// any differently.
    public var rating: SkillRating {
        LeakScorecard(of: history.hands(in: .allTime)).rating
    }

    /// Where the player stands on the ladder, counted off every hand ever saved. Recomputed on
    /// each read rather than cached, exactly like the analysis screens: it is one pass over a few
    /// thousand rows, and a stale standing would be worse than a slow one.
    public var ladder: LadderStanding { LadderStanding(of: history.hands(in: .allTime)) }
    public var pot: Int { engine?.pot ?? 0 }
    public var board: [Card] { engine?.board ?? [] }
    public var seats: [SeatState] { engine?.seats ?? [] }
    public var positions: [Int: Position] { engine?.positions() ?? [:] }
    public var actorSeat: Int? { engine?.actor }
    public var heroCards: [Card] { seat(heroSeat)?.hole ?? [] }
    public var heroStack: Int { seat(heroSeat)?.stack ?? 0 }
    public var heroToAct: Bool { engine?.isComplete == false && engine?.actor == heroSeat }
    public var legal: LegalActions? { heroToAct ? engine?.legalActions() : nil }

    public func seat(_ number: Int) -> SeatState? { engine?.seats.first { $0.seat == number } }

    /// The chips the player can bet with here, or nil when raising is not an option.
    public var chipTray: ChipTray? { legal.flatMap(ChipTray.init(legal:)) }

    public func label(forSeat number: Int) -> String {
        seat(number)?.playerID ?? "Seat \(number)"
    }

    /// The style badge under an opponent. Looked up by who is sitting there, not by seat number.
    public func personality(forSeat number: Int) -> Personality? {
        guard let id = seat(number)?.playerID, id != heroID else { return nil }
        return opponents[id]?.personality
    }

    /// Only ever non-nil once the hand is over and it actually went to showdown.
    public func revealedCards(forSeat number: Int) -> [Card]? {
        guard let engine, engine.isComplete, showdown,
              engine.result?.wentToShowdown == true,
              let seat = seat(number), seat.isInHand else { return nil }
        return seat.hole
    }

    /// What the player is holding, in words — blank before the flop, where naming the hand would
    /// say more than the player can actually know.
    public var handSummary: String {
        guard let engine, !heroCards.isEmpty, !engine.board.isEmpty else { return "" }
        let live = engine.seats.filter { $0.isInHand && $0.seat != heroSeat }.count
        return HandReader.assess(hole: heroCards, board: engine.board, opponents: max(1, live)).summary
    }
}