Advertisement

← poker-tutor

Sources/PokerSession/Drills.swift

47.6 KB Download file
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) }
}