Advertisement

← poker-tutor

Sources/PokerTests/CashReviewTests.swift

15.6 KB Download file
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit

// The cold review of the cash coaching (2026-09-12) found a set of faults that each let the app
// tell a cash player something untrue. Every test here failed against the code as it was, and
// says in its name what the fault was.

/// Nine seats at 25/50, everybody 100 big blinds unless told otherwise, `hero` holding `cards`,
/// with a first-seat raise to 150 and folds round to the hero. Returns the hero's view.
private func facingUnderTheGunRaise(hero: Int, cards: String, stacks: [Int]? = nil,
                                    raiserStack: Int = 5_000) -> DecisionView {
    let level = BlindLevel(smallBlind: 25, bigBlind: 50)
    var stackList = stacks ?? Array(repeating: 5_000, count: 9)
    // Button 0, small blind 1, big blind 2, first to speak 3.
    stackList[3] = raiserStack
    var holes: [[Card]] = [
        [Card].parse("2c 7d"), [Card].parse("3c 8d"), [Card].parse("4c 9d"),
        [Card].parse("Ac Kd"), [Card].parse("2h 7s"), [Card].parse("3h 8s"),
        [Card].parse("4h 9s"), [Card].parse("5h Ts"), [Card].parse("6h Js"),
    ]
    holes[hero] = [Card].parse(cards)
    let e = engine(stacks: stackList, button: 0, level: level, holes: holes)
    try! e.apply(.raise(to: 150))                       // seat 3, first to speak
    while let legal = e.legalActions(), legal.seat != hero { try! e.apply(.fold) }
    return e.view(for: hero)!
}

