Advertisement

← poker-tutor

Sources/PokerCoach/Explain.swift

11.1 KB Download file
import Foundation
import PokerCore
import PokerAI

/// Turns a verdict into something worth reading.
///
/// The rule here is that a player should be able to act on what they read without knowing any
/// jargon, and should always be told *why*, not just what. "Fold" teaches nothing. "You needed to
/// win one time in four and this hand wins about one in three" teaches the shape of the decision,
/// which transfers to the next hand.
extension DecisionEvaluator {

    static func explainPreflop(hand: HandClass, spot: SpotKind, error: ErrorKind?,
                               view: DecisionView, reason: String, cost: Double) -> (String, String) {
        guard let error else { return ("", "") }
        // The seat number leads and the traditional name follows it — see Position.described.
        let position = view.position.described(inTableOf: view.playersDealtIn)
        let cards = view.holeCards.map(\.pretty).joined(separator: " ")

        switch error {
        case .passedUpSteal:
            return ("That hand was worth raising with.",
                    "Nobody had come in yet, and \(cards) from \(position) is strong enough to raise with. \(reason) Folding it hands over the money already in the middle without anyone having to beat you for it.")
        case .limpedInstead:
            return ("Raise it or let it go, but do not just match the blind.",
                    "Just matching the blind with \(cards) lets everyone still to act come along cheaply, and gives up the chance to win it right there without seeing a card. \(reason)")
        case .playedTooManyHands:
            return ("That hand is too weak to be playing from where you sat.",
                    "\(cards) from \(position) is not worth getting involved with. \(reason) Hands like this tend to make you the second-best hand, and being second best is what quietly costs the most.")
        case .foldedTooMuch:
            if spot == .bigBlindDefence {
                return ("You gave up your blind too easily.",
                        "Your blind is already in the middle, and the price to carry on is \(oddsText(view)), so \(cards) is worth keeping. \(reason) Giving up the blind too readily is the most expensive habit there is, simply because it comes round again every time the deal goes round the table.")
            }
            return ("That was worth carrying on with.",
                    "\(cards) is good enough to carry on with from \(position). \(reason)")
        case .calledTooWide:
            return ("Paying to see more here loses money over time.",
                    "\(cards) is not good enough to pay to see more from \(position). \(reason) It is awkward to play once the cards come down, and it is often behind already.")
        case .failedToShove:
            return ("With a stack this short, get it all in.",
                    "You are down to \(bbText(view.effectiveStackInBB)) big blinds. \(reason) \(cards) is strong enough to commit, and pushing everything in often wins what is already there without a fight — which is most of the value in a stack this size.")
        case .shovedTooWide:
            return ("That is too weak to risk your whole stack on.",
                    "\(cards) is not strong enough to put your whole stack in from \(position) with \(bbText(view.effectiveStackInBB)) big blinds behind. \(reason)")
        case .missedImpliedOdds:
            return ("The money behind paid for that call.",
                    "\(cards) is behind right now, and that is not the point of it: when it hits, the pot it wins is many times the price of the call, and with \(bbText(view.effectiveStackInBB)) big blinds in front of everybody there is plenty to win. \(reason) Folding it gives up the cheapest way there is to win a big pot.")
        case .cappedLight:
            return ("Another bet with a hand that is not the best there is.",
                    "Somebody had already re-raised, and \(cards) is not one of the few hands that beats what people re-raise with. \(reason) At a limit table nobody folds to the extra bet, so it wins nothing now and puts more money in behind.")
        case .setMinedWithoutOdds:
            return ("Not enough behind to call for a set.",
                    "\(cards) is worth calling with only when the money left to win pays for all the times it misses. \(reason) Against a raise this size it does not, so the call loses money over time however good it feels when the set comes.")
        default:
            return ("Worth a second look.", reason)
        }
    }

