← poker-tutor
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 }
}
}