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