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