← poker-tutor
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit
/// A player with exactly one deliberate flaw, used to test whether the coach can find it.
public struct PlantedPlayer: @unchecked Sendable {
public let name: String
public let leakID: String
public let decide: (DecisionView, inout SeededRandom) -> PlayerAction
public init(name: String, leakID: String, decide: @escaping (DecisionView, inout SeededRandom) -> PlayerAction) {
self.name = name
self.leakID = leakID
self.decide = decide
}
/// Folds the big blind to any raise, no matter the price or the hand.
public static let foldsBigBlind = PlantedPlayer(name: "folds the big blind", leakID: "bb-overfold") { view, rng in
if view.street == .preflop, view.position == .bigBlind, view.toCall > 0 { return .fold }
return solid(view, &rng)
}
/// Never raises first in; calls the blind with everything worth playing.
public static let limpsEverything = PlantedPlayer(name: "limps instead of raising", leakID: "limping") { view, rng in
if view.street == .preflop, view.isUnopenedPot, view.legal.canCall,
PreflopTable.percentile(view.holeCards) <= 0.35 {
return .call
}
return solid(view, &rng)
}
/// Checks the river with everything, however strong.
public static let neverValueBetsRiver = PlantedPlayer(name: "never bets the river", leakID: "missed-river-value") { view, rng in
if view.street == .river, view.legal.canCheck { return .check }
return solid(view, &rng)
}
/// Checks the turn with everything, however strong.
///
/// The mirror of the river player above, and it exists for the same reason: a bar on a habit
/// can only be set by measuring both the player who has it and the player who does not. Until
/// this existed the turn habit's bar had been measured against one population, so there was no
/// way to tell a bar that catches the flaw from one that convicts everybody.
public static let neverValueBetsTurn = PlantedPlayer(name: "never bets the turn", leakID: "missed-turn-value") { view, rng in
if view.street == .turn, view.legal.canCheck { return .check }
return solid(view, &rng)
}
/// Calls every bet on the river, whatever it is holding.
///
/// The counterpart the "paying off on the river" bar needed, and it exists for the same reason
/// the turn player above does: a bar can only be set by measuring the player who has the habit
/// against the player who does not, and until this existed there was only one population to
/// measure. It calls rather than folds even when it can beat nothing, which is exactly what the
/// habit is.
public static let callsEveryRiverBet = PlantedPlayer(name: "calls every river bet", leakID: "river-hero-call") { view, rng in
if view.street == .river, view.toCall > 0, view.legal.canCall { return .call }
return solid(view, &rng)
}
/// Folds to any bet on the flop unless it holds two pair or better.
public static let foldsToEveryFlopBet = PlantedPlayer(name: "folds to every flop bet", leakID: "fold-to-cbet") { view, rng in
if view.street == .flop, view.toCall > 0 {
let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
if read.category < .twoPair { return .fold }
}
return solid(view, &rng)
}
/// Bets the smallest amount the rules allow, every time it bets the flop.
///
/// The counterpart the "betting the wrong amount" bar needed. A min-bet is the wrong size with
/// almost every hand — it charges nothing to draw against a strong one and folds nothing out
/// with a weak one — so a player who only ever makes it is the population the bar has to catch.
public static let alwaysMinBetsFlop = PlantedPlayer(name: "min-bets every flop", leakID: "bet-sizing") { view, rng in
let action = solid(view, &rng)
guard view.street == .flop else { return action }
switch action {
case .bet: return .bet(to: view.legal.minRaiseTo)
case .raise: return .raise(to: view.legal.minRaiseTo)
default: return action
}
}
/// Shoves the whole stack in every time it bets the flop.
///
/// The other end of the sizing habit from the min-bettor, and the reason both had to be built:
/// the bar has to catch the wrong size in both directions or it is not a bar about sizing.
public static let alwaysOverbetsFlop = PlantedPlayer(name: "shoves every flop", leakID: "bet-sizing") { view, rng in
let action = solid(view, &rng)
guard view.street == .flop else { return action }
switch action {
case .bet: return .bet(to: view.legal.maxRaiseTo)
case .raise: return .raise(to: view.legal.maxRaiseTo)
default: return action
}
}
/// Plays far too many hands before the flop.
public static let playsEverything = PlantedPlayer(name: "plays too many hands", leakID: "too-many-hands") { view, rng in
if view.street == .preflop, view.isUnopenedPot, view.legal.canBetOrRaise,
PreflopTable.percentile(view.holeCards) <= 0.85 {
let to = min(view.legal.maxRaiseTo, max(view.legal.minRaiseTo, view.bigBlind * 2))
return view.legal.canBet ? .bet(to: to) : .raise(to: to)
}
return solid(view, &rng)
}
// MARK: Cash-table flaws
/// Puts a whole stack in with one pair: calls any bet on the flop or turn holding one pair,
/// and moves the stack in with top pair or better rather than betting a size.
public static let stacksOffOnePair = PlantedPlayer(name: "stacks off with one pair", leakID: "stacked-off-one-pair") { view, rng in
if view.street == .flop || view.street == .turn {
let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
if read.isOnePair {
if view.toCall > 0, view.legal.canCall { return .call }
if read.category >= .topPair, view.legal.canBetOrRaise {
return view.legal.canBet ? .bet(to: view.legal.maxRaiseTo) : .raise(to: view.legal.maxRaiseTo)
}
}
}
return solid(view, &rng)
}
/// Calls any raise or re-raise holding a small pair, whatever is left behind to win.
public static let setMinesEverything = PlantedPlayer(name: "calls every raise with a small pair", leakID: "set-mine-no-odds") { view, rng in
if view.street == .preflop, view.toCall > 0, view.legal.canCall, view.preflopRaiseCount >= 1 {
let hand = HandClass(view.holeCards)
if hand.isPair, hand.high <= .nine { return .call }
}
return solid(view, &rng)
}
/// Folds every small pair and every pair of cards that fit together to a single raise, however
/// much money there is behind to win.
public static let foldsSpeculativeHands = PlantedPlayer(name: "folds the hands the stacks pay for", leakID: "missed-implied-odds") { view, rng in
if view.street == .preflop, view.toCall > 0, view.preflopRaiseCount == 1, view.position != .bigBlind {
let hand = HandClass(view.holeCards)
let smallPair = hand.isPair && hand.high <= .nine
let fitting = hand.suited && hand.gap <= 1 && hand.high <= .jack && hand.low >= .five
if smallPair || fitting { return .fold }
}
return solid(view, &rng)
}
/// Sound baseline play — the control. This one has no planted flaw.
public static func solid(_ view: DecisionView, _ rng: inout SeededRandom) -> PlayerAction {
var bot = Bot(id: "control", personality: .solidRegular, skill: 88, seed: rng.next())
// At a cash table the control plays the money behind, as any sound cash player does.
bot.playsImpliedOdds = LeakCatalogue.format == .cash
return bot.decide(view)
}
public static let control = PlantedPlayer(name: "no planted flaw", leakID: "") { view, rng in
solid(view, &rng)
}
/// Sound everywhere except one seat, where it opens far too many hands. The planted player for
/// the seat diagnosis: a flaw that lives in a seat rather than in a situation.
public static func badInSeat(_ seat: Position) -> PlantedPlayer {
PlantedPlayer(name: "plays badly from \(seat.fullName)", leakID: "too-many-hands") { view, rng in
if view.position == seat, view.street == .preflop, view.isUnopenedPot,
view.legal.canBetOrRaise, PreflopTable.percentile(view.holeCards) <= 0.85 {
let to = min(view.legal.maxRaiseTo, max(view.legal.minRaiseTo, view.bigBlind * 2))
return view.legal.canBet ? .bet(to: to) : .raise(to: to)
}
return solid(view, &rng)
}
}
// MARK: Fixed-limit habits — see `CoachFormat.limit`
/// Folds any flop or turn bet without two pair, draws included.
public static let foldsDrawsForOneBet = PlantedPlayer(name: "folds a draw to one bet", leakID: "limit-fold-to-price") { view, rng in
if view.street == .flop || view.street == .turn, view.toCall > 0 {
let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
if read.category < .twoPair { return .fold }
}
return solid(view, &rng)
}
/// Never raises after the flop: every raise the sound player would make becomes a call.
public static let neverRaisesPostflop = PlantedPlayer(name: "calls with the best hand", leakID: "limit-missed-value-raise") { view, rng in
let action = solid(view, &rng)
if view.street != .preflop, view.toCall > 0, case .raise = action, view.legal.canCall { return .call }
return action
}
/// Calls one raise cold with most of what it is dealt.
public static let coldCallsRaises = PlantedPlayer(name: "calls two bets cold", leakID: "limit-cold-call") { view, rng in
if view.street == .preflop, view.preflopRaiseCount == 1, view.position != .bigBlind, view.legal.canCall,
PreflopTable.percentile(view.holeCards) <= 0.55 {
return .call
}
return solid(view, &rng)
}
/// Folds the river to one bet without two pair.
public static let foldsRiverToOneBet = PlantedPlayer(name: "folds the river to one bet", leakID: "limit-river-overfold") { view, rng in
if view.street == .river, view.toCall > 0 {
let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
if read.category < .twoPair { return .fold }
}
return solid(view, &rng)
}
/// Calls the last bet with anything at all.
public static let callsRiverWithNothing = PlantedPlayer(name: "calls the last bet with nothing", leakID: "limit-call-down-nothing") { view, rng in
if view.street == .river, view.toCall > 0, view.legal.canCall { return .call }
return solid(view, &rng)
}
/// Never raises a draw on the flop, however well placed.
public static let neverRaisesDraws = PlantedPlayer(name: "never raises for the free card", leakID: "limit-no-free-card") { view, rng in
if view.street == .flop, view.toCall > 0, view.legal.canCall {
let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
if read.outs >= 8, read.category < .twoPair { return .call }
}
return solid(view, &rng)
}
/// Puts in another bet before the flop with anything it would have played.
public static let capsEverything = PlantedPlayer(name: "caps with anything", leakID: "limit-cap-light") { view, rng in
if view.street == .preflop, view.preflopRaiseCount >= 2, view.legal.canRaise,
PreflopTable.percentile(view.holeCards) <= 0.50 {
return .raise(to: view.legal.minRaiseTo)
}
return solid(view, &rng)
}
}
/// Runs a planted player through a table of bots and reports what the coach concluded.
/// - Parameter keepEveryReview: hold on to every hand rather than the last few hundred, so a sweep
/// can walk the decisions in the order they happened. What a habit measures at the end of a long
/// run is not the question a player cares about — they see the list at every point along the way,
/// and a habit named at hand 300 and gone by hand 4,000 was still named.
public func runPlanted(_ player: PlantedPlayer, hands: Int, seed: UInt64,
keepEveryReview: Bool = false) -> Coach {
let coach = Coach()
if keepEveryReview { coach.maxReviewsHeld = Int.max }
let level = BlindLevel(smallBlind: 50, bigBlind: 100)
var villains: [Bot] = []
for i in 1..<6 {
villains.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 60, seed: seed &+ UInt64(i) &* 131))
}
let table = BotTable(bots: villains, seed: seed, humanSeat: 0)
var rng = SeededRandom(seed: seed &* 13 &+ 1)
for hand in 0..<hands {
var seats = [SeatState(seat: 0, playerID: "hero", stack: 10_000)]
seats += villains.enumerated().map { SeatState(seat: $0.offset + 1, playerID: $0.element.id, stack: 10_000) }
let engine = HandEngine(seats: seats, config: HandConfig(level: level, buttonSeat: hand % 6, seed: rng.next()))
var steps = 0
while !engine.isComplete {
table.play(engine, observe: false)
guard !engine.isComplete, engine.actor == 0, let view = engine.view(for: 0) else { break }
let action = player.decide(view, &rng)
coach.judge(view, action: action)
try! engine.apply(action)
steps += 1
precondition(steps < 60, "hand did not terminate")
}
coach.finishHand(engine: engine, heroSeat: 0, handNumber: hand)
}
return coach
}
/// Runs a planted player through the app's own cash table — nine handed, the rung's roster,
/// stacks that carry over, rebuys — and reports what the coach concluded.
///
/// The six-handed harness above is the bar-setting rig for the tournament coach and stays so.
/// The cash coach's bars come from here instead, because the questions it asks are about the
/// money behind, and a rig that resets everybody to a hundred blinds every hand never has any
/// more or less of it. `rung` picks the room; the sweep runs the bottom one, where every player
/// starts, and the top one, where the opponents stop giving money away.
public func runPlantedCash(_ player: PlantedPlayer, hands: Int, seed: UInt64,
setup: CashSetup = .standard, rung: Int = 0,
keepEveryReview: Bool = false) -> Coach {
let coach = Coach()
if keepEveryReview { coach.maxReviewsHeld = Int.max }
let session = TableSession(coach: coach, seed: seed, history: .inMemory(), variant: .pokerTutorCash)
session.cashSetup = setup
session.forcedTier = LadderTier.cashRungs[min(rung, LadderTier.cashRungs.count - 1)]
var rng = SeededRandom(seed: seed &* 13 &+ 7)
var steps = 0
session.start(.cash)
while session.history.hands.count < hands {
steps += 1
precondition(steps < 900_000, "the cash session did not progress")
if !session.isPlaying { session.start(.cash); continue }
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
session.act(player.decide(view, &rng))
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
session.stepOpponent()
}
}
session.leave()
return coach
}
/// Runs a planted player through the app's own tournament table — nine handed, the rung's
/// roster, a tournament restarted whenever it ends — and hands back the session, whose history
/// holds every hand as it was written down. That is what the seat diagnosis is built from, so it
/// is what its bars are swept on.
public func runPlantedTournament(_ player: PlantedPlayer, hands: Int, seed: UInt64,
rung: Int = 0) -> TableSession {
let coach = Coach()
let session = TableSession(coach: coach, seed: seed, history: .inMemory(), variant: .pokerTutor)
session.forcedTier = LadderTier.tournamentRungs[min(rung, LadderTier.tournamentRungs.count - 1)]
var rng = SeededRandom(seed: seed &* 13 &+ 7)
var steps = 0
session.start(.tournament)
while session.history.hands.count < hands {
steps += 1
precondition(steps < 2_000_000, "the tournament session did not progress")
if !session.isPlaying { session.start(.tournament); continue }
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
session.act(player.decide(view, &rng))
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
session.stepOpponent()
}
}
session.leave()
return session
}
/// Runs `body` with the process set to the cash product, and puts the tournament back after.
public func withCashProduct<T>(_ body: () throws -> T) rethrows -> T {
let before = AppVariant.current
AppVariant.use(.pokerTutorCash)
defer { AppVariant.use(before) }
return try body()
}
func coachTests(_ t: TestRun) {
cashCoachTests(t)
t.suite("the coach finds planted leaks") { t in
// This is the test that decides whether the product works. Each player below has exactly
// one deliberate flaw. The coach must name that flaw, and must not invent others — a coach
// that cries wolf is worse than no coach, because the player stops believing the true ones.
//
// The two halves used to be tested together, one habit at a time. They are split now, and
// the split is what caught the last three faults. Each planted player is run at the sample
// its habit's floor actually needs, which is different for every one of them — 420 hands
// for the loud ones, twelve thousand for the quiet ones — while the "and nobody else"
// half is one sweep of the whole catalogue against a player with no flaw at all, at the
// bottom of this suite. Testing the two together meant the control half was only ever
// checked on the one habit the test was about.
for player in [PlantedPlayer.limpsEverything, .playsEverything] {
t.test("it catches a player who \(player.name)") {
// Loud habits: every unopened pot is a chance, so 420 hands is plenty.
let coach = runPlanted(player, hands: 420, seed: 4242)
let found = coach.profile.confirmed().map(\.leak.id)
t.expect(found.contains(player.leakID),
"expected to find '\(player.leakID)' in a player who \(player.name); found \(found)")
}
}
t.test("it catches a player who folds the big blind, at the sample that takes") {
// This one used to sit in the loop above at 420 hands, and it passed there against a
// bar of 18% over 25 chances — a bar the control player crosses on his way past, ten
// sittings in sixteen. The bar is 30% over 50 chances now (the sweep is in
// `LeakCatalogue`), which is about 430 hands of big blind defence, so the test asks at
// a thousand. A player who folds every big blind sits at roughly 53%.
for seed in [UInt64(4_242), 512] {
let coach = runPlanted(.foldsBigBlind, hands: 1_000, seed: seed)
t.expect(coach.profile.confirmed().map(\.leak.id).contains("bb-overfold"),
"seed \(seed): missed a player who folds every big blind; found "
+ "\(coach.profile.confirmed().map(\.leak.id))")
}
}
t.test("it catches a player who folds to every flop bet, at the sample that takes") {
// Same story, same fix: 20% over 14 chances named three flawless sittings in sixteen,
// and the bar is 30% over 25 now. A flop bet comes at the player about once in
// twenty-five hands, so 25 chances is roughly 660 of them and the test asks at 1,500.
// The flawed player sits at 48-58%, so there is nothing marginal about the catch.
for seed in [UInt64(4_242), 512] {
let coach = runPlanted(.foldsToEveryFlopBet, hands: 1_500, seed: seed)
t.expect(coach.profile.confirmed().map(\.leak.id).contains("fold-to-cbet"),
"seed \(seed): missed a player who folds to every flop bet; found "
+ "\(coach.profile.confirmed().map(\.leak.id))")
}
}
t.test("it catches a player who never bets the river, at the sample that takes") {
// The bar here — 30% — survived the second sweep. The floor did not: reading the
// running rate rather than the finishing one, a flawless player reaches 41% with 25
// chances behind him and only settles under 25% past 200. So the floor is 200 river
// decisions, which is around six thousand hands, and the test asks at eight.
for seed in [UInt64(4_242), 512] {
let flawed = runPlanted(.neverValueBetsRiver, hands: 8_000, seed: seed)
t.expect(flawed.profile.confirmed().map(\.leak.id).contains("missed-river-value"),
"seed \(seed): missed a player who never bets the river; found "
+ "\(flawed.profile.confirmed().map(\.leak.id))")
}
}
t.test("it catches a player who pays off every river bet, at the sample that takes") {
// The third habit found sitting inside the band of the player it existed to clear, and
// the one that needed a planted counterpart building before anything could be said: a
// bar can only be set by measuring the player who has the habit against the player who
// does not. Control 15-27% at 8,000 hands, a player who calls every river bet 34-56%,
// so the bar goes at 30% — and the floor at 150, because the running rate of a
// flawless player is still touching 29% with a hundred chances behind it.
for seed in [UInt64(4_242), 512] {
let flawed = runPlanted(.callsEveryRiverBet, hands: 12_000, seed: seed)
t.expect(flawed.profile.confirmed().map(\.leak.id).contains("river-hero-call"),
"seed \(seed): missed a player who calls every river bet; found "
+ "\(flawed.profile.confirmed().map(\.leak.id))")
}
}
t.test("it catches a player who never bets the turn, at the sample that takes") {
// The most expensive of the four to measure. Below about 250 turn decisions the two
// populations overlap so heavily that no bar exists, so until then the habit sits
// under "too soon to call" with its count on show and no accusation attached. At 4,000
// hands the player who checks every turn he is allowed to check sits at 25-33% across
// twenty seeds and the sound one at 13-21%, so 24% separates them.
for seed in [UInt64(4_242), 512] {
let flawed = runPlanted(.neverValueBetsTurn, hands: 4_000, seed: seed)
t.expect(flawed.profile.confirmed().map(\.leak.id).contains("missed-turn-value"),
"seed \(seed): missed a player who never bets the turn; found "
+ "\(flawed.profile.confirmed().map(\.leak.id))")
}
}
t.test("a player with no flaw is named for nothing, anywhere in the catalogue") {
// This replaces "it does not invent leaks in a player who has none", which allowed one
// named habit at 420 hands and called that a measured position. It was not enough on
// three counts, and each one hid a real fault.
//
// It read one seed. It read the profile only at the end of the run, so a habit named at
// hand three hundred and gone again by hand four thousand never showed up — and a
// player reads the list at every point in an evening, not only at the end of it. And
// 420 hands is under the floor of half the catalogue, so the habits it could not reach
// were passing by never being asked. That last one is exactly how the river-call bar
// survived three reviews: the gate ran 320 hands, the floor was 14, and the chances
// came in at 10 to 13.
//
// So this runs the control player long enough for every floor in the catalogue to be
// cleared, replays his decisions in the order they happened, and applies the coach's
// own three tests after every one. Anything named at any point is a bar sitting inside
// the band of the player it exists to clear. Three seeds, because one seed is a story.
//
// What it does not cover, said out loud rather than left to be discovered: the two
// short-stack habits never come up at all here, because this table is a hundred big
// blinds deep from the first hand to the last. Their bars have not been swept.
var everNamed: [String] = []
var floorsCleared = 0
var floorsMissed: [String] = []
for seed in [UInt64(4_242), 512, 77] {
let coach = runPlanted(.control, hands: 14_000, seed: seed, keepEveryReview: true)
let sweep = NamingSweep(coach)
for moment in sweep.named {
everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) "
+ "(seed \(seed))")
}
for leak in LeakCatalogue.nameable {
let chances = sweep.chances[leak.id] ?? 0
guard chances > 0 else { continue }
if chances >= leak.minimumSample { floorsCleared += 1 }
else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
}
}
t.expect(everNamed.isEmpty,
"a player with no planted flaw was told he has a habit: "
+ "\(everNamed.joined(separator: "; "))")
// And the silence has to be a measurement rather than an abstention. If a habit never
// gathered enough chances to be judged, this test says nothing about its bar, and the
// whole point is that it should not be able to pass by looking away.
t.expect(floorsMissed.isEmpty,
"these habits were never judged at all, so their bars are untested here: "
+ "\(Set(floorsMissed).sorted().joined(separator: ", "))")
t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
}
t.test("a planted leak is the worst thing it finds") {
// Not just present in the list — at or near the top of it, ranked by what it costs.
// Each one is asked at the sample its own habit's floor needs: the big blind bar takes
// fifty chances at the habit now, which 420 hands does not produce.
for (player, hands) in [(PlantedPlayer.foldsBigBlind, 1_000),
(.limpsEverything, 420), (.playsEverything, 420)] {
let coach = runPlanted(player, hands: hands, seed: 99)
let ranked = coach.profile.confirmed().map(\.leak.id)
guard let position = ranked.firstIndex(of: player.leakID) else {
t.expect(false, "'\(player.leakID)' missing entirely for a player who \(player.name)")
continue
}
t.lessThan(position, 2, "'\(player.leakID)' should be near the top for a player who \(player.name), was #\(position + 1) of \(ranked.count)")
}
}
t.test("a flawed player rates below a sound one") {
// Measured, not assumed: at 420 hands this comparison is a coin flip — a sweep of ten
// seeds got it right five times on one build and six on the next, which is noise
// wearing a number. The rating separates the two cleanly by four thousand hands. That
// is a fact about poker, not a fault in the rating: it is built from what mistakes
// cost, and a few hundred hands do not contain enough mistakes to be sure.
//
// Several seeds, because one seed is a story rather than a measurement.
var correct = 0
var flawedTotal = 0
var soundTotal = 0
let seeds: [UInt64] = [7, 99, 137]
for seed in seeds {
let flawed = runPlanted(.foldsBigBlind, hands: 4_000, seed: seed).rating.value
let sound = runPlanted(.control, hands: 4_000, seed: seed).rating.value
if flawed < sound { correct += 1 }
flawedTotal += flawed
soundTotal += sound
}
t.equal(correct, seeds.count,
"the rating should separate a player with a known flaw from one without")
t.lessThan(flawedTotal / seeds.count + 10, soundTotal / seeds.count,
"the gap should be a gap, not a rounding error")
}
}
t.suite("the coach knows when to keep quiet") { t in
t.test("it says nothing about decisions it is unsure of") {
let coach = runPlanted(.control, hands: 200, seed: 3)
let spoken = coach.reviews.flatMap(\.worthMentioning)
let judged = coach.profile.decisionsJudged
t.greaterThan(judged, 200, "it should be judging plenty of decisions")
let rate = Double(spoken.count) / Double(max(1, judged))
t.lessThan(rate, 0.35, "flagging more than a third of a sound player's decisions is crying wolf")
}
t.test("every verdict it speaks carries a reason and a cost") {
let coach = runPlanted(.foldsBigBlind, hands: 200, seed: 11)
let spoken = coach.reviews.flatMap(\.worthMentioning)
t.greaterThan(spoken.count, 5, "the planted player should generate flagged decisions")
for verdict in spoken.prefix(40) {
t.expect(!verdict.headline.isEmpty, "a spoken verdict must have a headline")
t.expect(!verdict.detail.isEmpty, "a spoken verdict must explain itself")
t.greaterThan(verdict.costInBB, 0, "a spoken verdict must cost something")
t.greaterThan(verdict.confidence, DecisionEvaluator.confidenceThreshold - 0.0001,
"a spoken verdict must clear the confidence bar")
}
}
t.test("a hand review names at most two decisions") {
let coach = runPlanted(.playsEverything, hands: 150, seed: 5)
t.greaterThan(coach.reviews.count, 0, "there were no reviews to look at")
for review in coach.reviews {
t.expect(review.worthMentioning.count <= 2, "a review should not be a lecture")
}
}
t.test("a long sitting reports every mistake it saw, not every one it still holds") {
// `reviews` is a window on the last few hundred hands and nothing more — memory has to
// stop somewhere, and the store keeps the full history anyway. The session report
// counted its mistakes off that window and its hands off the profile, which is not
// capped, so past the cap it quietly reported fewer mistakes than it had seen: the cost
// per hundred beside them went on climbing while the count meant to explain it fell.
let coach = runPlanted(.foldsBigBlind, hands: 700, seed: 11)
let report = coach.sessionReport()
t.equal(coach.reviews.count, coach.maxReviewsHeld, "the cap did not come into it")
t.equal(report.hands, 700)
let held = coach.reviews.flatMap(\.worthMentioning).count
t.greaterThan(held, 0, "the planted player made no mistakes at all, so this proves nothing")
t.greaterThan(report.mistakes, held,
"the report is counting the \(held) mistakes it still holds rather than "
+ "the \(report.mistakes) it saw")
t.expect(report.biggest != nil, "the dearest decision went out with the window")
t.expect(report.headline.contains("\(report.mistakes) decision"),
"the headline prints a different count again: \"\(report.headline)\"")
}
t.test("staying quiet is not the same as saying nothing was there") {
// The coach's restraint stands: it stops the game only for a mistake worth stopping it
// over. What it may not do is turn that silence into a claim. A player whose named
// habit is one of the cheap ones — limping, playing too many hands — used to open the
// review on one of the hands the habit was counted from and read "Nothing wrong with
// how you played that one".
let coach = runPlanted(.limpsEverything, hands: 300, seed: 7)
let quietButCounted = coach.reviews.filter {
$0.playedWell && !$0.countedQuietly.isEmpty
}
t.greaterThan(quietButCounted.count, 0,
"a player who limps every hand produced no quiet counted decisions, so "
+ "this test proves nothing")
for review in quietButCounted.prefix(20) {
t.expect(!review.summary.contains("Well played"),
"the review praised a hand a habit was counted from: \(review.summary)")
t.expect(!review.summary.contains("Nothing wrong"), review.summary)
t.expect(review.summary.contains("counts") || review.summary.contains("count"),
review.summary)
// And nothing here makes it interrupt: the hand is still one that deals on.
t.expect(review.worthMentioning.isEmpty)
}
// A hand with nothing in it at all still gets the short answer.
let silent = coach.reviews.filter { $0.playedWell && $0.countedQuietly.isEmpty }
t.greaterThan(silent.count, 0, "there were no genuinely quiet hands to check")
for review in silent.prefix(20) {
t.expect(review.summary.contains("Well played")
|| review.summary.contains("Nothing wrong"), review.summary)
}
}
t.test("the counting bar reads the same live as it does off disk") {
// The rule is written down twice — once on `Verdict`, which the table reads, and once on
// `VerdictRecord`, which reads it back off a file where an older hand may carry no
// confidence at all. Two copies drift, so this holds them to the same answer on every
// verdict a real sitting produced.
let coach = runPlanted(.playsEverything, hands: 200, seed: 5)
var checked = 0
for review in coach.reviews {
for verdict in review.verdicts {
let record = VerdictRecord(verdict)
t.equal(record.countsTowardsLeak, verdict.countsTowardsLeak,
"\(verdict.severity.name), confidence \(verdict.confidence)")
checked += 1
}
}
t.greaterThan(checked, 100, "no verdicts were compared, so this asserted nothing")
}
t.test("nothing is said without a big enough sample") {
let coach = runPlanted(.foldsBigBlind, hands: 8, seed: 5)
t.equal(coach.profile.confirmed().count, 0,
"eight hands is not enough to tell anyone what is wrong with their game")
}
}
t.suite("reference ranges") { t in
t.test("late position plays more hands than early") {
let open = { (p: Position) in ReferenceRanges.open(position: p, players: 9) }
t.lessThan(open(.underTheGun), open(.middle))
t.lessThan(open(.middle), open(.cutoff))
t.lessThan(open(.cutoff), open(.button))
t.greaterThan(open(.button), 0.35, "the button opens a lot of hands")
t.lessThan(open(.underTheGun), 0.16, "under the gun opens few")
}
t.test("the big blind defends far wider than anyone calls elsewhere") {
let defend = ReferenceRanges.defendBigBlind(versus: .button, players: 9)
let call = ReferenceRanges.callVersusOpen(from: .middle, versus: .button, players: 9)
t.greaterThan(defend, call * 1.5, "the big blind is already invested and getting a price")
t.greaterThan(defend, 0.4)
}
t.test("shorter stacks move in wider") {
let shove = { (d: Double) in ReferenceRanges.shove(depth: d, position: .button, players: 6, facingRaise: false) }
t.greaterThan(shove(3), shove(6))
t.greaterThan(shove(6), shove(10))
t.lessThan(shove(12), shove(4))
}
t.test("facing a raise narrows everything") {
let open = ReferenceRanges.shove(depth: 8, position: .button, players: 6, facingRaise: false)
let facing = ReferenceRanges.shove(depth: 8, position: .button, players: 6, facingRaise: true)
t.lessThan(facing, open)
}
t.test("ranges widen as the table shortens") {
t.lessThan(ReferenceRanges.open(position: .cutoff, players: 9),
ReferenceRanges.open(position: .cutoff, players: 3))
}
}
t.suite("reading an opponent's range") { t in
t.test("a raise narrows a range, a check barely does") {
let board = [Card].parse("Kh 8s 3c")
let full = HandRange.everything(excluding: board)
let strong = full.strongest(0.3, board: board)
t.lessThan(strong.count, full.count)
t.greaterThan(strong.count, 0)
// The kept hands really are the strong ones.
let kept = strong.rankedByStrength(board: board)
let best = HandEvaluator.evaluate(kept.first!.0.cards + board)
let worstKept = HandEvaluator.evaluate(kept.last!.0.cards + board)
t.greaterThan(best.value, worstKept.value)
t.greaterThan(worstKept.category.rawValue, HandRank.Category.highCard.rawValue,
"the top 30% on this board should all have at least a pair")
}
t.test("equity against a strong range is lower than against everything") {
var rng = SeededRandom(seed: 5)
let hole = [Card].parse("Ah Kd")
let board = [Card].parse("Kh 8s 3c")
let everything = HandRange.everything(excluding: hole + board)
let strong = everything.strongest(0.15, board: board)
let vsAll = RangeEquity.equity(hole: hole, board: board, versus: everything, rng: &rng)
let vsStrong = RangeEquity.equity(hole: hole, board: board, versus: strong, rng: &rng)
t.greaterThan(vsAll, 0.7, "top pair beats a random hand most of the time")
t.lessThan(vsStrong, vsAll, "and much less often against hands that would keep betting")
}
t.test("a range never contains cards we can see") {
let hole = [Card].parse("Ah Kd")
let board = [Card].parse("Kh 8s 3c")
let range = HandRange.everything(excluding: hole + board)
for combo in range.weights.keys {
for card in hole + board {
t.expect(!combo.blocks(card), "\(combo.a)\(combo.b) uses a card that is already visible")
}
}
}
}
t.suite("the rating") { t in
t.test("losing less money rates higher") {
let clean = SkillRating(costPer100: 0, hands: 500)
let leaky = SkillRating(costPer100: 8, hands: 500)
let awful = SkillRating(costPer100: 30, hands: 500)
t.greaterThan(clean.value, leaky.value)
t.greaterThan(leaky.value, awful.value)
// The line is gentler than it was (see Coach.swift): thirty big blinds a hundred with
// nothing confirmed is "Improving", and the floor is reached by cost and habits together.
t.greaterThan(clean.value, 80)
t.lessThan(awful.value, 35)
}
// The scale is anchored on what `pokerlab calibrate` measured over tournament play, and
// these are the two ends of it. They are here so that changing the arithmetic without
// re-measuring fails, which is how the first version came to have a top nobody could
// reach: it was anchored on the coach harness, and the app ships tournaments.
t.test("the scale lands where the measurements put it") {
// A player with no deliberate flaw measured 9.8 bb/100 over eight seeds (2026-09-11),
// with nothing confirmed against it.
let reference = SkillRating(costPer100: 9.8, hands: 3000)
t.equal(reference.label, "Strong amateur",
"the flawless reference player rates \(reference.value) — \(reference.label)")
t.expect((62...70).contains(reference.value),
"the reference player landed at \(reference.value), not near 66")
// The worst of the scripted players — plays far too many hands — measured 32.4, with
// its habit confirmed and the penalty at its ceiling.
let worst = SkillRating(costPer100: 32.4, hands: 3000,
leakPenalty: LeakProfile.maximumLeakPenalty)
t.equal(worst.label, "Beginner")
t.expect((5...20).contains(worst.value), "the worst player landed at \(worst.value)")
// And the top is reachable, but only by somebody better than any player here.
t.equal(SkillRating(costPer100: 2, hands: 3000).label, "Low pro")
t.greaterThan(SkillRating(costPer100: 9.8, hands: 3000).value,
SkillRating(costPer100: 10.8, hands: 3000).value,
"one cheap habit should cost something, but not everything")
}
t.test("confirmed habits cannot sink anyone through the floor") {
// Each confirmed habit used to take up to twenty points on top of the money it had
// already cost, and the scale had been anchored as if nobody carried any — so three
// confirmed habits put a real player at zero. The penalty has a ceiling now and the
// anchors were measured with it in.
t.equal(LeakProfile.maximumLeakPenalty, 15)
let floor = SkillRating(costPer100: 32.4, hands: 3000,
leakPenalty: LeakProfile.maximumLeakPenalty)
t.greaterThan(floor.value, 4, "the worst scripted player, every habit confirmed, rates \(floor.value)")
t.greaterThan(SkillRating(costPer100: 9.8, hands: 3000).value, 59)
// And at the app's own tables, over two seeds of tournament play, the control never
// drops out of "solid amateur". Single runs of a thousand hands swing several big
// blinds either way (the eight-seed bands are in Coach.swift), so the bar is the
// bottom of the band, not the mean.
for seed: UInt64 in [3, 5] {
let history = HistoryStore.inMemory()
let session = TableSession(seed: seed, history: history)
var rng = SeededRandom(seed: seed &* 13 &+ 7)
let player = PlantedPlayer.control
var steps = 0
session.start(.tournament)
while history.hands.count < 1000, steps < 300_000 {
steps += 1
if !session.isPlaying { session.start(.tournament); continue }
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
session.act(player.decide(view, &rng))
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
session.stepOpponent()
}
}
let rating = LeakScorecard(of: history.hands).rating
t.greaterThan(rating.value, 44,
"control seed \(seed): rated \(rating.value) off \(rating.costPer100) bb/100")
}
}
t.test("it does not pretend to know from a handful of hands") {
let early = SkillRating(costPer100: 0, hands: 10)
let settled = SkillRating(costPer100: 0, hands: 500)
t.lessThan(early.value, settled.value, "ten good hands should not make anyone an expert")
t.greaterThan(early.value, 40, "nor should it call them a beginner")
}
t.test("it says it is still settling for exactly as long as it is being held back") {
// The screen's caveat used to come off a hundred hands before the pulling stopped, so
// at 150 hands the card said "based on what your mistakes cost" about a number still a
// quarter of the way to the middle. The number owns the bar now and the screen reads
// `isSettled`, so the two cannot drift apart again.
let bar = SkillRating.handsToSettle
t.equal(bar, 200, "200 hands is the bar the rest of the app uses")
t.expect(!SkillRating(costPer100: 0, hands: bar - 50).isSettled)
t.expect(SkillRating(costPer100: 0, hands: bar).isSettled)
let held = SkillRating(costPer100: 0, hands: bar - 50)
let settled = SkillRating(costPer100: 0, hands: bar)
t.lessThan(held.value, settled.value,
"the rating is not being pulled towards the middle at \(bar - 50) hands, so "
+ "the caveat at that point would be saying nothing")
t.equal(settled.value, SkillRating(costPer100: 0, hands: 5_000).value,
"the rating is still being held back past the point the screen stops saying so")
}
t.test("it sets the table slightly above the player") {
let rating = SkillRating(costPer100: 5, hands: 400)
t.greaterThan(rating.tableDifficulty, rating.value - 1, "tables should not be easier than the player")
}
}
}
/// The cash coach: a second edition of the catalogue, and the three habits only a deep stack has.
/// Runs `body` with the process set to the cash product at a fixed-limit table, and puts the
/// tournament back after.
public func withLimitTable<T>(_ body: (CashSetup) throws -> T) rethrows -> T {
try withCashProduct {
let setup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .fixedLimit)
LeakCatalogue.format = .limit
defer { LeakCatalogue.format = .cash }
return try body(setup)
}
}
func limitCoachTests(_ t: TestRun) {
t.suite("the limit coach") { t in
t.test("the limit catalogue drops the bet sizes and the stacks and adds the price") {
let limit = LeakCatalogue.limit.map(\.id)
for gone in ["stacked-off-one-pair", "set-mine-no-odds", "missed-implied-odds", "bet-sizing",
"station-flop", "fold-to-cbet", "river-hero-call", "river-overfold",
"call-too-wide-preflop", "no-shove", "shove-too-wide"] {
t.expect(!limit.contains(gone), "\(gone) has no business at a fixed-limit table")
}
for id in LeakCatalogue.limitOnly.map(\.id) {
t.expect(limit.contains(id), "\(id) is missing from the limit edition")
}
t.equal(Set(limit).count, limit.count, "two limit habits share an id")
var seen: Set<String> = []
for leak in LeakCatalogue.limit {
let key = "\(leak.spot)/\(leak.error)"
t.expect(!seen.contains(key), "two limit habits answer to \(key)")
seen.insert(key)
}
t.equal(LeakCatalogue.all(for: .limit).map(\.id), limit)
// The free card is its own situation at a limit table and nowhere else; the whole
// stack never goes in on one street there.
t.expect(SpotKind.measured(for: .limit).contains(.freeCardChance))
t.expect(!SpotKind.measured(for: .limit).contains(.deepStackOff))
t.expect(!SpotKind.measured(for: .limit).contains(.shortStackShove))
t.expect(!SpotKind.measured(for: .cash).contains(.freeCardChance))
t.expect(!SpotKind.measured(for: .tournament).contains(.freeCardChance))
}
t.test("a fixed-limit sitting judges as a limit coach, and a no-limit one as a cash coach") {
withCashProduct {
let session = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutorCash)
session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .fixedLimit)
session.start(.cash)
t.equal(session.coach.format, .limit)
t.equal(LeakCatalogue.format, .limit)
t.equal(session.bettingStructure, .fixedLimit)
session.leave()
session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .noLimit)
session.start(.cash)
t.equal(session.coach.format, .cash)
t.equal(LeakCatalogue.format, .cash)
session.leave()
// The lessons, the setups and the rating line follow the same switch.
t.equal(Basics.lessons(for: .limit).map(\.id).contains("limit-price"), true)
t.expect(!Basics.lessons(for: .limit).map(\.id).contains("money-behind"))
t.expect(!AssessmentPlanner.setups(for: .limit).map(\.id).contains("deep"))
t.expect(AssessmentPlanner.setups(for: .limit).map(\.id).contains("big-blind"))
}
t.equal(LeakCatalogue.format, .tournament)
}
// The limit habits, each measured against the player who has it and the player who does
// not, at the app's own fixed-limit table. Short runs — the bars come from the sweep in
// `Leaks.swift`; these say the coach can tell the two apart at all.
func charged(_ player: PlantedPlayer, _ id: String, hands: Int, seeds: [UInt64],
setup: CashSetup) -> (flawed: Int, clean: Int, flawedChances: Int) {
var flawed = 0, clean = 0, chances = 0
for seed in seeds {
let a = runPlantedCash(player, hands: hands, seed: seed, setup: setup)
let b = runPlantedCash(.control, hands: hands, seed: seed, setup: setup)
flawed += a.profile.records[id]?.mistakes ?? 0
chances += a.profile.records[id]?.opportunities ?? 0
clean += b.profile.records[id]?.mistakes ?? 0
}
return (flawed, clean, chances)
}
t.test("the coach charges the player who folds a draw to one bet, and not the control") {
withLimitTable { setup in
// A sound limit player still folds a draw now and then — the sweep put the control
// at 5-10% against the flawed player's 68-84% — so the bar here is a multiple.
let r = charged(.foldsDrawsForOneBet, "limit-fold-to-price", hands: 600, seeds: [11, 23], setup: setup)
t.greaterThan(r.flawed, r.clean * 2 + 5,
"folding a draw was charged \(r.flawed) times against \(r.clean) for the control")
}
}
t.test("the coach charges the player who calls the last bet with nothing") {
withLimitTable { setup in
let r = charged(.callsRiverWithNothing, "limit-call-down-nothing", hands: 900, seeds: [5, 17], setup: setup)
t.greaterThan(r.flawed, r.clean * 2 + 3,
"calling with nothing was charged \(r.flawed) times against \(r.clean)")
}
}
t.test("the coach charges the player who caps with anything") {
withLimitTable { setup in
let r = charged(.capsEverything, "limit-cap-light", hands: 700, seeds: [7, 29], setup: setup)
t.greaterThan(r.flawed, r.clean * 3 + 5,
"capping light was charged \(r.flawed) times against \(r.clean)")
}
}
t.test("the coach charges the player who calls two bets cold") {
withLimitTable { setup in
let r = charged(.coldCallsRaises, "limit-cold-call", hands: 900, seeds: [3, 19], setup: setup)
t.greaterThan(r.flawed, r.clean * 2 + 3,
"cold-calling was charged \(r.flawed) times against \(r.clean)")
}
}
t.test("the coach charges the player who folds the river to one bet") {
withLimitTable { setup in
let r = charged(.foldsRiverToOneBet, "limit-river-overfold", hands: 1200, seeds: [13, 31], setup: setup)
t.greaterThan(r.flawed, r.clean * 2 + 3,
"folding the river was charged \(r.flawed) times against \(r.clean)")
}
}
t.test("calling with the best hand is priced, and said not to be nameable") {
// The bands touch — see the catalogue — so this habit is unnameable. What the coach
// still owes is the price: the player who turns every raise into a call must be
// charged more than the control, and the cost must count.
withLimitTable { setup in
let leak = LeakCatalogue.named("limit-missed-value-raise")
t.expect(leak != nil && leak!.canBeNamed == false, "the habit should be priced but unnameable")
let r = charged(.neverRaisesPostflop, "limit-missed-value-raise", hands: 900, seeds: [5, 17], setup: setup)
t.greaterThan(r.flawed, r.clean, "never raising was charged \(r.flawed) times against \(r.clean)")
}
}
t.test("a player with no planted flaw is never named at a fixed-limit table") {
// The limit edition's version of the cash sweep: the control at the app's own
// fixed-limit table, replayed decision by decision, at the bottom rung and the top.
// Two habits are exempt from the "was it judged" check and say why in the catalogue:
// the free card (a spot a sound player is in a dozen times in six thousand hands) and
// the river value bet (five hundred chances is more than six thousand hands hold).
withLimitTable { setup in
var everNamed: [String] = []
var floorsCleared = 0
var floorsMissed: [String] = []
for (seed, rung) in [(UInt64(4_242), 0), (77, 3)] {
let coach = runPlantedCash(.control, hands: 6_000, seed: seed, setup: setup, rung: rung,
keepEveryReview: true)
let sweep = NamingSweep(coach)
for moment in sweep.named {
everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) (seed \(seed), rung \(rung))")
}
for leak in LeakCatalogue.nameable where !["limit-no-free-card", "missed-river-value"].contains(leak.id) {
let chances = sweep.chances[leak.id] ?? 0
guard chances > 0 else { continue }
if chances >= leak.minimumSample { floorsCleared += 1 }
else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
}
}
t.expect(everNamed.isEmpty,
"a player with no planted flaw was told he has a habit at a limit table: "
+ "\(everNamed.joined(separator: "; "))")
t.expect(floorsMissed.isEmpty,
"these limit habits were never judged at all, so their bars are untested here: "
+ "\(Set(floorsMissed).sorted().joined(separator: ", "))")
t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
}
}
}
}
func cashCoachTests(_ t: TestRun) {
limitCoachTests(t)
t.suite("the cash coach") { t in
// The number a coach lives or dies on, measured at the app's own cash table. The
// tournament's flawless player is stopped on about 7% of hands; at the bottom cash rung,
// where six of eight opponents call too much and every pot is multiway, the same player
// is stopped on 8-9% over 2,000 hands (5% at the top rung; 7-8% at pot-limit; 7% at
// fixed-limit — `pokerlab coach --player control --format cash --all`). Nothing on the
// list is one habit: the charges are spread across twenty kinds, half of them sizing
// and value-raise disagreements a solid regular and the coach are entitled to have. A
// bot rewritten to read a big bet as a strong hand at cash tables (2026-09-12) moved the
// rate within noise and raised the cost, and was reverted. This holds the rate under one
// hand in twelve, over two seeds, in the room the drills and the sweeps use.
t.test("a sound cash player is not stopped more than one hand in twelve") {
withCashProduct {
var opened = 0, hands = 0, spoken = 0
for seed: UInt64 in [31, 1_009] {
let coach = runPlantedCash(.control, hands: 1_500, seed: seed, keepEveryReview: true)
opened += coach.reviews.filter { !$0.playedWell }.count
spoken += coach.reviews.flatMap(\.verdicts).filter(\.shouldSpeak).count
hands += coach.reviews.count
}
let rate = Double(opened) / Double(max(1, hands))
t.lessThan(rate, 1.0 / 12.0,
"a player with no flaw had a review opened on \(opened) of \(hands) cash hands "
+ "(\(Int((rate * 100).rounded()))%) — the coach is accusing a sound player")
t.greaterThan(spoken, 0, "the coach said nothing at all in \(hands) hands, which is not the same as being right")
}
}
t.test("the tournament catalogue is untouched by the cash one") {
let ids = LeakCatalogue.tournament.map(\.id)
t.equal(ids.count, 18, "the tournament edition should still hold its eighteen habits")
t.expect(ids.contains("no-shove") && ids.contains("shove-too-wide"),
"the short-stack habits belong to the tournament edition")
t.expect(!ids.contains("stacked-off-one-pair"),
"a cash habit has no business in the tournament edition")
// The process starts as the tournament product, and that is the edition it reads.
t.equal(LeakCatalogue.format, .tournament)
t.equal(LeakCatalogue.all.map(\.id), ids)
}
t.test("the cash catalogue drops the short stack and adds the money behind") {
let cash = LeakCatalogue.cash.map(\.id)
t.expect(!cash.contains("no-shove") && !cash.contains("shove-too-wide"),
"nobody at a cash table is in a push-or-fold spot")
for id in ["stacked-off-one-pair", "set-mine-no-odds", "missed-implied-odds"] {
t.expect(cash.contains(id), "\(id) is missing from the cash edition")
}
for leak in LeakCatalogue.tournament where leak.spot != .shortStackShove {
t.expect(cash.contains(leak.id), "\(leak.id) should carry over to the cash table")
}
t.equal(Set(cash).count, cash.count, "two cash habits share an id")
// Every (situation, mistake) pair is one habit, in either edition — a verdict has to
// land on exactly one row.
for edition in [LeakCatalogue.tournament, LeakCatalogue.cash] {
var seen: Set<String> = []
for leak in edition {
let key = "\(leak.spot)/\(leak.error)"
t.expect(!seen.contains(key), "two habits answer to \(key)")
seen.insert(key)
}
}
}
t.test("switching the product switches the edition, and switching back restores it") {
withCashProduct {
t.equal(LeakCatalogue.format, .cash)
t.equal(AppVariant.current, .pokerTutorCash)
t.equal(LeakCatalogue.all.map(\.id), LeakCatalogue.cash.map(\.id))
t.equal(LeakCatalogue.nameable.count, LeakCatalogue.cash.filter(\.canBeNamed).count)
}
t.equal(LeakCatalogue.format, .tournament)
t.equal(AppVariant.current, .pokerTutor)
}
t.test("a cash session judges as a cash coach, a tournament session as a tournament coach") {
let cash = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutorCash)
t.equal(cash.coach.format, .cash)
t.expect(!cash.coach.judgesShortStacks)
let tournament = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutor)
t.equal(tournament.coach.format, .tournament)
t.expect(tournament.coach.judgesShortStacks)
}
// The three cash habits, each measured against the player who has it and the player who
// does not, at the app's own cash table. Short runs — the bars come from the long sweep in
// `Leaks.swift`; these say the coach can tell the two apart at all.
t.test("the coach charges the player who stacks off with one pair, and not the control") {
withCashProduct {
var flawed = 0, clean = 0
for seed: UInt64 in [11, 23] {
let a = runPlantedCash(.stacksOffOnePair, hands: 700, seed: seed)
let b = runPlantedCash(.control, hands: 700, seed: seed)
flawed += a.profile.records["stacked-off-one-pair"]?.mistakes ?? 0
clean += b.profile.records["stacked-off-one-pair"]?.mistakes ?? 0
}
t.greaterThan(flawed, clean + 3,
"stacking off was charged \(flawed) times against \(clean) for the control")
}
}
t.test("the coach charges the player who calls every re-raise with a small pair") {
withCashProduct {
var flawed = 0, clean = 0
for seed: UInt64 in [5, 17] {
flawed += runPlantedCash(.setMinesEverything, hands: 900, seed: seed)
.profile.records["set-mine-no-odds"]?.mistakes ?? 0
clean += runPlantedCash(.control, hands: 900, seed: seed)
.profile.records["set-mine-no-odds"]?.mistakes ?? 0
}
t.greaterThan(flawed, clean + 2,
"calling re-raises with small pairs was charged \(flawed) times against \(clean)")
}
}
t.test("the coach charges the player who folds the hands the money behind pays for") {
withCashProduct {
var flawed = 0, clean = 0
for seed: UInt64 in [7, 29] {
flawed += runPlantedCash(.foldsSpeculativeHands, hands: 700, seed: seed)
.profile.records["missed-implied-odds"]?.mistakes ?? 0
clean += runPlantedCash(.control, hands: 700, seed: seed)
.profile.records["missed-implied-odds"]?.mistakes ?? 0
}
t.greaterThan(flawed, clean + 3,
"folding the hands the stacks pay for was charged \(flawed) times against \(clean)")
}
}
t.test("a player with no planted flaw is never named at a cash table, at either end of the ladder") {
// The cash edition's own version of the sweep above: the control at the app's own
// nine-handed cash table, twelve thousand hands each — long enough to clear every floor a sound player can reach, the river call included —
// replayed decision by decision. Two seeds at the bottom rung, where every player
// starts, and one at the top, where the opponents stop giving money away and the
// running rates run highest.
//
// One habit is exempt from the "was it judged" check, and it is said here rather
// than left to be found: putting a whole stack in with one pair is counted over the
// decisions where the next chips were most of a stack, and a sound player faces one
// of those a handful of times in six thousand hands. Its floor is twelve; the control
// never gets there. The bar is not looking away; a sound player is simply rarely in
// that situation.
withCashProduct {
var everNamed: [String] = []
var floorsCleared = 0
var floorsMissed: [String] = []
for (seed, rung) in [(UInt64(4_242), 0), (512, 0), (77, 3)] {
let coach = runPlantedCash(.control, hands: 12_000, seed: seed, rung: rung,
keepEveryReview: true)
let sweep = NamingSweep(coach)
for moment in sweep.named {
everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) "
+ "(seed \(seed), rung \(rung))")
}
for leak in LeakCatalogue.nameable where leak.id != "stacked-off-one-pair" {
let chances = sweep.chances[leak.id] ?? 0
guard chances > 0 else { continue }
if chances >= leak.minimumSample { floorsCleared += 1 }
else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
}
}
t.expect(everNamed.isEmpty,
"a player with no planted flaw was told he has a habit at a cash table: "
+ "\(everNamed.joined(separator: "; "))")
t.expect(floorsMissed.isEmpty,
"these cash habits were never judged at all, so their bars are untested here: "
+ "\(Set(floorsMissed).sorted().joined(separator: ", "))")
t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
}
}
t.test("the cash scale has the control near the top and the worst scripted player near the bottom") {
// The cash line was derived with each player's own penalty in (`SkillRating`), so the
// same two ends hold with penalties: the control at 66 with none, and the player who
// puts a whole stack in with one pair — a habit priced but not named, so no penalty —
// at 10. One decision at a cash table is charged at most thirty big blinds, and a
// preflop fold at most six, which is what keeps a single enormous pot from rating a
// sound player zero.
t.equal(DecisionEvaluator.mostOneDecisionCosts, 30)
t.equal(DecisionEvaluator.mostOnePreflopFoldCosts, 6)
t.equal(SkillRating(costPer100: 15.1, hands: 3000, format: .cash).value, 66)
t.equal(SkillRating(costPer100: 60.2, hands: 3000, format: .cash).value, 10)
t.close(SkillRating.costFor(rating: 66, format: .cash), 15.1, 0.2)
// The fixed-limit line: the control at 9.6 bb/100 with no penalty rates 66, the
// player who folds every draw to one bet — 46.3 with every habit confirmed — rates 10.
t.equal(SkillRating(costPer100: 9.6, hands: 3000, format: .limit).value, 66)
t.equal(SkillRating(costPer100: 46.3, hands: 3000, leakPenalty: 15, format: .limit).value, 10)
t.close(SkillRating.costFor(rating: 66, format: .limit), 9.6, 0.2)
// The two lines are different lines, and the tournament one has not moved.
t.equal(SkillRating(costPer100: 9.8, hands: 3000, format: .tournament).value, 66)
t.expect(SkillRating(costPer100: 18.8, hands: 3000, format: .tournament).value
< SkillRating(costPer100: 18.8, hands: 3000, format: .cash).value,
"the same cost rates lower at a tournament, because a tournament charges less")
// The rungs of the stakes ladder spread the scripted players out, in order.
withCashProduct {
t.equal(LadderTier.entry(forRating: 10).id, "cash-25-50")
t.equal(LadderTier.entry(forRating: 30).id, "cash-25-50")
t.equal(LadderTier.entry(forRating: 40).id, "cash-50-100")
t.equal(LadderTier.entry(forRating: 50).id, "cash-100-200")
t.equal(LadderTier.entry(forRating: 55).id, "cash-100-200")
t.equal(LadderTier.entry(forRating: 62).id, "cash-500-1000")
t.equal(LadderTier.entry(forRating: 66).id, "cash-500-1000")
}
// And at the app's own cash table, over two seeds, the control stays a solid amateur.
withCashProduct {
for seed: UInt64 in [3, 5] {
let history = HistoryStore.inMemory()
let session = TableSession(seed: seed, history: history, variant: .pokerTutorCash)
var rng = SeededRandom(seed: seed &* 13 &+ 7)
var steps = 0
session.start(.cash)
while history.hands.count < 1000, steps < 300_000 {
steps += 1
if !session.isPlaying { session.start(.cash); continue }
if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
session.act(PlantedPlayer.control.decide(view, &rng))
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
session.stepOpponent()
}
}
let rating = LeakScorecard(of: history.hands).rating
t.greaterThan(rating.value, 44,
"cash control seed \(seed): rated \(rating.value) off \(rating.costPer100) bb/100")
}
}
}
t.test("a tournament table never produces a cash verdict") {
// The deep-stack rules are switched on by the format, not by the stack. A tournament
// coach a hundred blinds deep prices the same hands the way it always did.
let coach = runPlanted(.setMinesEverything, hands: 400, seed: 9, keepEveryReview: true)
let cashKinds: Set<ErrorKind> = [.stackedOffOnePair, .setMinedWithoutOdds, .missedImpliedOdds]
let stray = coach.reviews.flatMap(\.verdicts).filter { $0.error.map(cashKinds.contains) ?? false }
t.equal(stray.count, 0, "a tournament coach produced \(stray.count) cash verdicts")
}
t.test("the assessment's arrangements follow the edition") {
let tournament = AssessmentPlanner.setups(for: .tournament).map(\.id)
let cash = AssessmentPlanner.setups(for: .cash).map(\.id)
t.expect(tournament.contains("short-stack") && !cash.contains("short-stack"))
t.expect(cash.contains("deep") && !tournament.contains("deep"))
t.expect(cash.contains("early-seat") && cash.contains("last-seats"),
"the drills pin on these two ids in both editions")
withCashProduct {
t.equal(AssessmentPlanner.setups.map(\.id), cash)
// Every situation the cash coach measures can be reached by some arrangement.
for spot in SpotKind.measured {
t.expect(AssessmentPlanner.setups.contains { ($0.expected[spot] ?? 0) > 0 },
"no cash arrangement reaches \(spot)")
}
}
}
}
}