← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
/// The two store screenshots that need somebody playing: the felt with a decision on it, and the
/// coach's review of a hand.
///
/// The other six screens are reached by name through `POKERTUTOR_SCREEN`. These two cannot be:
/// the app never reopens onto a felt nobody is sitting at, and a review only exists once a hand
/// has been played to the end. So the screenshot script asks for a scene instead, through
/// `POKERTUTOR_DEMO`, and the app plays a fixed-seed game against itself until the scene is on
/// the table. Nothing in here runs unless that variable is set, and a test plays both scenes
/// headlessly, for both products, so the script cannot start producing an empty felt without
/// anybody noticing.
public enum DemoScene: String, CaseIterable, Sendable {
/// The player to act on the flop with a hand worth looking at.
case table
/// The coach's review open over a hand it had something to say about.
case review
/// The seed both scenes are played from. Fixed so the shots can be remade identically; the
/// hand each scene lands on is whatever this seed deals first that qualifies.
public static let seed: UInt64 = 20_260_912
static let environmentKey = "POKERTUTOR_DEMO"
/// The scene asked for in the environment, if any.
static var requested: DemoScene? {
guard let named = ProcessInfo.processInfo.environment[environmentKey] else { return nil }
return DemoScene(rawValue: named)
}
/// A player for the hero's chair. The review scene wants somebody who makes the odd mistake,
/// so the coach has something to open a sheet about. The table scene wants somebody who sees
/// flops: a sound player folds most hands before one, and a fixed seed then runs out of
/// tournament before it deals a flop worth photographing.
var player: Bot {
switch self {
case .table: return Bot(id: "demo", personality: .callingStation, skill: 40, seed: Self.seed)
case .review: return Bot(id: "demo", personality: .solidRegular, skill: 35, seed: Self.seed)
}
}
/// The table the cash product's scenes are played at: the middle stakes, so the chips in the
/// shot are the ones most people would recognise. The felt is shot at fixed-limit, because
/// that is the one thing no other table in these apps shows — the tray gone, the bet written
/// on the button, "Limit: Fixed-limit" in the header — while the review is shot at no-limit,
/// where the cash lessons about the money behind and a stack going in with one pair live.
public func cashSetup() -> CashSetup {
let stakes = BlindLevel(smallBlind: 50, bigBlind: 100)
switch self {
case .table: return CashSetup(stakes: stakes, structure: .fixedLimit)
case .review: return CashSetup(stakes: stakes, structure: .noLimit)
}
}
}
extension AppState {
/// Plays the session until `scene` is on the table, and leaves the app showing it.
///
/// Returns false if the seed never produced the scene within the hands allowed, which the
/// test turns into a failure — a screenshot script that quietly captured Home instead of the
/// felt is exactly the kind of stale-by-accident this exists to prevent.
@discardableResult
public func arrangeDemo(_ scene: DemoScene, handsAllowed: Int = 400) -> Bool {
var bot = scene.player
sheet = nil
// The cash product's scenes are played at a cash table, so the shot shows the product:
// the stakes and the limit in the header, and a review about the money behind rather
// than about a tournament stack.
let mode: GameMode = session.variant.format == .cash ? .cash : .tournament
if mode == .cash { session.cashSetup = scene.cashSetup() }
session.start(mode)
var handsSeen = 0
var steps = 0
while steps < 60_000, handsSeen < handsAllowed {
steps += 1
// Busted or won: the next tournament, from the same stream of cards. A cash table
// never ends on its own — a bust buys back in.
if !session.isPlaying { session.start(mode) }
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
if scene == .table, Self.isWorthAShot(view) {
screen = .table
return true
}
_ = session.act(bot.decide(view))
} else if session.engine?.isComplete == true {
handsSeen += 1
if scene == .review, let review = session.review, !review.playedWell,
Self.isWorthShowing(review, patience: handsSeen < handsAllowed / 2,
format: session.variant.format) {
// Setting the screen is what raises the sheet: coming back to a finished hand
// with something to say puts the review up, the same as it does for a person.
screen = .table
if openSheet != "review" { showReview() }
return true
}
session.continueAfterReview()
} else if session.stepOpponent() == nil {
return false
}
}
return false
}
/// A review worth photographing: one whose headline is a piece of coaching about a decision
/// — a call or a fold that was wrong, too many hands played — rather than a sizing quibble.
/// While there are hands to spare the scene waits for one of those; after that any hand the
/// coach spoke about will do.
///
/// At a cash table the first choice is a cash lesson — the money behind, a stack that went in
/// with one pair, a re-raise called without the odds, a limit habit — and the shared list is
/// the second.
static func isWorthShowing(_ review: HandReview, patience: Bool,
format: AppVariant.Format = .tournament) -> Bool {
guard patience else { return true }
guard let error = review.worthMentioning.first?.error else { return false }
return wanted(for: format).contains(error)
}
static func wanted(for format: AppVariant.Format) -> Set<ErrorKind> {
let shared: Set<ErrorKind> = [.calledTooWide, .foldedTooMuch, .playedTooManyHands,
.limpedInstead]
switch format {
case .tournament: return shared.union([.shovedTooWide, .failedToShove])
case .cash: return shared.union([.missedImpliedOdds, .stackedOffOnePair,
.setMinedWithoutOdds, .cappedLight, .missedFreeCard])
}
}
/// A flop worth photographing: the player still has an opponent, there is a pot to look at,
/// and the hand is either already something — two pair or better — or has a real draw, the
/// two things a decision on the flop is usually about.
static func isWorthAShot(_ view: DecisionView) -> Bool {
guard view.street == .flop, view.opponentCount >= 1,
view.pot >= 6 * view.bigBlind else { return false }
let read = HandReader.assess(hole: view.holeCards, board: view.board,
opponents: view.opponentCount)
return read.category >= .twoPair || read.outs >= 8
}
}