Advertisement

← poker-tutor

Sources/PokerSession/GameAnalysis.swift

11.1 KB Download file
import Foundation
import PokerCore

/// What a stack of hands says about the person who played them.
///
/// Every number here is one a poker player already recognises — VPIP, PFR, showdown rates, big
/// blinds per hundred — because the point is to be comparable with what they read everywhere else,
/// not to invent a private scale. Nothing is reported off a sample too small to mean anything;
/// `enoughHands` says whether to believe any of it.
public struct GameAnalysis: Sendable, Equatable {

    public struct Rate: Sendable, Equatable {
        public let made: Int
        public let of: Int
        public var value: Double { of > 0 ? Double(made) / Double(of) : 0 }
        public var percent: Int { Int((value * 100).rounded()) }
        /// A rate off fewer than this many chances is noise wearing a percentage sign.
        public var trustworthy: Bool { of >= 30 }
    }

    public struct PositionStat: Sendable, Equatable, Identifiable {
        /// The seat as it was written down — "CO". Kept because that is what is on disk.
        public let position: String
        /// The table these hands were mostly played at, which is what the number counts against.
        public let tableSize: Int
        /// 1 is the small blind; the button is last. Nil only if the stored name is one this
        /// build no longer knows.
        public let seatNumber: Int?
        public let hands: Int
        public let netBB: Double
        public let vpip: Rate
        public var id: String { position }
        public var bbPer100: Double { hands > 0 ? netBB / Double(hands) * 100 : 0 }

        private var seat: Position? { Position(rawValue: position) }
        /// "seat 8 of 9" — the number the row leads with.
        public var seatPhrase: String { seat?.seatPhrase(inTableOf: tableSize) ?? position }
        /// "cutoff" — kept beside the number rather than replaced by it.
        public var fullName: String { seat?.fullName ?? position }
        /// Who is still to speak after you, in plain words.
        public var actOrderNote: String { seat?.actOrderNote(inTableOf: tableSize) ?? "" }
    }

    public struct StreetStat: Sendable, Equatable, Identifiable {
        public let street: String
        public let times: Int
        public let costBB: Double
        public var id: String { street }
    }

    public let hands: Int
    public let netChips: Int
    public let netBB: Double
    public let sessions: Int
    /// Hands played inside a skill assessment, which are counted here and then left out of
    /// everything below. See `arrangedNote`.
    public let assessmentHands: Int
    /// Hands played inside a drill or the check that follows one, left out for the same reason and
    /// one more of their own.
    public let drillHands: Int

    public let vpip: Rate
    public let pfr: Rate
    /// Of the hands that saw a flop, how many were carried to a showdown.
    public let wentToShowdown: Rate
    /// Of the showdowns, how many were won.
    public let wonAtShowdown: Rate
    /// Of the hands that saw a flop, how many were won by any route.
    public let wonAfterFlop: Rate

    public let flaggedDecisions: Int
    public let costInBB: Double

    public let byPosition: [PositionStat]
    /// What the flagged decisions cost, round of betting by round of betting. Which *habits* cost
    /// what is `LeakScorecard`'s question and is answered there — there was a second count of the
    /// same mistakes here once, under different names, printed on the same screen.
    public let byStreet: [StreetStat]
    public let best: HandRecord?
    public let worst: HandRecord?

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

    /// The three headline numbers as they are printed, and a dash where there is nothing to print.
    ///
    /// With no hands at all these were "+0", "+0.0 bb" and "0.0 bb/100", set in the same confident
    /// type as a real result — and the caveat that would have explained them was rendered inside a
    /// branch that only ran once there were hands, so it could never appear beside them. Nought
    /// reads as "this is free", which is the one thing the scorecard three inches below refuses to
    /// imply about anything. The choice lives here rather than in the view so that it is a thing a
    /// test can read.
    public var netChipsText: String {
        guard hands > 0 else { return "—" }
        return netChips >= 0 ? "+\(netChips)" : "\(netChips)"
    }
    public var bbPer100Text: String {
        hands > 0 ? String(format: "%+.1f bb", bbPer100) : "—"
    }
    public var costPer100Text: String {
        hands > 0 ? String(format: "%.1f bb/100", costPer100) : "—"
    }

    /// The fewest hands a cost per hundred is worth printing off.
    ///
    /// A display floor rather than a measurement bar: nothing decides anything on it and no
    /// threshold in the coach moves with it. It is the same 25 the by-seat rows are already greyed
    /// at, so the app has one answer to "too few hands to read anything into" rather than two.
    /// Three hands and one two-big-blind mistake is arithmetically 66.7 per hundred and is a
    /// sentence about nothing.
    public static let costFloor = 25

