← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
/// One rung of the ladder: a room, described by who is sitting in it.
///
/// The rung is the **cast**, not the dial, because that is what the shootouts measured. A
/// competent regular beats a table of calling stations by well over a
/// hundred big blinds per hundred hands, and beats a better version of its own style by single
/// digits: style is worth roughly ten times what skill is worth. So a promotion here replaces
/// people. Three of the players who called everything are gone and two regulars have taken their
/// chairs, and *that* is what makes the next rung hard. The skill number moves too, second, and it
/// only decides how well each of them plays the style they were given.
///
/// Every rung seats eight opponents, because the tables in this app are nine handed.
public struct LadderTier: Sendable, Equatable, Identifiable {
/// Stable, and written into every saved hand. Never renamed: a hand recorded under an id that
/// no longer exists simply stops counting towards the ladder, which is a silent demotion.
public let id: String
/// Zero at the bottom.
public let rung: Int
public let name: String
/// The eight people who sit down. This is what the rung actually is.
public let roster: [Personality]
/// How well they play it. Secondary, and deliberately so.
public let skill: Int
/// What the room is like, in plain English.
public let blurb: String
/// The rating that moves you up from here, or nil at the top.
public let promotionRating: Int?
/// The rating that drops you back, or nil at the bottom — nobody falls off the ladder.
public let demotionRating: Int?
/// Cash rungs only: the blinds this room plays. A cash ladder is a ladder of stakes.
public let stakes: BlindLevel?
public init(id: String, rung: Int, name: String, roster: [Personality], skill: Int, blurb: String,
promotionRating: Int?, demotionRating: Int?, stakes: BlindLevel? = nil) {
self.id = id
self.rung = rung
self.name = name
self.roster = roster
self.skill = skill
self.blurb = blurb
self.promotionRating = promotionRating
self.demotionRating = demotionRating
self.stakes = stakes
}
public var isTop: Bool { promotionRating == nil }
public var isBottom: Bool { demotionRating == nil }
/// The name as it reads inside a sentence. Only the article changes case: lowercasing the
/// whole thing gives "the friday night home game", and Friday is still a Friday.
public var inSentence: String {
name.hasPrefix("The ") ? "the " + name.dropFirst(4) : name
}
/// The rung above and below, where there is one.
public var next: LadderTier? { LadderTier.all.first { $0.rung == rung + 1 } }
public var previous: LadderTier? { LadderTier.all.first { $0.rung == rung - 1 } }
/// The cast, counted up and kept in roster order — the order the rungs are written in, which
/// runs from the players who give money away towards the ones who take it.
public var cast: [(style: Personality, count: Int)] {
var order: [Personality] = []
var counts: [Personality: Int] = [:]
for style in roster {
if counts[style] == nil { order.append(style) }
counts[style, default: 0] += 1
}
return order.map { ($0, counts[$0] ?? 0) }
}
/// "Three calling stations, three loose passive players, a maniac and a nit."
public var castLine: String {
let parts = cast.map { $0.style.counted($0.count) }
guard let last = parts.last else { return "" }
if parts.count == 1 { return last + "." }
return parts.dropLast().joined(separator: ", ") + " and " + last + "."
}
// MARK: - The rungs
/// Five rooms, and the whole difference between them is who is in them.
///
/// Read the rosters downwards and the story is the one the measurements told: the people who
/// call too much and raise too little are replaced, a couple at a time, by people who do
/// neither. The skill numbers climb alongside, but they are the smaller half of it, and each
/// one is pulled into whatever band the style it is attached to can exist in — a calling
/// station is a losing style by definition and cannot be an expert one.
///
/// The bars are twenty rating points apart on purpose. The rating that moves you off a rung is
/// always twenty clear of the one that would send you back down to it, so a player sitting
/// near a bar settles on one side of it instead of being carried up and down by the ordinary
/// wobble of a few hundred hands. See `LadderStanding`.
///
/// The bars themselves were re-derived when the rating was re-anchored on tournament play
/// rather than on the coach harness — the old ones sat against a scale where the flawless
/// reference player scored zero, so on the new one everybody would have climbed to the top in
/// an evening. They are placed so that each of the scripted players in `pokerlab calibrate`
/// settles on a different rung, in the order anybody would put them in:
///
/// plays far too many hands rating 9 the kitchen table
/// folds to every flop bet 17 the kitchen table
/// never raises first in 30 the Friday night home game
/// folds the big blind 48 the casino low stakes game
/// never value bets the river 59 the regular game
/// no planted flaw 62 the tough table
///
/// That is the whole ladder doing its job: play like a bot with no deliberate flaw and you
/// reach the top table, and every habit you have not fixed keeps you a rung below it.
/// The rungs of the ladder this product plays: rooms at a tournament, stakes at a cash table.
public static var all: [LadderTier] { all(for: AppVariant.current.format) }
public static func all(for format: AppVariant.Format) -> [LadderTier] {
format == .cash ? cashRungs : tournamentRungs
}
/// The cash ladder: four stakes, and the same story down the rosters — the people who call
/// too much and raise too little are replaced, a couple at a time, by people who do neither.
/// Moving up is moving up in stakes, which is what a cash player means by it. The bars were
/// set from `pokerlab calibrate --format cash` (the line is in `SkillRating`) so the scripted
/// players spread across them: stacks off with one pair 10 and plays far too many hands 30
/// (25/50), folds to every flop bet 40 (50/100), never raises first in 50, folds the hands the
/// stacks pay for 49 and folds the big blind 55 (100/200), never bets the river 62 and the
/// control 66 (500/1000). Twenty points between the bar that moves you up and the one that
/// sends you back, as on the tournament ladder.
public static let cashRungs: [LadderTier] = [
LadderTier(
id: "cash-25-50", rung: 0, name: "The 25/50 table",
roster: tournamentRungs[0].roster, skill: tournamentRungs[0].skill,
blurb: "The smallest game, and the loosest. Almost nobody folds and almost nobody "
+ "raises, so a good hand does not have to be clever here — it has to bet, and it "
+ "gets paid.",
promotionRating: 34, demotionRating: nil,
stakes: BlindLevel(smallBlind: 25, bigBlind: 50)),
LadderTier(
id: "cash-50-100", rung: 1, name: "The 50/100 table",
roster: tournamentRungs[1].roster, skill: tournamentRungs[1].skill,
blurb: "Still loose, but two of them are waiting for good cards and one of them is "
+ "playing properly. Free pots are rarer, and a weak hand now gets raised off "
+ "rather than simply called.",
promotionRating: 46, demotionRating: 14,
stakes: BlindLevel(smallBlind: 50, bigBlind: 100)),
LadderTier(
id: "cash-100-200", rung: 2, name: "The 100/200 table",
roster: tournamentRungs[2].roster, skill: tournamentRungs[2].skill,
blurb: "Half the table knows exactly what it is doing. There is still one person here "
+ "paying for everybody else, and working out which one early is most of the "
+ "evening.",
promotionRating: 58, demotionRating: 26,
stakes: BlindLevel(smallBlind: 100, bigBlind: 200)),
LadderTier(
id: "cash-500-1000", rung: 3, name: "The 500/1000 table",
roster: tournamentRungs[3].roster, skill: tournamentRungs[3].skill,
blurb: "Nobody at this table is giving anything away. Pots are fought over, weak hands "
+ "get pushed out, and the money stops coming from other people's mistakes and "
+ "starts coming from small edges taken over and over.",
promotionRating: nil, demotionRating: 38,
stakes: BlindLevel(smallBlind: 500, bigBlind: 1000)),
]
/// The cash rung that plays these blinds, if there is one.
public static func cash(forStakes stakes: BlindLevel) -> LadderTier? {
cashRungs.first { $0.stakes?.bigBlind == stakes.bigBlind }
}
/// The tournament ladder: what the app shipped with.
public static let tournamentRungs: [LadderTier] = [
LadderTier(
id: "kitchen", rung: 0, name: "The kitchen table",
roster: [.callingStation, .callingStation, .callingStation,
.loosePassive, .loosePassive, .loosePassive, .maniac, .nit],
skill: 15,
blurb: "Almost nobody folds and almost nobody raises. Hands get played to the end that "
+ "should never have been played at all, so a good hand here does not have to be "
+ "clever — it just has to bet and get paid.",
promotionRating: 22, demotionRating: nil),
LadderTier(
id: "home-game", rung: 1, name: "The Friday night home game",
roster: [.callingStation, .callingStation, .loosePassive, .loosePassive,
.maniac, .nit, .nit, .tightAggressive],
skill: 30,
blurb: "Still loose, but two of them are sitting on their hands waiting for good cards "
+ "and one of them is playing properly. Free pots are rarer, and a weak hand now "
+ "gets raised off rather than simply called.",
promotionRating: 35, demotionRating: 2),
LadderTier(
id: "casino-low", rung: 2, name: "The casino low stakes game",
roster: [.callingStation, .loosePassive, .nit, .nit,
.tightAggressive, .tightAggressive, .looseAggressive, .solidRegular],
skill: 45,
blurb: "Half the table knows exactly what it is doing. There is still one person here "
+ "paying for everybody else, and working out which one early is most of the "
+ "evening.",
promotionRating: 48, demotionRating: 15),
LadderTier(
id: "regulars", rung: 3, name: "The regular game",
roster: [.nit, .tightAggressive, .tightAggressive, .tightAggressive,
.solidRegular, .solidRegular, .solidRegular, .looseAggressive],
skill: 62,
blurb: "Nobody at this table is giving anything away. Pots are fought over, weak hands "
+ "get pushed out, and the money stops coming from other people's mistakes and "
+ "starts coming from small edges taken over and over.",
promotionRating: 60, demotionRating: 28),
LadderTier(
id: "tough", rung: 4, name: "The tough table",
roster: [.solidRegular, .solidRegular, .solidRegular,
.trickyRegular, .trickyRegular, .trickyRegular,
.looseAggressive, .looseAggressive],
skill: 80,
blurb: "Everyone here is watching you and changing what they do because of it. Any "
+ "habit you fall into gets found and charged for inside an hour.",
promotionRating: nil, demotionRating: 40),
]
public static var bottom: LadderTier { all[0] }
/// The rung a saved hand was played at, or nil if it names one this build no longer has.
/// Both ladders are searched: a hand is what it says it is whichever product reads it.
public static func named(_ id: String?) -> LadderTier? {
guard let id else { return nil }
return all.first { $0.id == id } ?? tournamentRungs.first { $0.id == id }
?? cashRungs.first { $0.id == id }
}
/// Whether this rung is on the ladder the product plays. A cash hand read by the tournament
/// app names a rung that is not on its ladder, and the ladder leaves it out.
public var isOnThisLadder: Bool { LadderTier.all.contains { $0.id == id } }
/// Where a rating sits: the highest rung whose entry has actually been earned, which is the
/// same test the climb itself uses rather than a second set of numbers that could disagree.
public static func entry(forRating rating: Int) -> LadderTier {
var placed = bottom
for tier in all.dropFirst() {
guard let bar = tier.previous?.promotionRating, rating >= bar else { break }
placed = tier
}
return placed
}
}
/// Which way the last full sample pointed.
public enum LadderMove: String, Sendable {
case promoted
case demoted
case held
}
/// Where the player is on the ladder, and what their recent hands say about it.
///
/// Worked out from the saved hands, every time, rather than kept in a file of its own. There is no
/// second copy of the standing to fall out of step with the history, for the same reason there is
/// no second copy of an assessment's progress: the hands *are* the record. Every hand written down
/// while sitting on the ladder carries the rung it was played at, so the standing survives a
/// restart because the evidence for it does.
///
/// Two rules decide whether anybody moves, and both exist to stop the ladder reacting to noise.
///
/// - **Nothing moves inside a tournament, or two, or five.** A full sample is two hundred hands at
/// the current table — the same bar every other number in this app is held to — because a good
/// evening and a good player are indistinguishable over forty hands. Someone can win a whole
/// tournament and still be exactly where they were.
/// - **The bars do not touch.** The rating that promotes you off a rung is twenty points clear of
/// the one that would send you back down to it. Without that gap a player sitting on a bar is
/// carried up on one sample and back down on the next for as long as they keep playing, and a
/// ladder that moves someone every fortnight in alternating directions is telling them nothing.
public struct LadderStanding: Sendable {
/// Hands at the current table before anything can move. Two hundred is roughly five
/// tournaments, and it is the number the rest of the app already treats as the point where a
/// rate stops being an anecdote.
public static let sampleSize = 200
/// How far back the sample reaches. Beyond this the older hands are let go, so a bad month
/// last year cannot hold somebody down after they have fixed it.
public static let window = 400
/// The rung the hands in question were actually played at.
public let settled: LadderTier
/// The rung the next table will be — `settled` moved by whatever the sample decided.
public let tier: LadderTier
public let move: LadderMove
/// Hands played at `settled` since the last time anybody moved.
public let handsAtTier: Int
/// How many of those the rating below was worked out from.
public let sample: Int
/// What those hands say the player is worth. Nil before there are any.
public let rating: SkillRating?
/// The habit costing the most at this table, where the hands support naming one at all.
public let costliest: LeakScorecard.Entry?
/// Big blinds won or lost over the counted hands — the number a cash player calls a result.
public let netBB: Double
/// Big blinds per hundred hands over the counted sample, the way cash players count it.
public var winRatePer100: Double { sample > 0 ? netBB / Double(sample) * 100 : 0 }
/// Whether this is a ladder of stakes.
public var isCash: Bool { settled.stakes != nil }
/// "50/100" for a cash rung.
public static func stakesText(_ tier: LadderTier) -> String {
guard let stakes = tier.stakes else { return tier.inSentence }
return "\(stakes.smallBlind)/\(stakes.bigBlind)"
}
public init(of records: [HandRecord]) {
// Hands written down under a rung this build still has. A rung that was removed takes its
// hands out of the ladder with it rather than being guessed at.
let onLadder = records.filter { LadderTier.named($0.tier)?.isOnThisLadder == true }
guard let latest = onLadder.last, let settled = LadderTier.named(latest.tier) else {
// Nobody has sat on the ladder yet. Everyone starts at the bottom unless there is
// already a real history saying otherwise — and "real" is the same two hundred hands,
// because a rating off forty hands is held near the middle on purpose and placing
// somebody off it would be reading a number the hands do not support.
//
// Arranged hands are not results. An assessment chose the seat and the stacks and a
// drill chose the situation, so neither can move anybody — and counting them towards
// the two hundred while the rating below refuses to count them meant an evening of
// drilling opened the door on a number built from almost nothing.
// And a hand written at a rung of the other ladder is no evidence about this one.
let real = records.filter { !$0.isArranged && (LadderTier.named($0.tier)?.isOnThisLadder ?? true) }
let placed = real.count >= Self.sampleSize
? LadderTier.entry(forRating: LeakScorecard(of: real).rating.value)
: LadderTier.bottom
self.settled = placed
self.tier = placed
self.move = .held
self.handsAtTier = 0
self.sample = 0
self.rating = nil
self.costliest = nil
self.netBB = 0
return
}
// The run at this rung. On the cash ladder a rung is a stake AND a kind of betting: two
// hundred hands of fixed-limit and two hundred of no-limit at 50/100 are two records on
// two scales, not one run — and the hands are read on the scale of the game they were
// played at, whatever the player is looking at now. Hands at another stake in between do
// not break the run; each stake keeps its own.
let structure = settled.stakes != nil ? latest.structure : nil
let run = onLadder.filter { $0.tier == settled.id && (structure == nil || $0.structure == structure) }
let counted = Array(run.suffix(Self.window))
let card = LeakScorecard(of: counted)
let format: CoachFormat = settled.stakes == nil ? .tournament
: (structure == BettingStructure.fixedLimit.rawValue ? .limit : .cash)
let rating = card.rating(for: format)
self.settled = settled
self.handsAtTier = run.count
self.sample = counted.count
self.rating = rating
self.costliest = card.costing.first
self.netBB = counted.reduce(0.0) { $0 + $1.netBB }
if run.count < Self.sampleSize {
self.move = .held
self.tier = settled
} else if let bar = settled.promotionRating, rating.value >= bar, let up = settled.next {
self.move = .promoted
self.tier = up
} else if let bar = settled.demotionRating, rating.value <= bar, let down = settled.previous {
self.move = .demoted
self.tier = down
} else {
self.move = .held
self.tier = settled
}
}
/// True once the sample is long enough for the bars to apply at all.
public var enoughHands: Bool { handsAtTier >= Self.sampleSize }
/// Hands still to play before anything can move.
public var handsToGo: Int { max(0, Self.sampleSize - handsAtTier) }
/// How far through the sample they are, for a bar on the screen.
public var fraction: Double {
min(1, Double(handsAtTier) / Double(Self.sampleSize))
}
// MARK: - Saying it out loud
/// Where they are sitting, or where they are about to sit.
public var placeLine: String {
if isCash {
switch move {
case .promoted:
return "You have earned \(Self.stakesText(tier)). That is the game to sit down at next."
case .demoted:
return "You are going back to \(Self.stakesText(tier)) for a while."
case .held:
return "You play \(Self.stakesText(settled))."
}
}
switch move {
case .promoted:
return "You have earned \(tier.name). That is where you sit down next."
case .demoted:
return "You are going back to \(tier.name) for a while."
case .held:
return "You are at \(settled.name)."
}
}
/// A cash player's own number for the table, said beside the rating that moves them.
public var winRateLine: String? {
guard isCash, sample > 0 else { return nil }
let rate = String(format: "%+.0f", winRatePer100)
return "Win rate there: \(rate) big blinds for every 100 hands, over \(sample) hands."
}
/// What the hands actually say — including, most of the time, that they do not say enough yet.
public var resultsLine: String {
guard let rating, handsAtTier > 0 else {
return "You have not played a hand at this table yet. Nothing moves in either "
+ "direction until you have played \(Self.sampleSize) of them."
}
guard enoughHands else {
let spell = isCash ? "an evening or two" : "a few tournaments"
return "\(handsAtTier) hands here so far, and they rate you \(rating.value). Nothing "
+ "moves until \(Self.sampleSize) — \(handsToGo) to go — because over "
+ "\(spell) a good player and a lucky one look exactly the same."
}
switch move {
case .promoted:
return "Over your last \(sample) hands at this table you rate \(rating.value), which "
+ "is past the \(settled.promotionRating ?? 0) it asks for. That is a big enough "
+ "run of hands to mean it."
case .demoted:
return "Over your last \(sample) hands at this table you rate \(rating.value), which "
+ "is under the \(settled.demotionRating ?? 0) it holds you to. A table you cannot "
+ "beat teaches you nothing, so you go back a rung and build the sample up again."
case .held:
let bar = settled.promotionRating
let needed = bar.map { " You need \($0) to move up." } ?? " There is nothing above this one."
return "Over your last \(sample) hands at this table you rate \(rating.value).\(needed)"
}
}
/// What it takes to move up from where they are.
public var requirementLine: String {
guard let bar = settled.promotionRating, let up = settled.next else {
return "This is the top of the ladder. There is no tougher table to be moved to, so "
+ "from here the rating is the only thing that goes up."
}
if isCash {
return "Rate \(bar) or better across at least \(Self.sampleSize) hands at "
+ "\(Self.stakesText(settled)) — the last \(Self.window) count — and the ladder moves "
+ "you up to \(Self.stakesText(up))."
}
return "Rate \(bar) or better across at least \(Self.sampleSize) hands at \(settled.inSentence) "
+ "— the last \(Self.window) count — and you move to \(up.inSentence)."
}
/// And what it takes to fall back, said out loud rather than left as a surprise.
public var demotionLine: String? {
guard let bar = settled.demotionRating, let down = settled.previous else {
return "Nobody falls off the bottom rung. This is where everyone starts."
}
return "If those same \(Self.sampleSize) hands rate you \(bar) or worse you go back to "
+ "\(down.inSentence). That is \((down.promotionRating ?? 0) - bar) points below what it "
+ "takes to come back up, and the gap is deliberate: a bad fortnight should not undo a "
+ "promotion, and a good one should not hand you a table you are not ready for."
}
/// The honest caveat that belongs under all of it.
public var meaningNote: String {
"This rating is only about what your own mistakes cost. It is not a win rate and it does "
+ "not care whether the cards ran for you, which is exactly why it is the thing the "
+ "ladder is hung on."
}
/// Which hands this rating is off, said out loud beside the other one.
///
/// Two numbers, both called "rating", visible at the same time on a wide screen: the one beside
/// this is counted over every hand ever played, and this one over at most the last 400 at the
/// table the player is on now. They disagree, often by a lot — a forty-hand assessment drags
/// the all-time number about and cannot touch this one at all — and "you rate 47, you need 52
/// to move up" beside a permanent "RATING 62" reads as an app that has forgotten to promote
/// somebody. It lives here rather than in the view because a sentence a test cannot read is a
/// sentence nobody is checking.
public var scopeNote: String {
"This is counted over your hands at this table only, so it will not match the all-time "
+ "rating in the corner — that one is every hand you have ever played, and moving up "
+ "turns on this one."
}
/// Where the money is going at this table, when there is enough to say. Nil is the common
/// answer and the honest one.
public var costliestLine: String? {
guard let costliest else { return nil }
guard let seat = costliest.seatLine else { return costliest.frequencyLine }
return costliest.frequencyLine + " " + seat
}
}