← poker-tutor
import Foundation
import PokerCore
/// What a hand is, relative to the board — the distinction a player actually reasons with.
/// A pair of eights is a monster on 8-3-2 and nearly worthless on A-K-Q.
public enum MadeCategory: Int, Comparable, Sendable, CaseIterable {
case nothing = 0
case aceHigh
case underPair // pocket pair below the board's top card
case bottomPair
case middlePair
case topPairWeakKicker
case topPair
case overPair // pocket pair above every board card
case twoPair
case trips // paired board plus one in hand
case set // pocket pair matching a board card
case straight
case flush
case fullHouse
case monster // quads or a straight flush
public static func < (a: MadeCategory, b: MadeCategory) -> Bool { a.rawValue < b.rawValue }
public var name: String {
switch self {
case .nothing: return "no pair"
case .aceHigh: return "ace high"
case .underPair: return "an underpair"
case .bottomPair: return "bottom pair"
case .middlePair: return "middle pair"
case .topPairWeakKicker: return "top pair, weak kicker"
case .topPair: return "top pair"
case .overPair: return "an overpair"
case .twoPair: return "two pair"
case .trips: return "trips"
case .set: return "a set"
case .straight: return "a straight"
case .flush: return "a flush"
case .fullHouse: return "a full house"
case .monster: return "the near nuts"
}
}
}
public enum DrawKind: Sendable, Equatable {
case none
case backdoor
case gutshot
case overcards
case openEnded
case flushDraw
case combo // a flush draw plus a straight draw
public var name: String {
switch self {
case .none: return "no draw"
case .backdoor: return "a backdoor draw"
case .gutshot: return "a gutshot"
case .overcards: return "two overcards"
case .openEnded: return "an open-ended straight draw"
case .flushDraw: return "a flush draw"
case .combo: return "a combination draw"
}
}
}
/// How dangerous the board is, which decides how much a made hand is really worth.
public struct BoardTexture: Sendable {
public let isPaired: Bool
public let flushPossible: Bool // three or more of a suit out there
public let flushDrawPossible: Bool // exactly two of a suit
public let straightPossible: Bool
public let highCard: Rank?
public let connectedness: Double // 0 for rainbow rags, 1 for a coordinated mess
public init(board: [Card]) {
guard !board.isEmpty else {
isPaired = false; flushPossible = false; flushDrawPossible = false
straightPossible = false; highCard = nil; connectedness = 0
return
}
let ranks = board.map(\.rank)
var rankCounts: [Rank: Int] = [:]
for r in ranks { rankCounts[r, default: 0] += 1 }
isPaired = rankCounts.values.contains { $0 >= 2 }
var suitCounts: [Suit: Int] = [:]
for c in board { suitCounts[c.suit, default: 0] += 1 }
let topSuit = suitCounts.values.max() ?? 0
flushPossible = topSuit >= 3
flushDrawPossible = topSuit == 2 && board.count <= 4
var mask: UInt16 = 0
for r in ranks { mask |= r.bit }
// Any two cards that would complete a straight on this board.
var straight = false
for a in Rank.allCases {
for b in Rank.allCases where b >= a {
if HandEvaluator.straightHigh(mask | a.bit | b.bit) != nil { straight = true; break }
}
if straight { break }
}
straightPossible = straight
highCard = ranks.max()
let spread = Int((ranks.max()?.rawValue ?? 0)) - Int((ranks.min()?.rawValue ?? 0))
let tight = 1.0 - min(1.0, Double(spread) / 12.0)
connectedness = ((flushPossible ? 0.4 : (flushDrawPossible ? 0.2 : 0)) + tight * 0.4 + (isPaired ? 0.2 : 0)).clamped(to: 0...1)
}
}
/// A complete read of a hand on a board: what it is, what it is drawing to, and a single number
/// for how often it is likely to be best right now.
public struct HandAssessment: Sendable {
public let rank: HandRank
public let category: MadeCategory
public let draw: DrawKind
public let outs: Int
public let texture: BoardTexture
/// Rough chance this hand is currently the best one out there, against one opponent.
public let showdownValue: Double
/// Extra equity from cards still to come.
public let drawEquity: Double
/// Showdown value plus what the draw is worth — how the hand should be valued overall.
public var total: Double { (showdownValue + drawEquity * (1 - showdownValue)).clamped(to: 0...1) }
/// One pair of any kind — from a pair under the board to an overpair — and nothing better.
public var isOnePair: Bool { category >= .underPair && category <= .overPair }
public var summary: String {
draw == .none ? category.name : "\(category.name) with \(draw.name)"
}
}
public enum HandReader {
/// Reads a hand against a board. Preflop (an empty board) returns the hand's raw equity.
public static func assess(hole: [Card], board: [Card], opponents: Int = 1) -> HandAssessment {
let texture = BoardTexture(board: board)
guard board.count >= 3 else {
let equity = PreflopTable.equity(hole, opponents: opponents)
return HandAssessment(
rank: HandRank(value: 0), category: .nothing, draw: .none, outs: 0,
texture: texture, showdownValue: equity, drawEquity: 0
)
}
let rank = HandEvaluator.evaluate(hole + board)
let category = categorise(hole: hole, board: board, rank: rank)
let (draw, outs) = readDraw(hole: hole, board: board, rank: rank)
let streetsToCome = max(0, 5 - board.count)
// The rule of two and four: each out is worth about 2% per card still to come.
var drawEquity = streetsToCome == 0 ? 0 : Double(outs) * 0.02 * Double(streetsToCome)
if category >= .twoPair { drawEquity *= 0.3 } // already strong; improving matters less
drawEquity = drawEquity.clamped(to: 0...0.55)
let showdown = showdownValue(category: category, texture: texture, opponents: opponents)
return HandAssessment(
rank: rank, category: category, draw: draw, outs: outs, texture: texture,
showdownValue: showdown, drawEquity: drawEquity
)
}
// MARK: - What the hand is
static func categorise(hole: [Card], board: [Card], rank: HandRank) -> MadeCategory {
let boardRanks = board.map(\.rank).sorted(by: >)
let holeRanks = hole.map(\.rank).sorted(by: >)
let topBoard = boardRanks.first ?? .two
switch rank.category {
case .straightFlush, .fourOfAKind:
return .monster
case .fullHouse:
return .fullHouse
case .flush:
return .flush
case .straight:
return .straight
case .threeOfAKind:
// A pocket pair matching the board is a set; a paired board is trips.
return holeRanks[0] == holeRanks[1] ? .set : .trips
case .twoPair:
return .twoPair
case .pair:
return categorisePair(holeRanks: holeRanks, boardRanks: boardRanks, topBoard: topBoard)
case .highCard:
return holeRanks.contains(.ace) ? .aceHigh : .nothing
}
}
private static func categorisePair(holeRanks: [Rank], boardRanks: [Rank], topBoard: Rank) -> MadeCategory {
// A pocket pair: is it above the board, or under it?
if holeRanks[0] == holeRanks[1] {
return holeRanks[0] > topBoard ? .overPair : .underPair
}
// Otherwise one hole card has paired the board. Which one?
guard let paired = holeRanks.first(where: { boardRanks.contains($0) }) else {
return holeRanks.contains(.ace) ? .aceHigh : .nothing
}
let kicker = holeRanks.first { $0 != paired } ?? .two
if paired == topBoard {
// Top pair. A weak kicker is the difference between a hand that can call a raise
// and one that cannot.
return kicker >= .jack ? .topPair : .topPairWeakKicker
}
if paired == boardRanks.min() { return .bottomPair }
return .middlePair
}
// MARK: - What the hand is drawing to
/// Counts the cards that actually improve *this* hand.
///
/// The tempting shortcut — "any card that raises my hand category" — is wrong, because a card
/// that pairs the board raises everyone's hand equally and helps nobody. An out has to use a
/// hole card. Getting this right matters beyond the bots: the coach tells the player how many
/// outs they had, and a coach that says fifteen when the answer is nine is worse than silent.
static func readDraw(hole: [Card], board: [Card], rank: HandRank) -> (DrawKind, Int) {
guard board.count >= 3, board.count < 5 else { return (.none, 0) }
let seen = Set(hole + board)
let unseen = Card.all.filter { !seen.contains($0) }
var outCards = Set<Card>()
// A flush draw needs four of a suit with at least one of them in hand.
var suitCounts: [Suit: Int] = [:]
for card in hole + board { suitCounts[card.suit, default: 0] += 1 }
var hasFlushDraw = false
if rank.category < .flush,
let suit = suitCounts.first(where: { $0.value == 4 })?.key,
hole.contains(where: { $0.suit == suit }) {
hasFlushDraw = true
outCards.formUnion(unseen.filter { $0.suit == suit })
}
// A straight draw: which ranks would complete one, and does hero need a hole card for it?
var straightRanks = Set<Rank>()
if rank.category < .straight {
let boardMask = board.reduce(UInt16(0)) { $0 | $1.rank.bit }
let fullMask = (hole + board).reduce(UInt16(0)) { $0 | $1.rank.bit }
for candidate in Rank.allCases {
let withCard = fullMask | candidate.bit
guard HandEvaluator.straightHigh(withCard) != nil else { continue }
// If the board alone plus that card already makes the straight, everyone has it.
guard HandEvaluator.straightHigh(boardMask | candidate.bit) == nil else { continue }
straightRanks.insert(candidate)
}
for rankNeeded in straightRanks {
outCards.formUnion(unseen.filter { $0.rank == rankNeeded })
}
}
// Two overcards to the board: pairing either usually wins, but only when there is
// genuinely nothing else going on.
let holeRanks = hole.map(\.rank)
let boardHigh = board.map(\.rank).max() ?? .two
let overcards = rank.category == .highCard && holeRanks.allSatisfy { $0 > boardHigh }
if overcards && outCards.isEmpty {
outCards.formUnion(unseen.filter { holeRanks.contains($0.rank) })
}
let kind: DrawKind
if hasFlushDraw && !straightRanks.isEmpty {
kind = .combo
} else if hasFlushDraw {
kind = .flushDraw
} else if straightRanks.count >= 2 {
kind = .openEnded
} else if straightRanks.count == 1 {
kind = .gutshot
} else if overcards {
kind = .overcards
} else if board.count == 3 && backdoorPossible(hole: hole, board: board) {
kind = .backdoor
} else {
kind = .none
}
return (kind, outCards.count)
}
private static func backdoorPossible(hole: [Card], board: [Card]) -> Bool {
var suits: [Suit: Int] = [:]
for c in hole + board { suits[c.suit, default: 0] += 1 }
return suits.values.contains { $0 == 3 }
}
// MARK: - What the hand is worth
/// How often a made hand is best against one opponent, before considering draws.
/// Deliberately conservative on dangerous boards: a bare top pair on a coordinated board is a
/// bluff-catcher, and treating it as a value hand is one of the most expensive amateur habits.
static func showdownValue(category: MadeCategory, texture: BoardTexture, opponents: Int) -> Double {
var base: Double
switch category {
case .monster: base = 0.98
case .fullHouse: base = 0.95
case .flush: base = 0.88
case .straight: base = 0.84
case .set: base = 0.86
case .trips: base = 0.76
case .twoPair: base = 0.74
case .overPair: base = 0.66
case .topPair: base = 0.61
case .topPairWeakKicker: base = 0.52
case .middlePair: base = 0.36
case .bottomPair: base = 0.27
case .underPair: base = 0.30
case .aceHigh: base = 0.17
case .nothing: base = 0.09
}
// Danger the hand does not itself hold.
if category < .flush && texture.flushPossible { base *= 0.78 }
if category < .straight && texture.straightPossible { base *= 0.88 }
if category < .trips && texture.isPaired { base *= 0.90 }
// Every extra opponent is another chance somebody has something.
let extra = max(0, opponents - 1)
if extra > 0 { base = pow(base, 1.0 + Double(extra) * 0.35) }
return base.clamped(to: 0.02...0.99)
}
}