Advertisement

← poker-tutor

Sources/PokerSession/HandReplay.swift

11.2 KB Download file
import Foundation
import PokerCore
import PokerCoach

/// A saved hand, turned back into something that can be watched a step at a time.
///
/// This is reconstruction, not simulation: nothing is re-dealt and no opponent is asked to decide
/// again. Everything here comes off the line that was written down when the hand finished, which is
/// what makes a replay of a hand from a year ago exactly the hand that was played rather than a
/// plausible re-run of it.
///
/// It lives here rather than in the app for the usual reason — the phone will draw it differently
/// and the sums must not be written twice — and it is the reason the transcript is on `HandRecord`
/// at all. A test plays a hand, saves it, reloads it and checks the streets, the actions and the
/// pot sizes come back identical.
public struct HandReplay: Sendable, Equatable {

    /// One point in the hand: either a round of betting opening, somebody acting, or the finish.
    public struct Moment: Sendable, Equatable, Identifiable {
        public enum Kind: String, Sendable {
            /// A round of betting opens — the shared cards for it are turned over.
            case opens
            /// Somebody made a decision.
            case acts
            /// The hand is over.
            case ends
        }

        public let id: Int
        public let kind: Kind
        public let street: Street
        /// The shared cards face up at this point, and no more. Stepping back puts the river away.
        public let board: [Card]
        /// What was in the middle once this had happened.
        public let pot: Int
        public let seat: Int?
        public let position: Position?
        /// "seat 4 of 9", or nil where the moment is not about one seat.
        public let seatPhrase: String?
        /// Who acted: their name, or "You".
        public let actor: String?
        /// "Raises to 600", "Folds", "Calls 200".
        public let action: String?
        public let chipsAdded: Int
        public let isHero: Bool
        /// The whole thing in one plain sentence, which is also what a screen reader is given.
        public let headline: String
        /// The coach's read on this decision, where this was the player's decision and the coach
        /// was watching. Present even where the coach chose to stay quiet — a review screen is
        /// allowed to show what it thought, because it is not interrupting anybody there.
        public let verdict: VerdictRecord?
    }

    public let record: HandRecord
    public let players: [PlayerLine]
    public let moments: [Moment]
    /// How many were dealt in — what the seat numbers are out of.
    public let tableSize: Int

    /// Nil where the hand was written down before the transcript was kept. Those hands still have
    /// their summary; they simply cannot be walked through.
    public init?(_ record: HandRecord) {
        guard let actions = record.actions, let players = record.players,
              !actions.isEmpty, !players.isEmpty else { return nil }

        self.record = record
        self.players = players.sorted { $0.seat < $1.seat }
        self.tableSize = record.seatsAtTable

        let board = record.boardCards
        let names = Dictionary(uniqueKeysWithValues: players.map { ($0.seat, $0) })
        let heroSeat = players.first(where: \.isHero)?.seat

        var moments: [Moment] = []
        var pot = 0
        var verdictsLeft = record.verdicts
        var index = 0

        for street in Self.streetsReached(boardCards: board.count) {
            var lines = actions.filter { $0.street == street.name }

            // Blinds and antes are not decisions, so they are part of the picture the round opens
            // with rather than steps of their own. Nine antes and two blinds would otherwise be
            // eleven taps before anybody had done anything.
            var forced: [ActionLine] = []
            while let first = lines.first, first.forced {
                forced.append(first)
                pot = first.potAfter
                lines.removeFirst()
            }

            moments.append(Moment(
                id: index, kind: .opens, street: street,
                board: Self.board(board, upTo: street), pot: pot,
                seat: nil, position: nil, seatPhrase: nil, actor: nil, action: nil,
                chipsAdded: 0, isHero: false,
                headline: Self.opening(street, forced: forced, pot: pot,
                                       board: Self.board(board, upTo: street)),
                verdict: nil
            ))
            index += 1

            for line in lines {
                pot = line.potAfter
                let player = names[line.seat]
                let seatName = player?.seatName
                let isHero = player?.isHero ?? false
                // The coach judged the player's decisions in the order they were made, so the
                // verdicts come off the front as the player's actions come round.
                var verdict: VerdictRecord?
                if isHero, !line.forced, !verdictsLeft.isEmpty {
                    verdict = verdictsLeft.removeFirst()
                }
                moments.append(Moment(
                    id: index, kind: .acts, street: street,
                    board: Self.board(board, upTo: street), pot: pot,
                    seat: line.seat, position: seatName,
                    seatPhrase: seatName?.seatPhrase(inTableOf: record.seatsAtTable),
                    actor: isHero ? "You" : (player?.name ?? "Seat \(line.seat)"),
                    action: Self.phrase(line),
                    chipsAdded: line.chipsAdded, isHero: isHero,
                    headline: Self.sentence(line, actor: isHero ? "You" : (player?.name ?? "Seat \(line.seat)"),
                                            hero: isHero),
                    verdict: verdict
                ))
                index += 1
            }
        }

        let finalStreet = Self.streetsReached(boardCards: board.count).last ?? .preflop
        moments.append(Moment(
            id: index, kind: .ends, street: finalStreet,
            board: board, pot: pot,
            seat: heroSeat, position: record.seat,
            seatPhrase: record.seat?.seatPhrase(inTableOf: record.seatsAtTable),
            actor: nil, action: nil, chipsAdded: 0, isHero: true,
            headline: Self.ending(record, shown: players.contains { !$0.isHero && !$0.cards.isEmpty }),
            verdict: nil
        ))

        self.moments = moments
    }

