← poker-tutor
import Foundation
import PokerCore
import PokerCoach
/// When a drill is finished, and when a fix is believed.
///
/// The rule that matters here is the ending, and it asks two things at once.
///
/// The first is that the rate has to be at or under what a steady player manages in that spot —
/// with one imaginary slip added to the count, so the arithmetic errs against clearing rather than
/// towards it. The second is that it has to be over at least as many chances as the catalogue
/// already says it takes to *name* that habit. Both are needed. Without the rate, a drill would
/// clear on time served; without the sample, it would clear on four hands of luck, which is the
/// false all-clear this whole app exists not to hand anybody.
///
/// That second number is per habit, and deliberately: the catalogue says folding the big blind too
/// often takes 25 chances to call and folding to every re-raise takes 15, so those are the numbers
/// their drills have to clear. A drill that ended after a round number of hands would be handing
/// out all-clears off however many chances the deal happened to produce.
/// What a drill is aimed at: a habit from the catalogue, or a seat at the table.
///
/// The machinery is the same either way — chances, counted mistakes, a bar the rate has to get
/// under, a floor of chances it has to get there over, a check days later — so the target only
/// says what counts as a chance and what the bar is. A habit's chance is a decision in its
/// situation; a seat's chance is any decision the coach looked at from that seat, and its
/// mistake is any decision counted against the player there, whatever the habit.
public struct DrillTarget: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable {
case habit(Leak)
/// A seat, with the size of table it is a seat at — "seat 4 of 9".
case seat(Position, tableSize: Int)
}
public let kind: Kind
/// The rate a steady player manages here, which the drill has to get at or under.
public let acceptableRate: Double
/// Chances before a rate off them means anything.
public let minimumSample: Int
/// How the table is arranged to produce the situation. A habit picks off the planner; a seat
/// is pinned to itself.
public let setups: [AssessmentSetup]
/// "bb-overfold" for a habit; "seat:UTG+1" for a seat. Written into every drill hand, which
/// is how a drill and the check after it are counted back off the file.
public var id: String {
switch kind {
case .habit(let leak): return leak.id
case .seat(let position, _): return DrillTarget.seatPrefix + position.rawValue
}
}
public static let seatPrefix = "seat:"
public var leak: Leak? { if case .habit(let leak) = kind { return leak } else { return nil } }
public var seat: Position? { if case .seat(let position, _) = kind { return position } else { return nil } }
public var isSeat: Bool { seat != nil }
/// "Folding the big blind too often" or "Seat 4 of 9".
public var name: String {
switch kind {
case .habit(let leak): return leak.name
case .seat(let position, let size):
let phrase = position.seatPhrase(inTableOf: size)
return phrase.prefix(1).uppercased() + phrase.dropFirst()
}
}
/// The seat with its traditional name — "seat 4 of 9 (under the gun + 1)" — or the habit.
public var described: String {
switch kind {
case .habit(let leak): return leak.name
case .seat(let position, let size): return position.described(inTableOf: size)
}
}
public init(habit leak: Leak) {
kind = .habit(leak)
acceptableRate = leak.acceptableRate
minimumSample = leak.minimumSample
setups = DrillPlanner.setups(for: leak)
}
/// A seat drill: the player sits in this seat every hand. `leaning` names the habit the seat
/// is worst at, so the table is arranged to produce that habit's situations there; nil
/// arranges a mixed table.
public init(seat position: Position, tableSize: Int, leaning leak: Leak? = nil) {
kind = .seat(position, tableSize: tableSize)
acceptableRate = SeatDiagnosis.acceptableRate(for: position)
minimumSample = SeatDiagnosis.drillFloorDecisions
setups = [DrillTarget.seatSetup(position, leaning: leak)]
}
/// The arrangement for a seat drill. The lean comes from the habit the seat is worst at where
/// there is one — a seat that mostly folds to raises wants the raisers speaking first, a seat
/// that plays too many hands wants a quiet table it will be tempted at — and is mixed
/// otherwise. The depth is the ordinary hundred big blinds.
static func seatSetup(_ position: Position, leaning leak: Leak?) -> AssessmentSetup {
let lean: TableLean
if let leak, let pinned = AssessmentPlanner.setup(id: leak.drillSetup ?? "") {
lean = pinned.lean
} else if let leak, let likeliest = DrillPlanner.setups(for: leak.spot).first {
lean = likeliest.lean
} else {
lean = .mixed
}
// The seat yields whatever the deal brings from it; the planner only ever sees one
// arrangement here, so the priors are nominal.
return AssessmentSetup(id: DrillTarget.seatPrefix + position.rawValue, seat: position,
depthInBB: 100, lean: lean,
watching: "Every decision you make from this seat.",
expected: Dictionary(uniqueKeysWithValues: SpotKind.measured.map { ($0, 0.2) }))
}
/// Reads a target back off what a drill hand wrote down — a habit id or "seat:UTG+1".
public static func recorded(_ id: String, tableSize: Int) -> DrillTarget? {
if id.hasPrefix(seatPrefix), let position = Position(rawValue: String(id.dropFirst(seatPrefix.count))) {
return DrillTarget(seat: position, tableSize: tableSize)
}
return LeakCatalogue.named(id).map(DrillTarget.init(habit:))
}
}
public enum DrillRule {
/// The fewest chances a drill can ever clear on, whatever any individual habit's number says.
/// A rate off a handful of decisions is noise wearing a percentage sign.
public static let minimumChances = 12
/// Hands before a drill that is not getting anywhere stops rather than running for ever.
/// Stopping early costs nothing either way — the hands are in the history and the drill is
/// rebuilt from them — so this is a limit on one sitting rather than on the work.
public static let handLimit = 90
/// How many chances a re-test asks for before it will say anything at all.
public static let retestChances = 12
/// The fewest slips a re-test can be failed on. One bad decision is a bad decision.
public static let retestMistakes = 2
/// Whether a check's numbers say the fix is still there.
///
/// Judged on the same rate the report prints and the bar draws — the one with the imaginary
/// slip in it. Judging the raw rate and printing the smoothed one let a check come back "It
/// held up — 2 of 12 went wrong" beside "which makes it 21%, against the 18% a steady player
/// sits at", which is the app saying two contradictory things in one paragraph and the report
/// being the one that was telling the truth.
///
/// One slip is still forgiven outright, and that is the only place the answer and the printed
/// number are allowed to part company. Twelve chances cannot tell one bad decision from a habit
/// coming back, so failing a check on one would be reading noise — and the report says so in as
/// many words rather than leaving the reader to work out why 14% passed against an 8% bar.
public static func holdsUp(_ target: DrillTarget, chances: Int, mistakes: Int) -> Bool {
if mistakes < retestMistakes { return true }
return rate(chances: chances, mistakes: mistakes) <= target.acceptableRate
}
public static func holdsUp(_ leak: Leak, chances: Int, mistakes: Int) -> Bool {
holdsUp(DrillTarget(habit: leak), chances: chances, mistakes: mistakes)
}
/// Days between clearing a drill and being checked on it, and between each check after that.
/// It doubles because the question changes as it goes: the first check asks whether the fix
/// survived the week, the third asks whether it survived not thinking about it at all.
public static let firstGapInDays = 5.0
public static let longestGapInDays = 30.0
public static func gap(afterChecks checks: Int) -> TimeInterval {
let days = min(longestGapInDays, firstGapInDays * pow(2, Double(max(0, checks))))
return days * 24 * 60 * 60
}
/// How many chances a drill at this habit has to have had before it can clear at all.
///
/// Capped at the most chances a drill will ever ask for in one answer. Without the cap a habit
/// whose bar takes a lot of hands to measure — the turn habit takes 250 chances before it can
/// even be named, and that number was measured rather than chosen — asks a drill for more
/// chances than the drill is allowed to deal, and `chancesStillNeeded` then hands back a number
/// that does not clear. A drill that cannot answer at all is worse than one that answers on the
/// most it can gather and says how much that was, which the report already does.
///
/// The cap changes nothing for seventeen of the eighteen habits: every other number in the
/// catalogue is 25 or under.
public static func floor(for target: DrillTarget) -> Int {
min(mostChancesAskedFor, max(minimumChances, target.minimumSample))
}
public static func floor(for leak: Leak) -> Int { floor(for: DrillTarget(habit: leak)) }
/// The rate, counted with one imaginary slip added. It makes clearing harder, never easier,
/// which is the direction an all-clear should err in — and it stops nought out of twelve
/// reading as a certainty.
public static func rate(chances: Int, mistakes: Int) -> Double {
Double(mistakes + 1) / Double(chances + 2)
}
/// Whether this many chances, with this many slips among them, are enough to say the habit has
/// stopped.
public static func clears(_ target: DrillTarget, chances: Int, mistakes: Int) -> Bool {
guard chances >= floor(for: target) else { return false }
return rate(chances: chances, mistakes: mistakes) <= target.acceptableRate
}
public static func clears(_ leak: Leak, chances: Int, mistakes: Int) -> Bool {
clears(DrillTarget(habit: leak), chances: chances, mistakes: mistakes)
}
/// The most chances this will ever ask for in one answer. Reaching it means the habit cannot
/// be cleared from where the drill stands, which the screens already have a sentence for.
public static let mostChancesAskedFor = handLimit * 2
/// How many more chances it would take from here, if none of them went wrong.
///
/// Worked out rather than counted up to. `(mistakes + 1) / (chances + 2) <= acceptableRate`
/// rearranges to `chances >= (mistakes + 1) / acceptableRate - 2`, which is one line and cannot
/// spin. The loop this replaces terminated only because every rate in the catalogue today
/// happens to be 8% or more: a habit written with a rate of nought — a spot where a competent
/// player never gets it wrong, which is a reasonable thing for somebody to add — would have
/// hung the app inside a view body, because this is read while a drill banner is being drawn.
/// The cap is belt as well as braces, and it is what a rate of nought now answers.
public static func chancesStillNeeded(_ target: DrillTarget, chances: Int, mistakes: Int) -> Int {
let start = max(chances, floor(for: target))
guard target.acceptableRate > 0 else { return mostChancesAskedFor }
let solved = Double(mistakes + 1) / target.acceptableRate - 2
var total = min(mostChancesAskedFor, max(start, Int(solved.rounded(.up))))
// Arithmetic on doubles can land the answer one either side of the bar it was solved from,
// so it is walked back onto whatever `clears` says — that is the number that actually ends
// a drill, and the sentence on the screen has to be counting towards the same one. Both
// walks are bounded: down to the floor, up to the cap.
while total > start, clears(target, chances: total - 1, mistakes: mistakes) { total -= 1 }
while total < mostChancesAskedFor, !clears(target, chances: total, mistakes: mistakes) { total += 1 }
return max(0, total - chances)
}
public static func chancesStillNeeded(_ leak: Leak, chances: Int, mistakes: Int) -> Int {
chancesStillNeeded(DrillTarget(habit: leak), chances: chances, mistakes: mistakes)
}
}
/// Which way of arranging the table is most likely to hand the player the situation a leak lives
/// in.
///
/// It is the assessment's machinery pointed at one spot instead of at eleven, deliberately: the
/// setups already say roughly how often each of them produces each situation, and `SetupYield`
/// already knows that a setup which promises something and never delivers it should be dropped
/// after about three hands of trying. A drill needs exactly those two things.
public enum DrillPlanner {
/// The arrangements that can produce this situation at all, likeliest first.
public static func setups(for spot: SpotKind) -> [AssessmentSetup] {
AssessmentPlanner.setups
.filter { ($0.expected[spot] ?? 0) > 0 }
.sorted { a, b in
let (x, y) = (a.expected[spot] ?? 0, b.expected[spot] ?? 0)
return x != y ? x > y : a.id < b.id
}
}
/// The arrangements that can produce *this habit*, which is not always the same question.
///
/// Where a habit names the arrangement it needs, that is the only one on offer and the yield
/// below can never wander off it. It has to be that way round: the seat is what some of these
/// habits *are*. Not attacking the blinds and playing too many hands are both decisions about
/// an unopened pot, and they want opposite ends of the table — ranked on the situation alone,
/// the drill for the first of them sat the player first to speak, counted their correct folds
/// as clean chances, cleared in about twenty hands and scheduled a check that ran from the same
/// wrong seat. A false all-clear on a habit that was never once practised.
public static func setups(for leak: Leak) -> [AssessmentSetup] {
if let id = leak.drillSetup, let pinned = AssessmentPlanner.setup(id: id) { return [pinned] }
return setups(for: leak.spot)
}
/// How to set the table up for the next hand of a drill at this habit.
public static func next(for leak: Leak, yield: SetupYield = SetupYield()) -> AssessmentSetup {
best(of: setups(for: leak), for: leak.spot, yield: yield)
}
/// The same for any target. A seat has one arrangement and it is never dropped — the seat is
/// what the drill is.
public static func next(for target: DrillTarget, yield: SetupYield = SetupYield()) -> AssessmentSetup {
switch target.kind {
case .habit(let leak): return next(for: leak, yield: yield)
case .seat: return target.setups[0]
}
}
/// How to set the table up for the next hand of a drill: whichever arrangement has actually
/// been producing the situation, which starts as whichever one promises it most.
public static func next(for spot: SpotKind, yield: SetupYield = SetupYield()) -> AssessmentSetup {
best(of: setups(for: spot), for: spot, yield: yield)
}
private static func best(of candidates: [AssessmentSetup], for spot: SpotKind,
yield: SetupYield) -> AssessmentSetup {
guard var best = candidates.first else { return AssessmentPlanner.setups[0] }
var bestValue = yield.expected(spot, of: best)
for setup in candidates.dropFirst() {
let value = yield.expected(spot, of: setup)
if value > bestValue + 1e-9 {
best = setup
bestValue = value
}
}
return best
}
}
/// One sitting of drill hands, all of them aimed at the same habit.
///
/// Like the assessment, it holds no hands of its own: `resuming(from:)` replays what was written
/// down and arrives at exactly the state it was left in, so stopping half way costs nothing and
/// there is no second copy of the progress to fall out of step with the history.
public struct DrillRun: Sendable, Equatable {
public let target: DrillTarget
/// The habit, where this drill is at one. A seat drill has none.
public var leak: Leak? { target.leak }
/// A re-test is the same machinery days later, judged on a fixed number of chances rather than
/// on however long it takes to be sure. See `DrillBook`.
public let isRetest: Bool
public private(set) var handsPlayed = 0
/// Times the player has been in the situation this habit lives in.
public private(set) var chances = 0
/// How many of those went wrong the way this habit goes wrong.
public private(set) var mistakes = 0
public private(set) var yield = SetupYield()
/// How the table is being arranged for the hand about to be dealt.
public private(set) var setup: AssessmentSetup
/// When the habit was shown to have stopped, and nil for as long as it has not been.
public private(set) var clearedAt: Date?
/// A re-test only: when it had enough chances to answer.
public private(set) var settledAt: Date?
/// A re-test only: whether the fix was still there.
public private(set) var heldUp = false
public init(leak: Leak, retest: Bool = false) {
self.init(target: DrillTarget(habit: leak), retest: retest)
}
public init(target: DrillTarget, retest: Bool = false) {
self.target = target
self.isRetest = retest
self.setup = DrillPlanner.next(for: target)
}
/// The rate, with the imaginary slip in it: the one the rules judge, the one the report prints
/// and the one the bar draws.
///
/// There is deliberately no raw rate on this type any more. The raw rate is what the drill was
/// careful not to believe — nought out of twelve is not nought per cent of the time — and while
/// it existed as a property, the check quietly judged itself on it while the screen printed
/// this one, so a check could pass and print a number above the bar in the same breath. A
/// second version of a measurement is a second version of the answer.
public var countedRate: Double { DrillRule.rate(chances: chances, mistakes: mistakes) }
public var countedPercent: Int { Int((countedRate * 100).rounded()) }
public var acceptablePercent: Int { Int((target.acceptableRate * 100).rounded()) }
public var cleared: Bool { clearedAt != nil }
public var passed: Bool { isRetest && settledAt != nil && heldUp }
public var failed: Bool { isRetest && settledAt != nil && !heldUp }
public var ranOut: Bool { handsPlayed >= DrillRule.handLimit }
public var isFinished: Bool { cleared || settledAt != nil || ranOut }
/// How many more clean chances would finish it, if the next few all go right.
public var chancesStillNeeded: Int {
isRetest
? max(0, DrillRule.retestChances - chances)
: DrillRule.chancesStillNeeded(target, chances: chances, mistakes: mistakes)
}
public var handsLeft: Int { max(0, DrillRule.handLimit - handsPlayed) }
/// Whether the finish is still inside this sitting, going by how often a hand here has
/// actually been producing the situation. Once it is not, the drill says so rather than
/// dangling a finish line nobody can reach today.
public var reachableToday: Bool { reachable(chancesStillNeeded) }
/// The same arithmetic with the slips taken out.
///
/// `reachableToday` fails for two quite different reasons — too many have gone wrong, or the
/// situation simply is not coming up often enough per hand — and the banner used to blame the
/// player for both. On hand one of a drill at a habit that takes 250 chances, with nothing
/// played and nothing wrong, it read "Too many have gone wrong for this sitting to clear it".
/// This is how the two are told apart: if a spotless record would not have reached the finish
/// either, the deal is what is in the way and the player's decisions are not.
public var reachableWithNothingWrong: Bool {
reachable(isRetest ? max(0, DrillRule.retestChances - chances)
: DrillRule.chancesStillNeeded(target, chances: chances, mistakes: 0))
}
private func reachable(_ needed: Int) -> Bool {
let perHand = handsPlayed > 0 ? Double(chances) / Double(handsPlayed) : 0.5
return Double(needed) <= Double(handsLeft) * max(0.1, perHand)
}
/// Folds a finished hand in and picks how to arrange the next one.
///
/// A chance is every decision in the situation, and not only the ones where this particular
/// habit was on offer. That reads like an over-count and it is one — an unopened pot on the
/// button cannot produce "passed up a raise" unless the hand was worth raising, so about two in
/// three of the chances counted for that habit never offered it. The reason it stays is that
/// `acceptableRate` and `minimumSample` are defined over exactly this denominator: the coach
/// counts opportunities the same wide way when it names a habit in the first place, and a drill
/// that measured a narrower rate against the wider bar would be comparing two different
/// numbers.
///
/// That is not a guess. Narrowing the denominator here and leaving the bars alone was measured
/// over six seeds (11, 2020, 30303, 404040, 77, 512) of the full 90-hand big blind drill. A
/// sound player's counted rate ran 12–21% against the 18% bar as it stands, and 29–43% against
/// the same bar once the denominator was narrowed; sittings cleared went from 4 of 6 to 0 of 6.
/// The drill became unfinishable rather than demanding, which teaches as little as one
/// everybody finishes. Narrowing it properly means re-deriving both numbers for all eighteen
/// habits from a sweep, which is a measurement project and not a tidy-up.
public mutating func observe(_ record: HandRecord) {
handsPlayed += 1
var spots: [SpotKind] = []
for verdict in record.verdicts {
guard let spot = verdict.spotKind else { continue }
spots.append(spot)
switch target.kind {
case .habit(let leak):
guard spot == leak.spot else { continue }
// Every decision in the situation is a chance, not only the ones where this
// particular mistake was on offer. Deliberate, measured, and explained above.
chances += 1
if verdict.countsTowardsLeak, verdict.errorKind == leak.error { mistakes += 1 }
case .seat:
// A seat drill's chance is any decision the coach looked at from the seat, and
// its mistake any it counted — the same numbers the diagnosis rates the seat on.
chances += 1
if verdict.countsTowardsLeak { mistakes += 1 }
}
}
yield.saw(record.setup ?? setup.id, spots: spots)
if isRetest {
// A fixed number of chances and then an answer. A re-test that is allowed to run until
// it likes the result is not a test.
if settledAt == nil, chances >= DrillRule.retestChances {
settledAt = record.playedAt
heldUp = DrillRule.holdsUp(target, chances: chances, mistakes: mistakes)
}
} else if clearedAt == nil,
DrillRule.clears(target, chances: chances, mistakes: mistakes) {
clearedAt = record.playedAt
}
setup = DrillPlanner.next(for: target, yield: yield)
}
/// Picks a half-finished drill back up off the hands already written down.
public static func resuming(from records: [HandRecord], leak: Leak,
retest: Bool = false) -> DrillRun {
resuming(from: records, target: DrillTarget(habit: leak), retest: retest)
}
public static func resuming(from records: [HandRecord], target: DrillTarget,
retest: Bool = false) -> DrillRun {
var run = DrillRun(target: target, retest: retest)
for record in records.sorted(by: { $0.handNumber < $1.handNumber }) { run.observe(record) }
return run
}
// MARK: - Saying it out loud
/// What sits above the felt while it runs.
public var progressLine: String {
if isRetest {
return "Checking back — \(min(chances, DrillRule.retestChances)) of "
+ "\(DrillRule.retestChances) chances"
}
guard chances > 0 else { return "Waiting for the situation to come up" }
return "\(chances) \(chances == 1 ? "chance" : "chances") so far, \(mistakes) went wrong"
}
/// How near the end it is, for a bar on the screen.
public var fraction: Double {
if isRetest {
return min(1, Double(chances) / Double(DrillRule.retestChances))
}
let togo = chancesStillNeeded
guard togo > 0 else { return 1 }
return min(1, Double(chances) / Double(chances + togo))
}
/// What this sitting is for, in the player's own words.
public var purposeLine: String {
if isRetest {
return target.isSeat
? "You cleared this seat. This is the same seat again, to see whether the fix is "
+ "still there now you have stopped thinking about it."
: "You cleared this one. This is the same situation again, to see whether the fix is "
+ "still there now you have stopped thinking about it."
}
return target.isSeat
? "Every hand here sits you in \(target.described), over and over, until the decisions "
+ "you make from there go wrong no more often than a steady player's do."
: "Every hand here is arranged to put you in the one situation this habit lives in, "
+ "over and over, until you have got it right often enough for that to mean "
+ "something."
}
/// What it would take to finish from here, or an admission that it will not finish today.
public var targetLine: String {
if isRetest {
let left = max(0, DrillRule.retestChances - chances)
return left == 0
? "That is enough to answer."
: "\(left) more \(left == 1 ? "chance" : "chances") and this has its answer."
}
let togo = chancesStillNeeded
if togo == 0 { return "That is enough." }
guard reachableToday else {
// Which of the two is in the way decides the sentence. Blaming the deal on somebody's
// decisions is a plain untruth, and it is the one this line kept telling: on hand one
// of a drill at a habit needing hundreds of chances, nothing had gone wrong at all.
guard reachableWithNothingWrong else {
guard handsPlayed > 0 else {
return "This one takes more chances at the situation than \(DrillRule.handLimit) "
+ "hands here are likely to produce, so it will not finish today whatever "
+ "you do. Every chance you get counts towards it, and it picks up where "
+ "you left it."
}
return "The situation is not coming up often enough to finish today — \(chances) "
+ "\(chances == 1 ? "chance" : "chances") in \(handsPlayed) hands so far. That "
+ "is the deal rather than anything you have done. Play it out; the chances "
+ "you do get all count, and it picks up where you left it."
}
return "Too many have gone wrong for this sitting to clear it. Play it out, then come "
+ "back to it another day — nothing you have done here is lost."
}
return "\(togo) more \(togo == 1 ? "chance" : "chances") like the last few and this is "
+ "done. Get one wrong and it takes a few more, which is the point of it."
}
/// Whether the sitting gathered too few chances to say anything about the habit either way.
///
/// A drill sitting is ninety hands, and how many of those hands actually reach the situation is
/// up to the deal. Measured over six seeds of the full ninety, the turn drill produced five to
/// thirteen turn decisions — against a habit that takes far more than that before a rate means
/// anything. So the ending has to have a third answer in it. Reporting "it is still happening"
/// off six chances is the same overclaim as clearing on six would be, pointed the other way.
public var tooFewChances: Bool { !cleared && !isRetest && chances < DrillRule.floor(for: target) }
/// The heading on the report at the end.
public var outcomeTitle: String {
if passed { return "It held up" }
if failed { return "It came back" }
if cleared { return "Cleared" }
if tooFewChances { return "Not enough to say" }
return isRetest ? "Not enough to say" : "Not cleared"
}
/// What actually happened, with the numbers behind it.
public var outcomeLine: String {
let record = "\(mistakes) of \(chances) went wrong. One slip is always added to that count "
+ "before it is judged, so a clean run can never come out at nought — which makes it "
+ "\(countedPercent)%, against the \(acceptablePercent)% it has to be at or under."
if passed {
// Which sentence is owed depends on which of the two things carried it. A check that
// came in under the bar and a check that went over it and was forgiven one slip are
// not the same result, and saying the same words about both is how the number beside
// them starts contradicting them.
let why = mistakes < DrillRule.retestMistakes
? "One slip does not fail a check, because twelve chances cannot tell one bad "
+ "decision from a habit coming back."
: "That is at or under where a steady player sits."
return "\(record) \(why) The habit has not come back, so I will leave it a while "
+ "longer and check once more."
}
if failed {
return "\(record) That is where it was before you drilled it, so it goes back on your "
+ "list rather than being quietly forgotten."
}
if cleared {
// Two of the eighteen habits clear on the bare twelve-chance floor, and twelve
// decisions are the point at which a number stops being noise rather than the point at
// which it settles anything. Saying otherwise here would be the easiest overclaim in
// the app to make, on the one screen that hands out good news.
let sample = chances <= DrillRule.floor(for: target)
? "That is the fewest chances this one can clear on"
: "That is past the fewest chances this one can clear on"
return "\(record) \(sample), so it is a good sign rather than a finished job — which "
+ "is why the next word on it comes from the check in a few days rather than from "
+ "tonight."
}
if isRetest {
return "\(chances) \(chances == 1 ? "chance" : "chances") is not enough to check "
+ "anything. The check is still owed, and it is waiting for you."
}
if tooFewChances {
let had = "\(handsPlayed) hands here produced \(chances) "
+ "\(chances == 1 ? "chance" : "chances") at the situation"
return "\(had), and this \(target.isSeat ? "seat" : "habit") takes \(DrillRule.floor(for: target)) before a rate off "
+ "them means anything. So this sitting has not settled it either way — what you "
+ "did here is written down, and the fair way to measure this one is at a table."
}
return "\(record) \(handsPlayed) hands and it is still happening, so this one is not "
+ "fixed. That is worth knowing — a drill that reported a fix here would be lying to "
+ "you about the only thing it is for."
}
}
/// Every drill the player has ever done, and what is owed on each of them.
///
/// Where it is kept is the same answer as the ladder and the assessment: nowhere. Every drill hand
/// records which habit it was drilling and whether it was a drill or a check, and the whole of this
/// is counted back off those hands. There is no second file holding a date that could disagree with
/// what was actually played, which is what makes a re-test scheduled for next Tuesday survive the
/// app being closed — the evidence for it survives, so it does.
///
/// The clock is passed in rather than read, so the whole of the scheduling can be tested in a
/// second instead of over a fortnight.
public struct DrillBook: Sendable, Equatable {
/// Where one habit stands.
public enum Stage: String, Sendable, Equatable {
/// Never drilled.
case notStarted
/// Started, walked away from, and still there to carry on with.
case running
/// Drilled all the way and it did not clear.
case stalled
/// Cleared, and not yet due to be checked.
case holding
/// Cleared, and the check is owed now.
case due
/// Checked, and the habit was back.
case reopened
}
public struct Standing: Sendable, Equatable, Identifiable {
public let target: DrillTarget
/// The habit, for a drill at one. Nil for a seat drill.
public var leak: Leak? { target.leak }
public let stage: Stage
/// The last sitting's numbers — chances in the situation, and how many went wrong.
public let chances: Int
public let mistakes: Int
public let hands: Int
/// When the habit was last shown to have stopped.
public let clearedOn: Date?
/// When it was last checked and held up. The clear itself counts as the first check.
public let checkedOn: Date?
public let checksPassed: Int
/// When the next check is owed.
public let dueOn: Date?
/// The sitting to carry on with, where one was left half finished.
public let sessionID: String?
/// When this habit was last drilled or checked. Nil only where it never has been.
public let lastPlayedAt: Date?
public var id: String { target.id }
public var isDue: Bool { stage == .due }
public var isReopened: Bool { stage == .reopened }
/// True while the habit is being treated as fixed.
public var isHeld: Bool { stage == .holding || stage == .due }
public var percent: Int { chances > 0 ? Int((Double(mistakes) / Double(chances) * 100).rounded()) : 0 }
/// Whole days from a moment to the next check, negative once it is overdue.
public func days(until date: Date, from now: Date) -> Int {
Int((date.timeIntervalSince(now) / (24 * 60 * 60)).rounded(.up))
}
/// The short badge on a scorecard row.
public func badge(now: Date) -> String {
switch stage {
case .notStarted: return ""
case .running: return "drill half done"
case .stalled:
// A sitting that never produced the situation measured nothing, and the badge on
// the row has to say which of the two happened.
return chances < DrillRule.floor(for: target)
? "drilled, not measured"
: "drilled, not fixed"
case .holding:
guard let dueOn else { return "drilled" }
let left = max(0, days(until: dueOn, from: now))
return left <= 1 ? "checking tomorrow" : "checking in \(left) days"
case .due: return "check this again"
case .reopened: return "came back"
}
}
/// The sentence under a scorecard row, and the one on the home screen.
public func line(now: Date) -> String {
func when(_ date: Date?) -> String {
date.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "recently"
}
switch stage {
case .notStarted:
return ""
case .running:
return "You started drilling this and stopped part way. \(chances) "
+ "\(chances == 1 ? "chance" : "chances") in, \(mistakes) went wrong. It picks "
+ "up where you left it."
case .stalled:
// Two different endings wear this stage. One is a habit that kept happening; the
// other is a sitting where the situation barely came up, and those hands measure
// nothing at all. Saying "it is not fixed" off six chances would be an accusation
// dressed as a measurement.
guard chances >= DrillRule.floor(for: target) else {
return "You drilled this for \(hands) hands and the situation only came up "
+ "\(chances) \(chances == 1 ? "time" : "times") — this \(target.isSeat ? "seat" : "habit") takes "
+ "\(DrillRule.floor(for: target)) before a rate off them means anything. "
+ "That sitting settled nothing either way."
}
return "You drilled this for \(hands) hands and it did not clear — \(mistakes) of "
+ "\(chances) still went wrong. It is not fixed, and saying otherwise would be "
+ "the easiest lie in this app to tell."
case .holding:
let left = dueOn.map { max(0, days(until: $0, from: now)) } ?? 0
let gap = left <= 1 ? "tomorrow" : "in \(left) days"
return "Drilled and cleared on \(when(clearedOn)). I will put you back in the same "
+ "situation \(gap) to see whether it stuck."
case .due:
let owed = "You cleared this on \(when(clearedOn)) and it is time to check it "
+ "stuck. A habit you held off for one evening while you were watching for it "
+ "is not the same as one you have stopped."
// A check that was started and walked away from starts again, and the screen says
// so rather than quietly re-asking the chances already played. The rule stays as it
// is on purpose: a check you could restart after three bad answers would be a check
// anybody can pass by quitting, and the whole point of it is that it cannot be
// steered. Carrying a half-finished drill on is a different thing and is still
// offered — a drill is practice, and practice is meant to be picked back up.
guard sessionID != nil else { return owed }
let far: String
switch chances {
case 0: far = "before it had asked you anything"
case 1: far = "one chance in"
default: far = "\(chances) chances in"
}
return owed + " You started this check and stopped \(far). It begins again from "
+ "the top: a check you can restart after a bad start would measure nothing, "
+ "so the chances you played are kept in your history and not counted towards "
+ "this one."
case .reopened:
return "You cleared this on \(when(clearedOn)), and when I checked back on "
+ "\(when(checkedOn)) it had come back — \(mistakes) of \(chances) went wrong "
+ "again. So it is on your list again rather than quietly forgotten."
}
}
}
/// The moment this was worked out for. Everything that depends on the date is answered against
/// it rather than against the clock, so a test can move a fortnight in a line.
public let now: Date
private let standings: [String: Standing]
public static let empty = DrillBook(of: [], now: Date(timeIntervalSince1970: 0))
public init(of records: [HandRecord], now: Date = Date()) {
self.now = now
// One sitting is one attempt — by which sitting it was, not by which hands happen to sit
// next to each other in the file. Grouping by adjacency split a drill that was restarted
// and then picked back up into three attempts, the last of them holding only the hands
// played after the restart, so the book reported fewer chances than `resumeDrill` was
// about to load off the same sitting.
var byLeak: [String: [(session: String, records: [HandRecord])]] = [:]
var attemptAt: [String: [String: Int]] = [:]
for record in records {
guard record.isDrill || record.isRetest, let id = record.drill else { continue }
var attempts = byLeak[id] ?? []
if let at = attemptAt[id]?[record.sessionID] {
attempts[at].records.append(record)
} else {
attemptAt[id, default: [:]][record.sessionID] = attempts.count
attempts.append((record.sessionID, [record]))
}
byLeak[id] = attempts
}
var built: [String: Standing] = [:]
for (id, unordered) in byLeak {
// Oldest attempt first, by when it was last played rather than when it was started:
// the sitting the player was in most recently is the one they are in the middle of,
// and it is the one the state below has to end on.
let attempts = unordered.sorted {
($0.records.last?.playedAt ?? .distantPast)
< ($1.records.last?.playedAt ?? .distantPast)
}
// A habit this build no longer has takes its drills with it, rather than being filed
// against whichever one now sits closest. A seat is read back with the size of table
// it was drilled at.
let size = unordered.first?.records.first?.seatsAtTable ?? 9
guard let target = DrillTarget.recorded(id, tableSize: size) else { continue }
var stage = Stage.notStarted
var clearedOn: Date?
var checkedOn: Date?
var checks = 0
var chances = 0, mistakes = 0, hands = 0
var openSession: String?
for attempt in attempts {
let retest = attempt.records.contains(where: \.isRetest)
let run = DrillRun.resuming(from: attempt.records, target: target, retest: retest)
chances = run.chances
mistakes = run.mistakes
hands = run.handsPlayed
openSession = nil
if retest {
if run.passed {
checks += 1
checkedOn = run.settledAt
stage = .holding
} else if run.failed {
stage = .reopened
checkedOn = run.settledAt
checks = 0
} else {
// Walked away before it could say anything. The check is still owed, and
// an unfinished check is not a passed one.
openSession = attempt.session
if clearedOn != nil { stage = .due }
}
} else if run.cleared {
clearedOn = run.clearedAt
checkedOn = run.clearedAt
checks = 0
stage = .holding
} else if run.ranOut {
stage = .stalled
clearedOn = nil; checkedOn = nil; checks = 0
} else {
stage = .running
openSession = attempt.session
clearedOn = nil; checkedOn = nil; checks = 0
}
}
var dueOn: Date?
if clearedOn != nil, let checkedOn {
let due = checkedOn.addingTimeInterval(DrillRule.gap(afterChecks: checks))
dueOn = due
if stage == .holding, now >= due { stage = .due }
}
built[id] = Standing(target: target, stage: stage, chances: chances, mistakes: mistakes,
hands: hands, clearedOn: clearedOn, checkedOn: checkedOn,
checksPassed: checks, dueOn: dueOn, sessionID: openSession,
lastPlayedAt: attempts.last?.records.map(\.playedAt).max())
}
standings = built
}
/// Where a habit — or a seat, by "seat:UTG+1" — stands, or nil where it has never been drilled.
public func standing(of targetID: String) -> Standing? { standings[targetID] }
public func standing(ofSeat position: Position) -> Standing? {
standings[DrillTarget.seatPrefix + position.rawValue]
}
/// The seat drills, in seat order.
public var seatDrills: [Standing] { all.filter { $0.target.isSeat } }
public var all: [Standing] {
standings.values.sorted { $0.target.id < $1.target.id }
}
/// The checks that are owed, longest overdue first. This is what the home screen shows.
public var dueNow: [Standing] {
all.filter { $0.stage == .due }
.sorted { ($0.dueOn ?? now) < ($1.dueOn ?? now) }
}
/// A drill or a check that was left half finished, if there is one to offer.
///
/// The most recently played one, which is the one the player walked away from. `all` is sorted
/// by habit id so that everything built on it comes out in the same order every launch, and
/// taking the first half-done drill off that list offered whichever habit happened to sort
/// earliest in the alphabet — so somebody who stopped half way through the limping drill was
/// offered "carry on" on a big blind drill they had abandoned last month.
public var unfinished: Standing? {
all.filter { $0.sessionID != nil && $0.stage == .running }
.max { ($0.lastPlayedAt ?? .distantPast) < ($1.lastPlayedAt ?? .distantPast) }
}
/// Habits that were drilled, cleared, and then came back. They are on the scorecard again.
public var reopened: [Standing] { all.filter(\.isReopened) }
}