← poker-tutor
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)")
}
}
}
}