    // MARK: - Reading it

    public var lastIndex: Int { max(0, moments.count - 1) }

    /// Where each round of betting starts, so a screen can offer them as jumps rather than making
    /// somebody tap through a round they have already read.
    public var streetStarts: [(street: Street, index: Int)] {
        moments.filter { $0.kind == .opens }.map { ($0.street, $0.id) }
    }

    public func index(of street: Street) -> Int? {
        moments.first { $0.kind == .opens && $0.street == street }?.id
    }

    public var hero: PlayerLine? { players.first(where: \.isHero) }

    /// The seats still holding cards at the end, with whatever was turned over. A seat with no
    /// cards written down against it never showed them, and the replay leaves it that way.
    public var revealed: [PlayerLine] { players.filter { !$0.cards.isEmpty && !$0.isHero } }

    /// What a seat had in front of it before a card was dealt.
    public func startingStack(seat: Int) -> Int {
        players.first { $0.seat == seat }?.startingStack ?? 0
    }

    // MARK: - Working out what to say

    static func streetsReached(boardCards: Int) -> [Street] {
        var streets: [Street] = [.preflop]
        if boardCards >= 3 { streets.append(.flop) }
        if boardCards >= 4 { streets.append(.turn) }
        if boardCards >= 5 { streets.append(.river) }
        return streets
    }

    static func board(_ cards: [Card], upTo street: Street) -> [Card] {
        let count: Int
        switch street {
        case .preflop: count = 0
        case .flop: count = 3
        case .turn: count = 4
        default: count = 5
        }
        return Array(cards.prefix(min(count, cards.count)))
    }

    /// "Raises to 600". The verb is what the player reads; the number is the total in front of
    /// that seat for the round, which is how a raise is called at a table.
    static func phrase(_ line: ActionLine) -> String {
        switch line.action {
        case "fold": return "Folds"
        case "check": return "Checks"
        case "call": return line.chipsAdded > 0 ? "Calls \(line.chipsAdded)" : "Calls"
        case "bet": return "Bets \(line.to)"
        case "raise": return "Raises to \(line.to)"
        case "blind": return "Posts \(line.chipsAdded)"
        case "ante": return "Antes \(line.chipsAdded)"
        default: return line.action.capitalized
        }
    }

    static func sentence(_ line: ActionLine, actor: String, hero: Bool) -> String {
        let verb: String
        switch line.action {
        case "fold": verb = hero ? "fold" : "folds"
        case "check": verb = hero ? "check" : "checks"
        case "call": verb = (hero ? "call" : "calls") + (line.chipsAdded > 0 ? " \(line.chipsAdded)" : "")
        case "bet": verb = (hero ? "bet" : "bets") + " \(line.to)"
        case "raise": verb = (hero ? "raise" : "raises") + " to \(line.to)"
        case "blind": verb = (hero ? "post" : "posts") + " \(line.chipsAdded)"
        case "ante": verb = (hero ? "ante" : "antes") + " \(line.chipsAdded)"
        default: verb = line.action
        }
        return "\(actor) \(verb)."
    }

    static func opening(_ street: Street, forced: [ActionLine], pot: Int, board: [Card]) -> String {
        switch street {
        case .preflop:
            let antes = forced.contains { $0.action == "ante" }
            let money = antes ? "The blinds and the antes go in" : "The blinds go in"
            return "\(money) — \(pot) in the middle before anybody has decided anything, and "
                + "everybody has two cards."
        case .flop:
            return "The flop comes down: \(board.shorthand). \(pot) in the middle."
        case .turn:
            return "The turn: \(board.last.map { "\($0)" } ?? ""). \(pot) in the middle."
        case .river:
            return "The river: \(board.last.map { "\($0)" } ?? ""). \(pot) in the middle."
        case .showdown:
            return "Cards on the table."
        }
    }

    /// `shown` is whether anybody else's cards were actually turned over — which is not the same
    /// question as whether the player was still in the hand. Somebody who folded early can still
    /// have watched two opponents show each other down.
    static func ending(_ record: HandRecord, shown: Bool) -> String {
        let start = shown ? "The cards are turned face up. " : "No cards had to be shown. "
        if record.netChips > 0 { return start + "You won \(record.netChips)." }
        if record.netChips < 0 { return start + "You lost \(-record.netChips)." }
        return start + "You finished level."
    }
}