← poker-tutor
import Foundation
import Observation
#if os(Android)
import SkipFuse
#endif
import SwiftUI
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerStore
public enum Screen: String, CaseIterable, Identifiable {
case home = "Home"
case table = "Table"
case practice = "Drills"
case leaks = "My game"
case history = "Hands"
case ladder = "Ladder"
case settings = "Settings"
public var id: String { rawValue }
/// The tab bar splits a phone screen evenly between these, so the longer names get shorter
/// ones there. Seven is the most that fit, and only because none of the short names runs past
/// six characters — an eighth, or a longer name than "Ladder", starts truncating at the largest
/// type size and belongs behind "More" instead.
///
/// Drills earned a place on the bar rather than a link inside Settings because it is something
/// somebody does most sittings, and the first version buried it two taps deep behind a habit
/// the coach had to confirm first.
public var shortName: String {
switch self {
case .leaks: return "Game"
case .history: return "Hands"
case .settings: return "More"
default: return rawValue
}
}
var icon: String {
switch self {
case .home: return "house"
case .table: return "suit.spade"
case .practice: return "target"
case .leaks: return "chart.bar.doc.horizontal"
case .history: return "clock.arrow.circlepath"
case .ladder: return "chart.line.uptrend.xyaxis"
case .settings: return "gearshape"
}
}
}
/// What is on screen.
///
/// The game itself lives in `TableSession`, which knows nothing about SwiftUI. This type only
/// decides which screen is showing and tells the views when something changed — deliberately, so
/// the iPhone version can put a different skin on the same session.
@Observable
@MainActor
public final class AppState {
/// Which product this is. Chosen by the app shell; everything variant-specific reads it.
public let variant: AppVariant
public init(variant: AppVariant = .pokerTutor) {
// First, before any key or file is read: the keys and the folder come off it.
AppVariant.current = variant
self.variant = variant
// The screenshot script's two played scenes. A fixed seed, so the shots can be remade
// identically; see `DemoScene`. Nothing here runs for a person.
if let scene = DemoScene.requested {
// An in-memory history, so the scene is the one the suite plays — a history on disk
// would change the table's cast, and the cards after it, with every hand ever added.
let staged = TableSession(seed: DemoScene.seed, history: .inMemory(), variant: variant)
staged.pacedByCaller = true
_session = staged
arrangeDemo(scene)
return
}
_session = AppState.ownSession(variant)
// The coach's game follows the remembered structure from the first screen, not from the
// first hand: a fixed-limit player reads the limit course before sitting down.
_session.cashSetup = cashSetup
// The script opens the app on a named screen and cannot tap the walk-through away.
let scripted = ProcessInfo.processInfo.environment["POKERTUTOR_SCREEN"] != nil
if !scripted, !UserDefaults.standard.bool(forKey: AppState.tutorialKey) { sheet = .tutorial }
}
/// The same app around a session somebody else made, with the noise turned off.
///
/// This is what the suite drives. Everything the window does to a table — walking away from it,
/// coming back to it, what a finished hand is allowed to open — lives in this class and used to
/// be reachable only by playing with a mouse, which is how a table that kept dealing to an
/// empty chair survived four rounds of review. There is no clock in here: `stepOpponent` is
/// the dealer's own work with the waiting taken out.
public init(session: TableSession) {
self.variant = session.variant
self._session = session
session.cashSetup = cashSetup
silent = true
if !UserDefaults.standard.bool(forKey: AppState.tutorialKey) { sheet = .tutorial }
}
private static func ownSession(_ variant: AppVariant) -> TableSession {
let session = TableSession(seed: AppState.freshSeed(), history: HistoryStore(), variant: variant)
session.pacedByCaller = true // the opponents are dealt out one at a time, below
return session
}
/// A new deck for a new run of the app.
///
/// Ken, 2026-08-24: "I dont like that it seems like the same hand is often shown when I start
/// playing. Are the hands randomized in the tournement?" They were not. `TableSession` takes a
/// seed with a fixed default, which is exactly right for the suite and the command line —
/// every measurement in this project depends on the same seed dealing the same cards — and the
/// app had simply never passed one of its own. So every launch dealt the same tournament: the
/// same hole cards, the same board, and the same eight opponents in the same seats.
///
/// Only the app is random. The default stays where it is, because a shootout that cannot be
/// repeated measures nothing.
public static func freshSeed() -> UInt64 { UInt64.random(in: 1...UInt64.max) }
/// Whether to make a noise. A headless app has no speakers to answer for, and the setting a
/// person chose in Settings is not something a test run should be allowed to change.
private var silent = false
// MARK: - Paying for it
/// Apple's half of the subscription. Nil in the suite and on the command line, which have no
/// App Store to talk to.
///
/// `@Published` is not enough on its own — this is a class of its own, and SwiftUI needs to
/// hear about its changes rather than about it being replaced. `observe(_:)` forwards them.
public private(set) var subscription: SubscriptionStore?
/// How many hands have been played against the free allowance. Nil when nobody is being charged
/// for anything: the suite, and the command line.
///
/// ⚠️ A nil tally means every hand is free. That is correct for a test and wrong for a phone,
/// so the app shells set both of these at launch and a test asserts they do.
public private(set) var tally: TrialTally?
/// Turn on the free allowance and the App Store.
///
/// Called by the app shells at launch and by nothing else. The suite and the command line
/// deliberately never call it: they play tens of thousands of hands, have no App Store to talk
/// to, and must not meet a paywall.
public func startCharging() {
guard tally == nil else { return } // `.task` can run twice; a second store would not
let store = SubscriptionStore()
charge(for: TrialTally(), through: store)
Task { await store.start() }
}
/// Hand the app the two halves of the subscription. Split out from `startCharging` so a test
/// can pass a tally over an in-memory store.
public func charge(for tally: TrialTally, through subscription: SubscriptionStore) {
self.tally = tally
self.subscription = subscription
// The session counts the hands, because it owns the one place a hand is written down.
// Setting it here rather than at the call site is what stops it being forgotten.
session.tally = tally
// A renewal, a refund or a purchase finishing on another device all land on the store
// rather than here. Without this the paywall would stay up after somebody had paid.
subscription.didChange = { [weak self] in self?.dismissPaywallIfPaid() }
}
/// How many hands have been counted against the allowance.
public var handsPlayed: Int { tally?.handsPlayed ?? 0 }
// MARK: - The cash table
/// How the cash table is set up. Remembered between launches under this product's keys, so
/// somebody who plays 50/100 fixed-limit finds 50/100 fixed-limit waiting next time.
public var cashSetup: CashSetup = AppState.rememberedCashSetup() {
didSet {
AppState.remember(cashSetup)
// The session's coach follows the choice at once (a fixed-limit player reads the
// limit course before sitting down), not only when a sitting starts.
session.cashSetup = cashSetup
}
}
static func rememberedCashSetup(rung: LadderTier? = nil) -> CashSetup {
var setup = CashSetup.standard
// Until the player picks, the stakes are the rung the ladder has them at.
if let stakes = rung?.stakes { setup.stakes = stakes }
let defaults = UserDefaults.standard
let bb = defaults.integer(forKey: AppVariant.current.key("cash.bigBlind"))
if let stakes = CashSetup.stakesOnOffer.first(where: { $0.bigBlind == bb }) { setup.stakes = stakes }
if let raw = defaults.string(forKey: AppVariant.current.key("cash.structure")),
let structure = BettingStructure(rawValue: raw) { setup.structure = structure }
return setup
}
static func remember(_ setup: CashSetup) {
let defaults = UserDefaults.standard
defaults.set(setup.stakes.bigBlind, forKey: AppVariant.current.key("cash.bigBlind"))
defaults.set(setup.structure.rawValue, forKey: AppVariant.current.key("cash.structure"))
}
/// Sits down at the cash table as it is set up.
public func startCash() {
session.cashSetup = cashSetup
start(.cash)
}
/// What the last cash sitting came to, once the player has cashed out.
public var lastCashResult: SessionResult? { session.lastCashResult }
/// How betting is limited at the table on screen.
public var bettingStructure: BettingStructure { session.bettingStructure }
/// How often the player has bought back in this sitting.
public var heroRebuys: Int { session.rebuys[session.heroID] ?? 0 }
/// Whether Apple says this is a paying customer.
public var isSubscribed: Bool { subscription?.isSubscribed ?? false }
/// What the player is entitled to right now.
///
/// With no tally there is nobody to charge, so everything is open. That is the suite and the
/// command line, both of which play tens of thousands of hands and must not hit a paywall.
public var entitlement: Entitlement {
guard let tally else { return .subscribed }
return tally.entitlement(subscribed: subscription?.isSubscribed ?? false)
}
/// Whether a hand may be dealt.
///
/// While Apple has not answered yet, the answer is yes. Before the first answer "not
/// subscribed" means "not known", and stopping a paying customer at a paywall on a slow
/// connection is worse than letting an unpaid hand through.
public var mayPlay: Bool {
if let subscription, !subscription.hasCheckedWithApple { return true }
return entitlement.mayPlay
}
/// Whether to mention the allowance on Home. Never while a hand can still be dealt for free
/// without the end being in sight.
public var shouldWarnAboutAllowance: Bool {
guard tally != nil else { return false }
// A hundred free hands is short enough that the count is worth seeing throughout; it is
// also where a free player finds the price without going looking.
if case .trial = entitlement { return true }
return false
}
/// How many free hands are left, for the notice on Home.
public var handsLeft: Int {
if case .trial(let left) = entitlement { return left }
return 0
}
/// Show the price because somebody asked to see it.
public func showPaywall() { sheet = .paywall(dismissable: true) }
/// Take the paywall down the moment Apple says they have paid — including when the payment
/// went through somewhere else entirely, or an Ask to Buy was approved hours later.
private func dismissPaywallIfPaid() {
guard case .paywall = sheet, entitlement.mayPlay else { return }
sheet = nil
}
/// Everything that can open over the top of a screen.
///
/// One enum and one presenter, deliberately. A view has a single sheet: raising a second flag
/// while lowering the first — which is what handing the review over to the drill report used to
/// do — is the standard way to get the second sheet silently never appearing. Here the handover
/// is one assignment, so there is nothing to race.
enum Sheet: Identifiable {
case tutorial
case review
case assessmentBrief
case assessmentReport
case drillBrief(DrillTarget, retest: Bool)
case drillReport(DrillRun)
/// The price. `dismissable` is false when the free hands are gone — there is no table
/// behind it to go back to — and true when somebody opened it themselves.
case paywall(dismissable: Bool)
var id: String {
switch self {
case .paywall(let dismissable): return "paywall-\(dismissable)"
case .tutorial: return "tutorial"
case .review: return "review"
case .assessmentBrief: return "assessment-brief"
case .assessmentReport: return "assessment-report"
case .drillBrief(let target, let retest):
return "drill-brief-\(retest ? "check" : "drill")-\(target.id)"
case .drillReport(let run): return "drill-report-\(run.target.id)"
}
}
}
/// Which screen is showing.
///
/// Assigning this is what stops and starts the table, and that is the whole of the fix for a
/// game that used to carry on without anybody watching it. The sidebar and the tab bar only set
/// this, so a player who called a bet and then went to look at their stats left eight opponents
/// still acting on a timer: the hand finished off screen, the review opened over My game, and
/// the next hand was dealt at a table nobody was at.
///
/// Going anywhere else parks the table exactly where it stands. Coming back to it picks the
/// same hand up. Nothing is thrown away — a tournament somebody is forty hands into is not
/// something to discard because they glanced at the Hands list — and nothing is dealt to an
/// empty chair. Leaving properly is still leaving: the Leave button calls `leaveTable`, which
/// puts the sitting down.
/// Where you were when you last closed the app.
///
/// The table is deliberately never restored: opening to a felt you are not sitting at would be
/// a lie about the state of a tournament. Everything else is worth coming back to — somebody
/// working through the drills should not be returned to the front door every launch.
public var screen: Screen = AppState.rememberedScreen {
didSet {
guard screen != oldValue else { return }
if screen == .table { resumeTable() } else { pauseTable() }
AppState.remember(screen)
}
}
static var screenKey: String { AppVariant.current.key("screen") }
static var rememberedScreen: Screen {
// An override for the screenshot script, which has to open the app on a given screen and
// cannot tap. It goes through the environment rather than through the saved setting because
// the simulator caches an app's preferences and quietly discards a write made from outside
// — which looked exactly like this code being broken, for an afternoon.
if let named = ProcessInfo.processInfo.environment["POKERTUTOR_SCREEN"],
let screen = Screen(rawValue: named) { return screen }
guard let raw = UserDefaults.standard.string(forKey: screenKey),
let screen = Screen(rawValue: raw), screen != .table else { return .home }
return screen
}
static func remember(_ screen: Screen) {
guard screen != .table else { return }
UserDefaults.standard.set(screen.rawValue, forKey: screenKey)
}
/// What is open over the screen, if anything.
var sheet: Sheet?
/// The same thing as a name, for anybody outside this module — which means the suite. The sheet
/// itself stays internal: what a test needs to know is which one is up, and "none" is the
/// answer most of these tests are about.
public var openSheet: String? { sheet?.id }
/// Bumped when the colours change, to redraw a view tree that reads them statically.
/// A card being held up to be read. Nil almost always.
public private(set) var magnified: Card?
/// Touch a card to see it properly; touch again to put it back.
public func magnify(_ card: Card) {
withAnimation(.easeOut(duration: 0.14)) {
magnified = magnified == card ? nil : card
}
}
public func putCardBack() {
withAnimation(.easeOut(duration: 0.14)) { magnified = nil }
}
private(set) var palette = Theme.palette
/// The same trick for the size of the type, which is read statically for the same reason.
private(set) var textScale = Theme.textScale
private(set) var cardColours = Theme.cardColours
private static var tutorialKey: String { AppVariant.current.key("tutorialSeen") }
/// The session is a plain class that SwiftUI cannot watch. Every screen reaches it through
/// this property, which reads `generation` on the way — so bumping that counter (`redraw()`)
/// redraws every screen that read anything from the session. That is the same "something
/// moved" signal `ObservableObject` used to give for free, kept on purpose: the alternative
/// is teaching the session about SwiftUI, and it is the one part of the app that must not know.
public var session: TableSession { _ = generation; return _session }
private let _session: TableSession
private var generation = 0
/// Tell the screens the session moved.
private func redraw() { generation &+= 1 }
/// Which hands the Hands and My game screens are looking at.
var scope: HistoryFilter = .allTime
var history: HistoryStore { session.history }
/// The hands in scope, newest last. Recomputed rather than cached: it is one pass over a few
/// thousand rows at the very most, and a stale analysis would be worse than a slow one.
var scopedHands: [HandRecord] {
switch scope {
case .thisSession: return history.hands(in: .session(session.sessionID))
case .allTime: return history.hands(in: .allTime)
case .past(let id): return history.hands(in: .session(id))
}
}
var analysis: GameAnalysis { GameAnalysis(of: scopedHands) }
/// The leaks, ranked by what they have cost. Built from the hands in view rather than from the
/// coach's memory: memory starts empty every time the app opens, and cannot be pointed at one
/// sitting.
///
/// The drills are always read from every hand ever played, whatever the scope bar says. Where a
/// habit was drilled is a fact about the player rather than about the sitting being looked at,
/// and a re-test that vanished because the scope was set to this evening would be a re-test
/// nobody ever did.
///
/// The last finished assessment goes in for the same reason the drills do, and it is the fix
/// for the app having told somebody two opposite things at once: the report card on Home said
/// one habit was clear enough to name while this card, one button away, said nothing was
/// confirmed and that nothing is called a habit until there have been twenty-five chances.
var scorecard: LeakScorecard {
LeakScorecard(of: scopedHands, drills: drills, assessment: finishedAssessment)
}
/// The same card over everything ever played, whatever the scope bar on My game is set to.
///
/// Home has no scope bar and is not a scoped screen, so anything it shows has to be a fact
/// about the player rather than about whichever sitting somebody was last looking at. Without
/// this, setting the scope to this evening on My game made the drill card disappear from Home
/// on the next launch, with nothing on Home to explain why.
public var allTimeScorecard: LeakScorecard {
LeakScorecard(of: history.hands(in: .allTime), drills: drills, assessment: finishedAssessment)
}
/// What a player who has not paid is shown: one habit in full, the rest blurred, one drill.
///
/// Read over everything ever played, never over the scope bar on My game: the one habit a free
/// player is shown is a fact about their game, and it must not change because they looked at
/// one evening's hands. A subscriber, the suite and the command line (no tally) see everything.
public var freeView: FreeView {
guard tally != nil else { return .open }
return FreeView(of: allTimeScorecard, entitlement: entitlement)
}
/// Starts the drill, or shows the price where the free tier does not include this habit.
/// Every route into a drill — the scorecard row, the drills list, a lesson's practice button,
/// the card on Home — comes through here, so no screen can forget.
func drillIfAllowed(_ leak: Leak, _ start: () -> Void) {
guard freeView.mayDrill(leakID: leak.id) else { return showPaywall() }
start()
}
/// Which seat the player loses most from, off everything ever played. Shown to everyone —
/// the by-seat table it is read from already is.
public var seatDiagnosis: SeatDiagnosis { SeatDiagnosis(of: history.hands(in: .allTime)) }
/// Whether the player may drill a seat. A subscriber always; a free player only where the
/// habit the seat is worst at is the one habit they are shown — a seat drill is a drill at
/// whatever goes wrong there, and the free tier includes one drill.
public func mayDrillSeat(_ position: Position) -> Bool {
let free = freeView
guard free.isRestricted else { return true }
guard let leading = seatDiagnosis.report(for: position)?.leading.first?.leak else { return false }
return free.mayDrill(leakID: leading.id)
}
/// The seat drill's target as it would be dealt — the seat, at the table size it was played
/// at, leaning towards the habit it is worst at.
public func seatTarget(_ position: Position) -> DrillTarget {
let report = seatDiagnosis.report(for: position)
return DrillTarget(seat: position, tableSize: report?.tableSize ?? TableSession.seatsAtTable,
leaning: report?.leading.first?.leak)
}
/// What picking a seat should do: carry a half-finished seat drill on, run its check, or
/// offer a fresh one. The price where the free tier does not include it.
public func startOrResumeSeatDrill(_ position: Position) {
guard mayDrillSeat(position) else { return showPaywall() }
let target = seatTarget(position)
if let standing = drills.standing(of: target.id) {
if standing.stage == .running, standing.sessionID != nil { return resumeDrill(standing) }
if standing.stage == .due { return offerDrill(target, retest: true) }
}
offerDrill(target)
}
/// The sitting on the table right now, as it was written down, with the arranged hands left
/// out.
///
/// Home's "this session" panel is built from this rather than from `Coach.profile`, which is
/// not the sitting at all: the coach's memory is everything since the app opened and is cleared
/// by nothing short of resetting the history, so sitting a tournament, then an assessment, then
/// a drill added all three together and priced them per hundred hands as one evening's poker.
/// The arranged hands come out here for the same reason they come out everywhere else — a drill
/// is practice at one situation with the player watching for it, and the app promises in as
/// many words that those hands touch neither the win rate nor the leaks.
var sittingHands: [HandRecord] {
history.hands(in: .session(session.sessionID)).filter { !$0.isArranged }
}
var sittingAnalysis: GameAnalysis { GameAnalysis(of: sittingHands) }
/// The habits this sitting confirmed, off the same hands the numbers beside them come from.
var sittingLeaks: LeakScorecard { LeakScorecard(of: sittingHands) }
/// Every habit that has been drilled, and what is owed on each.
var drills: DrillBook { session.drills }
var pastSessions: [SessionSummary] {
history.sessions().filter { $0.id != session.sessionID }
}
var scopeName: String {
switch scope {
case .thisSession: return "This session"
case .allTime: return "All time"
case .past(let id):
guard let summary = history.sessions().first(where: { $0.id == id }) else { return "A session" }
return HistoryFilter.title(for: summary)
}
}
// There is deliberately no way to the coach's own memory from here. It is the within-hand
// judging state: it starts empty every launch, it is emptied by nothing short of resetting the
// history, and it counts arranged hands. Every screen reads the written-down hands instead, and
// the two times a screen reached for that memory — for the rating, and for "this session" — it
// reported something the app had promised in writing that it would not.
/// What the player is worth, off the hands on disk. There is one rating in the app and this is
/// where it comes from: `Coach.profile` is the within-hand judging state and starts empty every
/// launch, so a rating read off it tells somebody with a year of hands behind them that they
/// have never played. It also counted drill hands, which would have let anyone raise their own
/// rating by practising rather than by playing differently.
public var rating: SkillRating { allTimeScorecard.rating }
/// The hands the rating counts and the hands it leaves out, so Home can add its three numbers
/// up in words. `LeakScorecard` keeps drill and re-check hands out of the rating on purpose —
/// practice at one situation with you watching for it is not how you play when it turns up
/// unannounced — and the same rule is applied here rather than copied.
public var playedHands: Int { history.hands.filter { !$0.isDrill && !$0.isRetest }.count }
public var practiceHands: Int { history.hands.count - playedHands }
/// True from the moment a sitting runs out of hands until the report it opened is put away, so
/// the report can say the sitting is over rather than just appearing over Home.
public private(set) var assessmentJustFinished = false
/// The habit worth working on next, from the same hands for the same reason.
///
/// For a free player it is the one habit they are shown — the most common — rather than the
/// most expensive, so Home and the scorecard name the same thing.
public var focus: Leak? {
let card = allTimeScorecard
let free = freeView
if free.isRestricted {
guard let id = free.visibleLeakID, free.basis == .named else { return nil }
return card.costing.first { $0.id == id }?.leak
}
return card.costing.first?.leak
}
/// Where the player is on the ladder. Read off the saved hands, so it is the same after a
/// restart as it was before one.
var ladder: LadderStanding { session.ladder }
/// How long each opponent takes. Long enough to read who did what, short enough not to wait on.
private let opponentPace = Duration.milliseconds(620)
private var dealer: Task<Void, Never>?
/// A clean hand gets a thumbs up on the felt and nothing else — see `reviewShouldInterrupt`.
public private(set) var thumbsUp = false
// MARK: - Playing
public func start(_ mode: GameMode) {
// Every way onto a table comes through here — Home, the ladder, a drill, the assessment —
// which is why the check is here and not on each of the buttons.
guard mayPlay else {
sheet = .paywall(dismissable: false)
return
}
dealer?.cancel()
magnified = nil
thumbsUp = false
sheet = nil
changing { session.start(mode) }
screen = .table
runOpponents()
}
public func leaveTable() {
dealer?.cancel()
magnified = nil
thumbsUp = false
changing { session.leave() }
sheet = nil
screen = .home
}
public func act(_ action: PlayerAction) {
let applied = session.lastActions[session.heroSeat]
changing { session.act(action) }
// Only make a noise if the action was actually accepted.
if let now = session.lastActions[session.heroSeat], now != applied {
play(for: now)
}
// Folded? The hand is over for them. Run the rest out at once and get to the feedback
// rather than making them watch a pot they are not in.
if session.isPlaying, !session.heroInHand, !handComplete {
changing { session.finishHandNow() }
return
}
runOpponents()
}
public func continueAfterReview() {
sheet = nil
// A card held up belongs to the hand it came from. Carrying it into the next one shows
// somebody a card that is no longer on the table — the same fault as a definition about
// the last hand still sitting above the felt during this one.
magnified = nil
thumbsUp = false
// The allowance runs out mid-sitting, because hands are counted as they finish. The hand
// just played is honoured — it was already dealt — and the next one is where the table
// stops. Stopping any earlier would take away a hand somebody was in the middle of.
guard mayPlay else {
sheet = .paywall(dismissable: false)
return
}
changing { session.continueAfterReview() }
if session.mode == .drill || session.mode == .retest, !session.isPlaying {
// The drill has run out of hands, one way or the other. Off the felt and into the
// report — including when the report is "this did not clear", which is the one it must
// never quietly skip. One assignment rather than a dismissal and a presentation, which
// is why the report cannot be lost on the way.
screen = .leaks
sheet = session.drill.map(Sheet.drillReport)
return
}
if session.mode == .assessment, !session.isPlaying {
// The sitting has run out of hands. Off the felt and into the report, which is the end
// the whole thing was announced as having.
screen = .home
assessmentJustFinished = true
sheet = .assessmentReport
return
}
runOpponents()
}
// MARK: - The assessment
var assessment: AssessmentRun? { session.assessment }
/// A sitting that was started and walked away from, if there is one to offer.
var resumableAssessment: SessionSummary? { session.unfinishedAssessment() }
/// The sitting whose report is worth opening: the one that just ended, or the last one that ran
/// to the end. The fallback matters — leaving the table puts the run down, and the report has to
/// still be readable afterwards.
///
/// A sitting still being played is deliberately not it. `session.assessmentReport` answers for
/// as long as there is a run at all, finished or not, so Home used to show one assessment's
/// headline — "40 hands. 2 habits were already clear enough to be worth naming" — beside a
/// button that opened a different one, "You stopped 7 hands into 40". A card and its own button
/// have to be about the same sitting. What the sitting on the table has so far is on the table,
/// where the player left it.
public var assessmentReport: AssessmentReport? {
if assessmentInProgress { return session.lastFinishedAssessment() }
return session.assessmentReport ?? session.lastFinishedAssessment()
}
public var finishedAssessment: AssessmentReport? { session.lastFinishedAssessment() }
/// Whether an assessment is on the table right now.
public var assessmentInProgress: Bool { session.mode == .assessment && session.isPlaying }
func offerAssessment() {
// Already sitting at one: go back to it. The card cannot offer to carry on with the sitting
// that is on the table — the player is in it — so without this the only button left on the
// card would start a second assessment and orphan the first.
if assessmentInProgress {
screen = .table
return
}
sheet = .assessmentBrief
}
public func beginAssessment() {
dealer?.cancel()
thumbsUp = false
sheet = nil
changing { session.startAssessment() }
screen = .table
runOpponents()
}
func resumeAssessment() {
guard let sitting = session.unfinishedAssessment() else { return beginAssessment() }
dealer?.cancel()
thumbsUp = false
sheet = nil
changing { session.resumeAssessment(sitting.id) }
screen = .table
runOpponents()
}
public func showAssessmentReport() {
assessmentJustFinished = false
sheet = .assessmentReport
}
// MARK: - Drills
/// The drill on the table, or nil when this is not one.
var drill: DrillRun? { session.drill }
/// What picking a habit off the scorecard should do, which depends on where its drill stands:
/// carry a half-finished one on, run the check that has come due, or start a fresh drill. The
/// screen asks for the habit and this decides — the view has no business knowing the states.
public func startOrResumeDrill(_ leak: Leak) {
drillIfAllowed(leak) {
if let standing = drills.standing(of: leak.id) {
if standing.stage == .running, standing.sessionID != nil { return resumeDrill(standing) }
if standing.stage == .due { return offerDrill(leak, retest: true) }
}
offerDrill(leak)
}
}
public func offerDrill(_ leak: Leak, retest: Bool = false) {
drillIfAllowed(leak) { sheet = .drillBrief(DrillTarget(habit: leak), retest: retest) }
}
func offerDrill(_ target: DrillTarget, retest: Bool = false) {
if let leak = target.leak { return offerDrill(leak, retest: retest) }
guard let seat = target.seat, mayDrillSeat(seat) else { return showPaywall() }
sheet = .drillBrief(target, retest: retest)
}
/// The brief holds the target and whether this is a check, so both are handed back rather than
/// parked in a second copy that could fall out of step with the sheet on screen.
func beginDrill(_ target: DrillTarget, retest: Bool) {
dealer?.cancel()
thumbsUp = false
sheet = nil
changing { session.startDrill(target, retest: retest) }
screen = .table
runOpponents()
}
func beginDrill(_ leak: Leak, retest: Bool) { beginDrill(DrillTarget(habit: leak), retest: retest) }
/// Carries a half-finished drill on from where it was left.
func resumeDrill(_ standing: DrillBook.Standing) {
if let leak = standing.leak {
guard freeView.mayDrill(leakID: leak.id) else { return showPaywall() }
} else if let seat = standing.target.seat {
guard mayDrillSeat(seat) else { return showPaywall() }
}
guard let id = standing.sessionID else { return offerDrill(standing.target) }
dealer?.cancel()
thumbsUp = false
sheet = nil
var resumed = false
changing { resumed = session.resumeDrill(standing.target, sessionID: id) }
guard resumed else { return offerDrill(standing.target) }
screen = .table
runOpponents()
}
/// Opens the review by hand. This has to stop the clock: on a hand played well the next one is
/// already on its way, and dealing it out from under someone who just asked to read the hand is
/// exactly the bug that made the button look broken.
public func showReview() {
dealer?.cancel()
thumbsUp = false
redraw()
sheet = .review
}
/// Whether the opponents are still on the clock. The window never asks; the suite does, because
/// "the table carried on dealing while nobody was looking at it" is otherwise invisible from
/// outside this class.
public var dealerIsRunning: Bool {
guard let dealer else { return false }
return !dealer.isCancelled
}
/// Stops the clock without putting the sitting down. The hand keeps its state; the opponents
/// stop acting until somebody is looking at them again.
private func pauseTable() {
dealer?.cancel()
thumbsUp = false
// A review raised at the table belongs to the table. Anywhere else it is a sheet nobody
// asked for, over a screen it says nothing about.
if case .review = sheet { sheet = nil }
}
/// Picks the table back up. A hand that finished while the player was away is left finished:
/// the bar under the felt offers the review and the next hand, which is the same place they
/// would have been had they never left.
private func resumeTable() {
guard !handComplete else {
// Coming back to a hand that finished while nobody was looking at it. The same two
// outcomes as if they had been sitting here: the review when there is something to
// say, otherwise the thumbs up and the next hand. Nothing to press, here or anywhere.
if reviewShouldInterrupt {
sheet = .review
} else if session.isPlaying {
approveAndDealOn()
}
return
}
runOpponents()
}
/// Called when whatever was open closes, however it closed — its own button, a swipe, or the
/// app putting something else up.
///
/// This is the whole of "the table deals on by itself". The review used to be closed by a
/// button that also dealt the next hand, so any other way out of it left the table stopped
/// with a Next hand button as the only way to un-stick it. That button is gone, which means
/// every way out of the review has to lead to the same place.
/// Continuing can raise the paywall, which means putting a sheet up inside another one's
/// dismissal. That is safe here for the same reason the app has one sheet and one presenter:
/// the handover is a single assignment to `sheet`, so there is no dismissal racing a
/// presentation. Doing it on a `Task` instead would put it beyond the reach of the suite,
/// which is worse — this is the path every finished hand now takes.
public func sheetDismissed() {
guard screen == .table, handComplete, session.isPlaying else { return }
continueAfterReview()
}
/// Deals the opponents out one at a time, so the hand can be watched rather than just arrived
/// at. Each decision is drawn and heard before the next one starts.
private func runOpponents() {
// A finished hand has already scheduled what happens next — the thumbs up and then the
// next hand — and that schedule lives on `dealer` too. Cancelling it here is what stranded
// the table.
//
// `act` ends by calling this unconditionally, so any hand that ended on the player's OWN
// action ran `approveAndDealOn`, set the timer, and then had it cancelled one line later.
// A hand that ended on an OPPONENT's action was inside the dealer task rather than inside
// `act`, so nothing cancelled it and the table dealt on by itself. That is the whole of
// "sometimes I need to press next hand, sometimes I don't" — the button was covering for
// this, and removing the button turned it into a table that locked.
guard !handComplete else { return }
dealer?.cancel()
guard session.opponentToAct else { return }
dealer = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.session.opponentToAct {
try? await Task.sleep(for: self.opponentPace)
guard !Task.isCancelled else { return }
self.stepOpponent()
if self.handComplete { return }
}
}
}
/// One opponent's decision, drawn and heard, and everything that follows from it if the hand
/// ended on it. The dealer above calls this on a timer; the suite calls it in a loop, which is
/// how the table can be played out without a clock in the room.
@discardableResult public func stepOpponent() -> Bool {
guard session.opponentToAct else { return false }
var acted = false
changing {
if let applied = session.stepOpponent() {
play(for: applied)
acted = true
}
}
return acted
}
/// The table's noises, or silence where there is nobody to hear them.
private func play(_ cue: Sound.Cue) {
guard !silent else { return }
Sound.shared.play(cue)
}
private func play(for applied: TableSession.AppliedAction) {
guard !silent else { return }
Sound.shared.play(for: applied)
}
private var handIsOver: Bool { session.review != nil && session.engine?.isComplete == true }
/// Whether to put the review in front of the player.
///
/// Only hands with something to say interrupt. A hand played well gets a thumbs up on the felt
/// and deals on — being stopped to be told you did nothing wrong is an interruption that costs
/// something and teaches nothing. The hand is still under Hands if it is ever wanted.
private var reviewShouldInterrupt: Bool {
guard let review = session.review, session.engine?.isComplete == true else { return false }
return !review.playedWell
}
/// `TableSession` is a plain class, so SwiftUI has to be told by hand that it moved.
private func changing(_ work: () -> Void) {
redraw()
let wasOver = handIsOver
work()
if handIsOver {
if !wasOver { play(.pot) }
// Both of these are things the table does to the person watching it, so both of them
// wait for somebody to be watching. The hand stays finished either way, and the bar
// under the felt offers the review and the next hand when they come back.
if screen == .table {
if reviewShouldInterrupt {
sheet = .review
} else if !wasOver {
approveAndDealOn()
}
}
}
redraw()
}
/// The whole of the feedback on a hand played well: a thumbs up, and then the next hand.
private func approveAndDealOn() {
thumbsUp = true
dealer?.cancel()
dealer = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(2100))
guard let self, !Task.isCancelled else { return }
self.thumbsUp = false
// Still playing? Deal on. Busted or finished, leave the table as it is.
if self.session.isPlaying { self.continueAfterReview() } else { self.redraw() }
}
}
/// True once a hand has finished, whether or not the review sheet is still up.
public var handComplete: Bool { handIsOver }
/// Set on the hands the coach had nothing to say about, so the table can show that quietly
/// instead of opening a sheet to say nothing.
var handWasClean: Bool { handIsOver && session.review?.playedWell == true }
/// Throws away every saved hand and the coach's read on the player. Irreversible from inside
/// the app: the previous file is kept on disk, but nothing here goes looking for it.
func resetHistory() {
redraw()
dealer?.cancel()
session.leave()
session.history.clear()
session.coach.reset()
scope = .allTime
sheet = nil
thumbsUp = false
screen = .home
redraw()
}
var historyBackupPath: String? {
session.history.hasBackup ? session.history.backupURL?.path : nil
}
func use(_ palette: Palette) {
redraw()
Theme.use(palette)
self.palette = palette
}
func use(_ scale: TextScale) {
redraw()
Theme.use(scale)
self.textScale = scale
}
func use(_ colours: CardColours) {
redraw()
Theme.use(colours)
self.cardColours = colours
}
func showTutorial() {
sheet = .tutorial
}
func finishTutorial() {
sheet = nil
UserDefaults.standard.set(true, forKey: Self.tutorialKey)
}
/// Closes whatever is open. The sheets themselves have "Not now" and "Close" buttons that all
/// come here rather than each reaching for a flag of its own.
public func dismissSheet() {
sheet = nil
assessmentJustFinished = false
}
var soundOn: Bool { Sound.shared.enabled }
func toggleSound() {
redraw()
Sound.shared.enabled.toggle()
}
// MARK: - Table readout
var mode: GameMode { session.mode }
var engine: HandEngine? { session.engine }
var isPlaying: Bool { session.isPlaying }
var heroSeat: Int { session.heroSeat }
var handNumber: Int { session.handNumber }
var level: BlindLevel { session.level }
var playersLeft: Int { session.playersLeft }
var tableDifficulty: Int { session.tableDifficulty }
var statusLine: String { session.statusLine }
var handSummary: String { session.handSummary }
var review: HandReview? { session.review }
var pot: Int { session.pot }
var board: [Card] { session.board }
var seats: [SeatState] { session.seats }
var positions: [Int: Position] { session.positions }
public var heroCards: [Card] { session.heroCards }
var actorSeat: Int? { session.actorSeat }
var legal: LegalActions? { session.legal }
var chipTray: ChipTray? { session.chipTray }
func label(forSeat seat: Int) -> String { session.label(forSeat: seat) }
func lastAction(forSeat seat: Int) -> TableSession.AppliedAction? { session.lastActions[seat] }
// Deliberately not exposed to the table: which style an opponent is playing is the read the
// player is meant to be making for themselves. `TableSession` still knows, for the tests and
// for the coach.
func revealedCards(forSeat seat: Int) -> [Card]? { session.revealedCards(forSeat: seat) }
}
/// What the analysis screens are pointed at. "This session" has to be resolved when it is read,
/// not stored, because sitting down again starts a new one.
enum HistoryFilter: Equatable, Hashable {
case thisSession
case allTime
case past(String)
static func title(for summary: SessionSummary) -> String {
let when = summary.started.formatted(date: .abbreviated, time: .shortened)
return "\(when) · \(Copy.hands(summary.hands))"
}
}