← poker-tutor
import Foundation
/// What one seat can see of another.
public struct SeatView: Sendable, Equatable {
public let seat: Int
public let playerID: String
public let stack: Int
public let committedThisRound: Int
public let committedThisHand: Int
public let antePaid: Int
public let status: PlayerStatus
public let position: Position?
/// Only ever filled in for the seat doing the looking.
public let hole: [Card]?
public var isInHand: Bool { status.isInHand }
}
/// Everything a player legitimately knows when it is their turn.
///
/// This type exists to make cheating impossible rather than merely discouraged: bots receive a
/// `DecisionView`, never the engine, so there is no path by which they can read another player's
/// cards. The coach uses the same view when judging what the player could have known.
public struct DecisionView: Sendable {
public let hero: Int
public let holeCards: [Card]
public let board: [Card]
public let street: Street
public let pot: Int
public let legal: LegalActions
public let seats: [SeatView]
public let actions: [ActionRecord]
public let level: BlindLevel
public let buttonSeat: Int
public let position: Position
/// How betting works at this table. Fixed-limit is a different game to play and to coach —
/// one bet size, a cap on raises — so anyone reading this view can ask.
public let structure: BettingStructure
// MARK: Convenience
public var bigBlind: Int { level.bigBlind }
public var toCall: Int { legal.callCost }
public var heroSeat: SeatView { seats.first { $0.seat == hero }! }
public var heroStack: Int { heroSeat.stack }
/// Opponents still contesting the pot.
public var opponents: [SeatView] { seats.filter { $0.seat != hero && $0.isInHand } }
public var opponentCount: Int { opponents.count }
public var isHeadsUp: Bool { opponentCount == 1 }
/// How many players were dealt into this hand. Ranges depend on it heavily.
public var playersDealtIn: Int { seats.filter { $0.status != .sittingOut }.count }
/// The most the hero can lose in this hand: their own stack, or the deepest opponent's if
/// that is smaller, counting what is already committed.
///
/// Deliberately the DEEPEST opponent, not the shortest: in a multiway pot the hero's whole
/// stack is at risk against the biggest one still in. Every tournament bar was swept on this
/// number, so it does not move. A question about ONE opponent's money — what a raiser can pay
/// off, what a bettor is playing for — is `effectiveStack(against:)`.
public var effectiveStack: Int {
let heroTotal = heroSeat.stack + heroSeat.committedThisRound
let deepest = opponents.map { $0.stack + $0.committedThisRound }.max() ?? heroTotal
return min(heroTotal, deepest)
}
/// The most that can pass between the hero and one particular seat: the smaller of the two
/// stacks, counting what each has already committed.
public func effectiveStack(against seat: Int) -> Int {
let heroTotal = heroSeat.stack + heroSeat.committedThisRound
guard let other = seats.first(where: { $0.seat == seat }) else { return heroTotal }
return min(heroTotal, other.stack + other.committedThisRound)
}
/// Opponents still in the hand who have not yet spoken on this street — the people who could
/// still raise behind a call.
public var playersStillToAct: Int {
let spoken = Set(actionsThisStreet.map(\.seat))
return opponents.filter { !spoken.contains($0.seat) }.count
}
/// Effective stack measured in big blinds — the number that decides whether a tournament
/// hand is a normal raise-or-fold spot or a push-or-fold one.
public var effectiveStackInBB: Double { Double(effectiveStack) / Double(max(1, bigBlind)) }
/// Share of the final pot a call must win to break even.
public var potOdds: Double {
guard toCall > 0 else { return 0 }
return Double(toCall) / Double(pot + toCall)
}
/// Stack-to-pot ratio: how much room is left to maneuver relative to what is already in.
public var stackToPotRatio: Double {
guard pot > 0 else { return .infinity }
return Double(effectiveStack) / Double(pot)
}
public var actionsThisStreet: [ActionRecord] { actions.filter { $0.street == street && !$0.isForced } }
/// Voluntary raises made before the flop: 1 means someone opened, 2 means a 3-bet, and so on.
public var preflopRaiseCount: Int {
actions.filter { $0.street == .preflop && !$0.isForced }.filter {
if case .raise = $0.action { return true }
if case .bet = $0.action { return true }
return false
}.count
}
/// True when nobody has voluntarily put money in yet before the flop.
public var isUnopenedPot: Bool {
street == .preflop && !actions.contains { !$0.isForced && $0.action != .fold }
}
/// The seat that made the last aggressive action on this street, if any.
public var lastAggressor: Int? {
actionsThisStreet.last {
if case .raise = $0.action { return true }
if case .bet = $0.action { return true }
return false
}?.seat
}
public var heroIsAggressor: Bool { lastAggressor == hero }
}
public extension HandEngine {
/// The view of the table available to a given seat. Returns nil unless it is that seat's turn.
func view(for seat: Int) -> DecisionView? {
guard let legal = legalActions(), legal.seat == seat else { return nil }
let positions = positions()
let views = seats.map { s in
SeatView(
seat: s.seat,
playerID: s.playerID,
stack: s.stack,
committedThisRound: s.committedThisRound,
committedThisHand: s.committedThisHand,
antePaid: s.antePaid,
status: s.status,
position: positions[s.seat],
hole: s.seat == seat ? s.hole : nil // the whole point of this type
)
}
return DecisionView(
hero: seat,
holeCards: seats[seat].hole,
board: board,
street: street,
pot: pot,
legal: legal,
seats: views,
actions: actions,
level: config.level,
buttonSeat: config.buttonSeat,
position: positions[seat] ?? .button,
structure: config.structure
)
}
/// The view for whoever is to act.
var currentView: DecisionView? {
guard let seat = actor else { return nil }
return view(for: seat)
}
}