    static func explainPostflop(view: DecisionView, read: HandAssessment, equity: Double,
                                taken: PlayerAction, best: PlayerAction, error: ErrorKind?,
                                cost: Double) -> (String, String) {
        guard let error else { return ("", "") }
        let hand = read.summary
        let board = view.board.map(\.pretty).joined(separator: " ")
        let chance = "\(Int((equity * 100).rounded()))%"
        let needed = view.toCall > 0 ? "\(Int((view.potOdds * 100).rounded()))%" : ""
        let street = view.street.name

        switch error {
        case .foldedTooMuch:
            return ("That one was worth calling.",
                    "You had \(hand) on \(board). Against what they would actually bet like that, you win about \(chance) of the time — and you only needed to be right \(needed) of the time for the call to pay for itself. \(drawNote(read))")
        case .calledTooWide:
            return ("That one was worth letting go.",
                    "You had \(hand) on \(board). That wins about \(chance) of the time against what they are likely to have, and you needed \(needed) to break even on the call. Paying to see more when you are behind is a small, steady cost that never stops — no single hand of it looks like a disaster, which is exactly why it goes unnoticed.")
        case .missedValue:
            let didWhat = taken == .call ? "Just calling" : "Checking"
            let whatItCosts = view.street == .river
                ? "no more cards are coming, so betting is the only way this hand earns another chip"
                : "it hands them a free card and collects nothing from the weaker hands that would have paid you"
            let headline = taken == .call ? "You had the best of it and only called." : "You had the best of it and didn't bet."
            return (headline,
                    "\(hand) on \(board) is in front about \(chance) of the time here. \(didWhat) costs you — \(whatItCosts).")
        case .bluffedTooMuch:
            return ("That was not the moment to bluff.",
                    "With \(hand) on \(board), the hands better than yours are not going anywhere, and the ones that pay you off already have you beaten. Winning \(chance) of the time is not enough to keep putting money in.")
        case .wrongSize:
            return ("Right idea, wrong amount.",
                    "Betting was right with \(hand) on \(board). How much was the problem. \(sizeNote(view: view, best: best))")
        case .stackedOffOnePair:
            let stack = Int(view.effectiveStackInBB.rounded())
            return ("One pair is not worth a whole stack.",
                    "You had \(hand) on \(board) and put most of \(stack) big blinds in with it. Against the hands that are willing to play for everything here, one pair wins about \(chance) of the time — the hands that call you are the ones that beat you. One pair wins small pots; when the pot stops being small, it is usually somebody else's.")
        default:
            return ("Worth a second look.",
                    "You had \(hand) on \(board), which wins about \(chance) of the time against what they are likely to have.")
        }
    }

    static func drawNote(_ read: HandAssessment) -> String {
        guard read.outs > 0, read.draw != .none else { return "" }
        return "You also had \(read.draw.name), and \(read.outs) of the cards left would have turned it into a hand."
    }

    static func sizeNote(view: DecisionView, best: PlayerAction) -> String {
        guard case let .bet(to) = best else {
            if case let .raise(to) = best {
                return "Around \(to) is the amount that still gets paid by weaker hands while making anyone chasing a card pay to keep going."
            }
            return ""
        }
        let fraction = Double(to) / Double(max(1, view.pot))
        let description = fraction < 0.45 ? "about a third of the pot"
            : (fraction < 0.8 ? "around two thirds of the pot" : "close to the size of the pot")
        return "Roughly \(to) — \(description) — is enough to get paid by weaker hands and to charge anyone chasing a card."
    }
}

/// One hand, reviewed. This is what the player sees when a hand ends.
public struct HandReview: Sendable {
    public let handNumber: Int
    public let holeCards: [Card]
    public let board: [Card]
    public let netChips: Int
    public let bigBlind: Int
    /// Every decision the player made, judged.
    public let verdicts: [Verdict]

    /// The one or two decisions worth talking about, biggest mistake first.
    public var worthMentioning: [Verdict] {
        verdicts.filter(\.shouldSpeak).sorted { $0.costInBB > $1.costInBB }.prefix(2).map { $0 }
    }

    public var playedWell: Bool { worthMentioning.isEmpty }
    public var totalCostInBB: Double { verdicts.filter(\.shouldSpeak).reduce(0) { $0 + $1.costInBB } }

    /// The decisions the coach kept quiet about and the scorecard counts anyway.
    ///
    /// The two bars differ on purpose, and nothing here narrows the gap: this hand still deals on
    /// without stopping the player, because a third of a big blind is not worth stopping anybody
    /// over. What changes is what the review says when the player opens it themselves and asks how
    /// they played. "Nothing wrong with how you played that one" is a claim about the scorecard,
    /// and it was being made about the very decisions a habit on it was counted from.
    public var countedQuietly: [Verdict] {
        verdicts.filter { $0.countsTowardsLeak && !$0.shouldSpeak }
    }

    /// A single line for the top of the review.
    public var summary: String {
        if playedWell {
            let counted = countedQuietly.count
            if counted > 0 {
                let decisions = counted == 1
                    ? "One decision here still counts"
                    : "\(counted) decisions here still count"
                return "Nothing worth stopping you over. \(decisions) towards a habit on your "
                    + "My game list."
            }
            return netChips >= 0 ? "Well played." : "Nothing wrong with how you played that one."
        }
        let worst = worthMentioning[0]
        let cost = worst.costInBB >= 1
            ? String(format: "about %.0f big blinds", worst.costInBB)
            : "a fraction of a big blind"
        return "\(worst.street.name.capitalized): \(worst.severity.name), costing \(cost)."
    }
}