func cashReviewTests(_ t: TestRun) {

    t.suite("the cash review's fixes") { t in

        t.test("the money-behind rule knows the seat: a fold of six-five out of position is not a mistake") {
            withCashProduct {
                // Three seats after the big blind, four players still to speak behind: the chart
                // says fold, and the stacks do not overrule it from here. The coach used to charge
                // this 2.9 big blinds at 97% confidence.
                let view = facingUnderTheGunRaise(hero: 5, cards: "6s 5s")
                let verdict = DecisionEvaluator.evaluate(view, took: .fold, format: .cash)
                t.expect(verdict.error != .missedImpliedOdds,
                         "folding 65s from the middle against a first-seat raise is not a missed price")
                t.equal(verdict.error, nil, "a routine fold from that seat is not a mistake at all")
            }
        }

        t.test("and on the button, with only the blinds behind, the same fold is the missed price") {
            withCashProduct {
                let view = facingUnderTheGunRaise(hero: 0, cards: "6s 5s")
                t.equal(view.playersStillToAct, 2, "only the blinds are still to speak")
                let verdict = DecisionEvaluator.evaluate(view, took: .fold, format: .cash)
                t.equal(verdict.error, .missedImpliedOdds)
                t.expect(verdict.detail.contains("times the call"), verdict.detail)
            }
        }

        t.test("the money behind is the raiser's: a short raiser cannot pay a small pair off") {
            withCashProduct {
                // A 22-big-blind player raises; the hero on the button has 100 and so does
                // everybody else. Only the raiser can pay off a set, and 950 behind against a
                // 150 call is nowhere near twenty times. The old code took the deepest stack at
                // the table and called this fold a missed price.
                let view = facingUnderTheGunRaise(hero: 0, cards: "4d 4c", raiserStack: 1_100)
                t.equal(view.effectiveStack(against: 3), 1_100)
                t.equal(view.effectiveStack, 5_000, "the table-wide number is unchanged on purpose")
                let verdict = DecisionEvaluator.evaluate(view, took: .fold, format: .cash)
                t.expect(verdict.error != .missedImpliedOdds,
                         "against a short raiser the small pair has no price: \(verdict.detail)")
            }
        }

        t.test("folding a set to a flop shove is filed where a habit can see it, and costs") {
            withCashProduct {
                // Three-handed at 25/50, everybody a hundred blinds. The hero raises the button
                // with eights, the small blind calls, the flop comes eight high and the small
                // blind moves in. Folding the set is a flop fold — `fold-to-cbet`'s business —
                // and for a day it was filed under "playing for a whole stack", where no habit
                // for folding exists, and cost nothing at all.
                let level = BlindLevel(smallBlind: 25, bigBlind: 50)
                let holes = [[Card].parse("8s 8d"), [Card].parse("Ac Kc"), [Card].parse("7h 2s")]
                let e = engine(stacks: [5_000, 5_000, 5_000], button: 0, level: level,
                               holes: holes, board: [Card].parse("8c 4d 2h Jh 3s"))
                try! e.apply(.raise(to: 150))                 // button (hero)
                try! e.apply(.call)                           // small blind
                try! e.apply(.fold)                           // big blind
                try! e.apply(.bet(to: 4_850))                 // small blind moves in on the flop
                let view = e.view(for: 0)!
                t.expect(view.toCall > 4_000, "the shove should be most of a stack: \(view.toCall)")
                let verdict = DecisionEvaluator.evaluate(view, took: .fold, format: .cash)
                t.equal(verdict.error, .foldedTooMuch)
                t.equal(verdict.spot, .facingContinuationBet, "filed as the flop fold it is, not under the stack-off")
                t.greaterThan(verdict.costInBB, 5, "folding top set to a shove is expensive")
                var profile = LeakProfile()
                profile.record(verdict)
                t.greaterThan(profile.records["fold-to-cbet"]?.costInBB ?? 0, 5,
                              "the fold reaches the habit that counts flop folds")
                t.greaterThan(profile.totalCostInBB, 5, "and the rating")
            }
        }

        t.test("only the one stack-off habit lives under the stack-off situation, and it is not named") {
            withCashProduct {
                let under = LeakCatalogue.cash.filter { $0.spot == .deepStackOff }
                t.equal(under.map(\.id), ["stacked-off-one-pair"])
                t.expect(!under[0].canBeNamed,
                         "a bar no clean player was ever measured at its own floor is not a bar")
            }
        }

        t.test("the price of a small pair's call and the gate that allows it agree") {
            withCashProduct {
                // A pair on the button facing a raise that offers just under twenty times the
                // call: the gate says no, and the price says the call is worth about nothing —
                // they used to disagree by a third, and the write-up quoted both numbers.
                let view = facingUnderTheGunRaise(hero: 0, cards: "5d 5c", raiserStack: 2_900)
                let offered = (Double(view.effectiveStack(against: 3) - view.toCall) + Double(view.pot)) / Double(view.toCall)
                t.close(offered, 20, 1.0, "the spot is built to sit on the gate: \(offered)")
                let fold = DecisionEvaluator.evaluate(view, took: .fold, format: .cash)
                let call = DecisionEvaluator.evaluate(view, took: .call, format: .cash)
                // Whichever way the gate falls, the other side is a hair's breadth away.
                t.lessThan(fold.costInBB + call.costInBB, 0.5,
                           "at the gate neither the fold nor the call is worth charging: \(fold.costInBB) / \(call.costInBB)")
            }
        }

        t.test("the hands prepared to play for a stack include the ones drawing at it") {
            let board = [Card].parse("Kd 7d 2s")
            let range = HandRange.everything(excluding: [Card].parse("Ah As") + board)
            let made = range.strongest(0.30, board: board)
            let drawing = range.strongest(0.30, board: board, countingDraws: true)
            let nutFlushDraw = Combo(Card("Qd")!, Card("Jd")!)
            t.expect(!made.orderedCombos.contains(nutFlushDraw), "by made hand alone a flush draw is nothing")
            t.expect(drawing.orderedCombos.contains(nutFlushDraw),
                     "a flush draw is one of the hands that shoves this flop")
            t.expect(drawing.orderedCombos.contains(Combo(Card("Kc")!, Card("Kh")!)), "top set is still in")
            t.expect(!drawing.orderedCombos.contains(Combo(Card("9c")!, Card("4h")!)), "nine-four is still out")
        }

        t.test("the fixed-limit cap lifts when only two players can still act") {
            // Three-handed at 50/100 fixed-limit, the big blind short. The blind, a raise, a
            // re-raise, then the short stack calls all in: two players can still act and the
            // fourth bet does not cap them. It used to, because the all-in seat still counted.
            let e = engine(stacks: [10_000, 10_000, 300], button: 0,
                           level: BlindLevel(smallBlind: 50, bigBlind: 100), structure: .fixedLimit)
            try! e.apply(.raise(to: 200))                 // button
            try! e.apply(.raise(to: 300))                 // small blind
            let short = e.legalActions()!                 // big blind, 300 behind in all
            t.equal(short.seat, 2)
            try! e.apply(.call)                           // all in
            let fourth = e.legalActions()!                // button again
            t.equal(fourth.seat, 0)
            t.equal(fourth.maxRaiseTo, 400)
            try! e.apply(.raise(to: 400))                 // the fourth bet
            let after = e.legalActions()!                 // small blind
            t.equal(after.seat, 1)
            t.expect(!after.capped, "with the third player all in, two can act and the cap is off")
            t.expect(after.canRaise)
            t.equal(after.maxRaiseTo, 500)
        }

        t.test("every size the coach recommends is one the tray can count out") {
            // An odd pot on the flop, checked to the hero with the best hand: the recommended bet
            // is rounded to the chips in play, not a third of the pot to the chip.
            let level = BlindLevel(smallBlind: 25, bigBlind: 50)
            let holes = [[Card].parse("Ah As"), [Card].parse("7c 2d"), [Card].parse("9h 4s")]
            let e = engine(stacks: [5_000, 5_000, 5_000], button: 0, level: level,
                           holes: holes, board: [Card].parse("Kd 8c 3s Jh 2d"))
            try! e.apply(.raise(to: 175))                 // button (hero) opens for the pot
            try! e.apply(.call)                           // small blind
            try! e.apply(.call)                           // big blind: pot 525
            try! e.apply(.check); try! e.apply(.check)    // flop checked to the hero
            let view = e.view(for: 0)!
            t.equal(view.pot, 525)
            let smallest = ChipRounding.smallestChip(bigBlind: 50).value
            for format in [CoachFormat.tournament, .cash] {
                let verdict = DecisionEvaluator.evaluate(view, took: .check, format: format)
                if case .bet(let to) = verdict.best {
                    t.equal(to % smallest, 0, "\(to) is not an amount the \(smallest)-chip tray can make")
                } else {
                    t.expect(false, "aces on a dry board should be told to bet, not \(verdict.best)")
                }
            }
        }

        t.test("the coach's game follows the structure the moment it is chosen, not the first hand") {
            withCashProduct {
                let session = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutorCash)
                t.equal(session.coach.format, .cash)
                session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .fixedLimit)
                t.equal(session.coach.format, .limit, "a fixed-limit player reads the limit course before sitting down")
                t.equal(LeakCatalogue.format, .limit)
                session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .noLimit)
                t.equal(session.coach.format, .cash, "and switching back switches back")
            }
        }

        t.test("the courses drop what is not true at their table") {
            let cash = Set(Basics.cashLessons.map(\.id))
            let limit = Set(Basics.limitLessons.map(\.id))
            t.expect(!cash.contains("the-clock"), "the blinds never move at a cash table")
            t.expect(!cash.contains("stack-depth"))
            t.expect(!limit.contains("the-clock"))
            for sizing in ["bet-size", "same-size", "open-size"] {
                t.expect(!limit.contains(sizing), "\(sizing): a limit bet has one size")
            }
            for text in Basics.cashLessons.flatMap(\.allText) + Basics.limitLessons.flatMap(\.allText) {
                t.expect(!text.lowercased().contains("blinds go up"), text)
                t.expect(!text.contains("outside the money"), text)
            }
            // The lesson about the money behind quotes the coach's own numbers.
            let behind = Basics.cashLessons.first { $0.id == "money-behind" }!
            let all = behind.allText.joined(separator: " ")
            t.expect(all.contains("twenty times") && all.contains("twenty-five"), all)
            t.expect(!all.contains("fifteen times"), "the lesson used to say fifteen where the coach says twenty")
        }

        t.test("the tutorial at a fixed-limit table has no page about counting chips") {
            let noLimit = Copy.tutorial(for: .cash, structure: .noLimit).map(\.title)
            let limit = Copy.tutorial(for: .cash, structure: .fixedLimit).map(\.title)
            t.expect(noLimit.contains("Bet by counting out chips"))
            t.expect(!limit.contains("Bet by counting out chips"), "the tray is never shown at fixed-limit")
            t.equal(limit.count, noLimit.count - 1)
            t.equal(Copy.tutorial(for: .tournament).count, Copy.tournamentTutorial.count)
        }

        t.test("the assessment's fairness note says where it actually sat the player") {
            let report = AssessmentReport(of: [], target: 40)
            t.expect(report.fairnessNote.contains("short stack"), "a tournament sits the player short")
            withCashProduct {
                t.expect(!report.fairnessNote.contains("short stack"),
                         "a cash assessment never sits anybody short: \(report.fairnessNote)")
                t.expect(report.fairnessNote.contains("very deep") || report.fairnessNote.contains("big blind"))
            }
        }

        t.test("the ladder's promise names the window the number is read over") {
            withCashProduct {
                let standing = LadderStanding(of: [])
                t.expect(standing.requirementLine.contains("at least \(LadderStanding.sampleSize)"), standing.requirementLine)
                t.expect(standing.requirementLine.contains("\(LadderStanding.window)"), standing.requirementLine)
            }
            for line in [Copy.cashRungLine(rung: "50/100", chosen: "50/100"),
                         Copy.cashRungLine(rung: "50/100", chosen: "100/200")] {
                t.expect(!line.contains("becomes yours"), "no stake is gated: \(line)")
                t.expect(!line.contains("game above your rung counts"), "each stake keeps its own record: \(line)")
            }
        }
    }
}