    /// The same number where there is no caveat printed beside it. Home's "this session" strip is
    /// four numbers in a row with nothing under them, and it was printing 66.7 bb off three hands
    /// in the same type as everything else.
    public var costPer100Firm: String {
        hands >= Self.costFloor ? costPer100Text : "—"
    }
    /// Poker results need a lot of hands before they mean anything. Under this, the coach's
    /// verdicts are worth more than the win rate.
    public var enoughHands: Bool { hands >= 200 }

    public static let empty = GameAnalysis(of: [])

    /// Why the arranged hands are missing from every number on this screen, in the player's own
    /// words. Nil when there were none.
    ///
    /// The two kinds are set aside for overlapping but different reasons, and the difference is
    /// worth the extra sentence: an assessment hand still counts towards the leaks, and a drill
    /// hand does not.
    public var arrangedNote: String? {
        guard assessmentHands + drillHands > 0 else { return nil }
        var parts: [String] = []
        if assessmentHands > 0 {
            parts.append("\(assessmentHands) assessment hand\(assessmentHands == 1 ? " is" : "s are") "
                + "left out. The seat you sat in and the chips in front of everybody were chosen "
                + "for you in those, so what they won or lost is not a fair sample of anything. "
                + "They do still count towards your leaks: being put in a situation more often "
                + "does not change how often you get it right when you are in it.")
        }
        if drillHands > 0 {
            parts.append("\(drillHands) drill hand\(drillHands == 1 ? " is" : "s are") left out as "
                + "well, and those are kept out of your leaks too. A drill is practice at one "
                + "situation with you watching for it, which is not the same thing as how you play "
                + "when it turns up unannounced.")
        }
        return parts.joined(separator: " ")
    }

    public init(of allRecords: [HandRecord]) {
        // An assessment deliberately sits the player in the big blind and on a short stack far
        // more often than a real table would, and a drill does nothing else all sitting, so
        // neither can be mixed into a win rate, a VPIP or a by-seat table without making all three
        // of them mean something else. They are counted, said out loud, and then set aside.
        assessmentHands = allRecords.filter(\.isAssessment).count
        drillHands = allRecords.filter { $0.isDrill || $0.isRetest }.count
        let records = allRecords.filter { !$0.isArranged }

        hands = records.count
        netChips = records.reduce(0) { $0 + $1.netChips }
        netBB = records.reduce(0) { $0 + $1.netBB }
        sessions = Set(records.map(\.sessionID)).count

        vpip = Rate(made: records.filter(\.putMoneyInPreflop).count, of: records.count)
        pfr = Rate(made: records.filter(\.raisedPreflop).count, of: records.count)

        let sawFlop = records.filter(\.sawFlop)
        let showdowns = records.filter(\.wentToShowdown)
        wentToShowdown = Rate(made: showdowns.count, of: sawFlop.count)
        wonAtShowdown = Rate(made: showdowns.filter(\.won).count, of: showdowns.count)
        wonAfterFlop = Rate(made: sawFlop.filter(\.won).count, of: sawFlop.count)

        let spoken = records.flatMap(\.flagged)
        flaggedDecisions = spoken.count
        costInBB = spoken.reduce(0) { $0 + $1.costInBB }

        // Position, in the order they sit around the table rather than alphabetically.
        let order = Position.allCases.map(\.rawValue)
        byPosition = order.compactMap { name in
            let group = records.filter { $0.position == name }
            guard !group.isEmpty else { return nil }
            // A seat's number depends on how many were dealt in, and a tournament table shrinks as
            // people bust. The row reports the size these hands were mostly played at, falling
            // back to a full table if the seat does not exist at that size at all.
            let seat = Position(rawValue: name)
            var counts: [Int: Int] = [:]
            for record in group { counts[record.seatsAtTable, default: 0] += 1 }
            let commonest = counts.max { a, b in
                a.value != b.value ? a.value < b.value : a.key > b.key   // ties go to the smaller table
            }?.key ?? 9
            let size = seat?.number(inTableOf: commonest) != nil ? commonest : 9
            return PositionStat(
                position: name,
                tableSize: size,
                seatNumber: seat?.number(inTableOf: size),
                hands: group.count,
                netBB: group.reduce(0) { $0 + $1.netBB },
                vpip: Rate(made: group.filter(\.putMoneyInPreflop).count, of: group.count)
            )
        }

        let streetOrder = ["preflop", "flop", "turn", "river", "showdown"]
        byStreet = streetOrder.compactMap { street in
            let group = spoken.filter { $0.street.lowercased() == street }
            guard !group.isEmpty else { return nil }
            return StreetStat(street: street,
                              times: group.count,
                              costBB: group.reduce(0) { $0 + $1.costInBB })
        }

        best = records.max { $0.netChips < $1.netChips }
        worst = records.min { $0.netChips < $1.netChips }
    }

    /// The running win rate, hand by hand, for drawing a line through a session.
    public static func runningBB(_ records: [HandRecord]) -> [Double] {
        var total = 0.0
        return records.map { total += $0.netBB; return total }
    }
}