← poker-tutor
import Foundation
import PokerCore
import PokerAI
/// Two specific cards someone might be holding.
public struct Combo: Hashable, Sendable {
public let a: Card
public let b: Card
public init(_ x: Card, _ y: Card) {
// Normalised so the same two cards always make the same combo.
if x.index < y.index { a = x; b = y } else { a = y; b = x }
}
public var cards: [Card] { [a, b] }
public var handClass: HandClass { HandClass([a, b]) }
public func blocks(_ card: Card) -> Bool { a == card || b == card }
/// A fixed order over every possible pair of cards.
///
/// It exists because a range is stored as a dictionary, and the order a Swift dictionary hands
/// its keys back changes every time the program starts. Anything that reads those keys in
/// order — drawing a sample, breaking a tie between two hands of equal strength — was therefore
/// answering slightly differently on each launch from the same seed, and the coach's verdicts
/// moved with it. A seeded hand has to play out the same way today and tomorrow, so the keys
/// are put in this order before anything is done with them.
public var order: Int { Int(a.index) * 52 + Int(b.index) }
}
/// What an opponent might be holding, as a weight on every possible pair of cards.
///
/// The coach cannot know an opponent's cards, and neither can the player. What it can do is what a
/// good player does: start from the hands somebody in that seat would have played that way, and
/// narrow it as the hand goes on. Every judgement the coach makes is made against this, not
/// against a random hand — which is the difference between "top pair is usually good" and "top
/// pair is behind everything that would bet twice here".
public struct HandRange: Sendable {
public private(set) var weights: [Combo: Double]
public init(weights: [Combo: Double] = [:]) { self.weights = weights }
/// Every hand somebody could hold, minus the cards we can see.
public static func everything(excluding dead: [Card]) -> HandRange {
let deadSet = Set(dead)
let live = Card.all.filter { !deadSet.contains($0) }
var weights: [Combo: Double] = [:]
for i in 0..<live.count {
for j in (i + 1)..<live.count {
weights[Combo(live[i], live[j])] = 1.0
}
}
return HandRange(weights: weights)
}
/// The strongest `fraction` of starting hands, before any cards are out.
public static func top(_ fraction: Double, excluding dead: [Card]) -> HandRange {
var range = everything(excluding: dead)
range.weights = range.weights.filter { PreflopTable.percentile($0.key.handClass) <= fraction }
return range
}
public var count: Int { weights.count }
public var totalWeight: Double { weights.values.reduce(0, +) }
public var isEmpty: Bool { weights.isEmpty }
public func removing(_ dead: [Card]) -> HandRange {
let deadSet = Set(dead)
return HandRange(weights: weights.filter { !deadSet.contains($0.key.a) && !deadSet.contains($0.key.b) })
}
/// Keeps the strongest `fraction` of the range on this board, by made-hand strength.
///
/// This is how an action narrows a range: someone who bets twice into a dangerous board is
/// weighted toward the top of whatever they started with.
/// - Parameter countingDraws: rank the draws that play for a stack one category up —
/// alongside a pair of their own high card: a flush draw to the queen or better, or a
/// flush draw with a straight draw beside it. A bare low flush draw, a bare straight draw
/// and a gutshot do not count: nobody puts a hundred blinds in on those. Off by default:
/// narrowing by what an opponent has *shown* stays about made hands, but the hands that
/// shove a flop are part draws. Counting every draw was tried and made the range so weak
/// that stacking off with any top pair priced as right.
public func strongest(_ fraction: Double, board: [Card], countingDraws: Bool = false) -> HandRange {
guard fraction < 0.999, !weights.isEmpty else { return self }
let ranked = rankedByStrength(board: board, countingDraws: countingDraws)
let keep = max(1, Int(Double(ranked.count) * fraction.clamped(to: 0.02...1.0)))
var out: [Combo: Double] = [:]
for (combo, _) in ranked.prefix(keep) { out[combo] = weights[combo] ?? 1 }
return HandRange(weights: out)
}
/// Every combo in the range, in a fixed order that does not depend on how the dictionary
/// happens to be laid out this run.
public var orderedCombos: [Combo] { weights.keys.sorted { $0.order < $1.order } }
/// Every combo, strongest first. Two hands of exactly equal strength — which is common on a
/// paired board — keep their fixed order rather than whichever way round the dictionary
/// offered them, so the same board narrows the same way every time.
public func rankedByStrength(board: [Card], countingDraws: Bool = false) -> [(Combo, Double)] {
let scored: [(Combo, Double)] = board.count >= 3
? orderedCombos.map { combo in
var value = Double(HandEvaluator.evaluate(combo.cards + board).value)
if countingDraws, board.count < 5 { value += Self.drawBonus(combo: combo, board: board) }
return (combo, value)
}
: orderedCombos.map { ($0, 1 - PreflopTable.percentile($0.handClass)) }
return scored.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.order < $1.0.order }
}
/// What a draw is worth when ranking hands that would play for a stack, in `HandRank` units:
/// a flush draw to the queen or better, or any flush draw with an eight-out straight draw
/// beside it, counts as one category — a pair of its own high card. Everything else counts
/// as nothing. Only draws that use a hole card count.
static func drawBonus(combo: Combo, board: [Card]) -> Double {
let category = Double(1 << 20)
let all = combo.cards + board
var suits: [Suit: Int] = [:]
for card in all { suits[card.suit, default: 0] += 1 }
let flushDrawSuit = suits.first { suit, count in
count == 4 && combo.cards.contains { $0.suit == suit }
}?.key
let flushDraw = flushDrawSuit != nil
let bigFlushDraw = flushDrawSuit.map { suit in
combo.cards.contains { $0.suit == suit && $0.rank >= .queen }
} ?? false
var mask: UInt16 = 0
for card in all { mask |= card.rank.bit }
let boardMask = board.reduce(UInt16(0)) { $0 | $1.rank.bit }
// Straight draws: how many cards a five-card window is short, counting the wheel.
var bestShort = 5
let windows: [UInt16] = (0..<9).map { 0b11111 << UInt16($0) } + [0b1_0000_0000_1111]
for window in windows where (mask & window).nonzeroBitCount >= 4 && (boardMask & window) != (mask & window) {
bestShort = min(bestShort, 5 - (mask & window).nonzeroBitCount)
}
let openEnded = bestShort <= 1 && Self.hasOpenEnd(mask: mask, boardMask: boardMask)
return bigFlushDraw || (flushDraw && openEnded) ? category : 0
}
/// Four in a row using a hole card, with room at both ends.
static func hasOpenEnd(mask: UInt16, boardMask: UInt16) -> Bool {
for low in 0..<10 {
let four: UInt16 = 0b1111 << UInt16(low)
guard mask & four == four, (boardMask & four) != four else { continue }
let below = low > 0 && low + 4 <= 12
if below { return true }
}
return false
}
/// Draws a sample of combos, for equity work that would be too slow exhaustively.
public func sample(_ count: Int, rng: inout SeededRandom) -> [Combo] {
let all = orderedCombos
guard all.count > count else { return all }
var out: [Combo] = []
var pool = all
for _ in 0..<count {
let index = Int(rng.next() % UInt64(pool.count))
out.append(pool[index])
pool.remove(at: index)
}
return out
}
}
/// Builds an opponent's likely range from how they have played the hand.
public enum RangeReader {
/// The range an opponent's preflop line suggests, before any board narrowing.
public static func preflopRange(for seat: SeatView, in view: DecisionView) -> HandRange {
let dead = view.holeCards + view.board
let players = view.playersDealtIn
let position = seat.position ?? .button
let preflop = view.actions.filter { $0.street == .preflop && !$0.isForced && $0.seat == seat.seat }
let raisesBefore = view.preflopRaiseCount
let raised = preflop.contains {
if case .raise = $0.action { return true }
if case .bet = $0.action { return true }
return false
}
let called = preflop.contains { $0.action == .call }
// At a fixed-limit table the opponents play the limit charts, so they are read off them;
// reading a limit raiser off the no-limit chart understated every range and had the
// coach preferring folds at the one game where folding is the expensive habit.
let format: CoachFormat = view.structure == .fixedLimit ? .limit : .tournament
var fraction: Double
if raised && raisesBefore >= 2 {
fraction = ReferenceRanges.fourBet(from: position, players: players, format: format) * 2.2
} else if raised {
fraction = raisesBefore >= 2
? ReferenceRanges.threeBet(from: position, versus: .button, players: players, format: format) * 1.8
: ReferenceRanges.open(position: position, players: players, format: format) * 1.35
} else if called {
fraction = ReferenceRanges.callVersusOpen(from: position, versus: .cutoff, players: players, format: format) * 1.8
} else {
// Checked the big blind, or limped in: very wide and weighted to nothing special.
fraction = 0.80
}
// Real opponents are looser than any chart. Widening deliberately keeps the coach from
// telling the player their good hand was beaten by a range nobody actually has.
fraction = fraction.clamped(to: 0.03...0.95)
return HandRange.top(fraction, excluding: dead)
}
/// Narrows a range by what the opponent has done on this street.
public static func narrow(_ range: HandRange, by seat: SeatView, in view: DecisionView) -> HandRange {
var out = range.removing(view.holeCards + view.board)
guard view.board.count >= 3 else { return out }
// At a fixed-limit table a bet is a fraction of the pot, so people bet and call with far
// more — a bet there says less about the hand behind it than a no-limit bet does.
let limit = view.structure == .fixedLimit
for action in view.actions where action.street == view.street && !action.isForced && action.seat == seat.seat {
switch action.action {
case .bet, .raise:
// Betting is polarising, but at amateur stakes it mostly means a real hand.
out = out.strongest(limit ? 0.72 : 0.55, board: view.board)
case .call:
out = out.strongest(limit ? 0.85 : 0.70, board: view.board)
case .check:
out = out.strongest(0.92, board: view.board) // barely narrows: everyone checks everything
case .fold:
break
}
}
return out
}
/// The range of the opponent whose action the player is answering — or the strongest threat
/// still in the hand when nobody has bet.
public static func threatRange(in view: DecisionView) -> HandRange {
let opponents = view.opponents
guard !opponents.isEmpty else { return HandRange.everything(excluding: view.holeCards + view.board) }
let target = view.lastAggressor.flatMap { seat in opponents.first { $0.seat == seat } } ?? opponents[0]
return narrow(preflopRange(for: target, in: view), by: target, in: view)
}
}
/// Hero's chances against a modelled range, rather than against a random hand.
public enum RangeEquity {
/// Share of the pot hero expects against this range with the board run out.
public static func equity(hole: [Card], board: [Card], versus range: HandRange,
trials: Int = 600, rng: inout SeededRandom) -> Double {
guard !range.isEmpty else { return 0.5 }
let combos = range.sample(min(trials, 120), rng: &rng)
guard !combos.isEmpty else { return 0.5 }
let dead = Set(hole + board)
let runoutsEach = max(1, trials / combos.count)
var wins = 0.0
var total = 0.0
for combo in combos {
guard !dead.contains(combo.a), !dead.contains(combo.b) else { continue }
let blocked = dead.union([combo.a, combo.b])
var stub = Card.all.filter { !blocked.contains($0) }
let needed = 5 - board.count
for _ in 0..<runoutsEach {
for i in 0..<needed {
let j = i + Int(rng.next() % UInt64(stub.count - i))
stub.swapAt(i, j)
}
let full = board + Array(stub.prefix(needed))
let mine = HandEvaluator.evaluate(hole + full)
let theirs = HandEvaluator.evaluate(combo.cards + full)
if mine > theirs { wins += 1 } else if mine == theirs { wins += 0.5 }
total += 1
}
}
return total > 0 ? wins / total : 0.5
}
}