← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit
/// The seat mode: which seat the player loses most from, and the drill that sits them there.
///
/// Ken, 2026-09-11: "a mode that diagnoses which position they play the worst in and why and let
/// them drill while playing only that position." The rule the whole thing rests on is the same as
/// every other bar here: a flawless player's seats differ by chance, so a seat is named only past
/// the point where the two populations separate, and that point was measured rather than chosen.
private let epoch = Date(timeIntervalSince1970: 1_790_000_000)
/// A dealt hand from one seat with one decision the coach looked at, counted as a mistake or not.
private func seatHand(_ number: Int, seat: Position, mistake: Bool, cost: Double = 2.0,
spot: SpotKind = .openingChance, error: ErrorKind = .playedTooManyHands,
kind: String? = nil, drill: String? = nil, session: String = "s1") -> HandRecord {
let verdict = VerdictRecord(street: "preflop", spot: spot.name,
leak: mistake ? error.name : "",
severity: mistake ? 3 : 0, costInBB: mistake ? cost : 0,
headline: "h", detail: "d", taken: "raise", best: "fold",
spoke: mistake, confidence: 0.9)
return HandRecord(sessionID: session, sessionStarted: epoch,
playedAt: epoch.addingTimeInterval(Double(number) * 60),
handNumber: number, mode: "Tournament", bigBlind: 100,
position: seat.rawValue, holeCards: "Ah Kd", board: "",
netChips: mistake ? -200 : 50, wentToShowdown: false, sawFlop: false,
putMoneyInPreflop: true, raisedPreflop: mistake, verdicts: [verdict],
tableSize: 9, kind: kind, drill: drill)
}
/// `hands` hands from `seat`, `wrong` of them counted against the player.
private func seatRun(_ seat: Position, hands: Int, wrong: Int, from: Int = 1,
kind: String? = nil, drill: String? = nil) -> [HandRecord] {
(0..<hands).map { i in seatHand(from + i, seat: seat, mistake: i < wrong, kind: kind, drill: drill) }
}
/// A full table of dealt hands where every seat runs at `rate`, except `badSeat` at `badRate`.
private func table(rate: Double, badSeat: Position? = nil, badRate: Double = 0,
handsPerSeat: Int) -> [HandRecord] {
var out: [HandRecord] = []
var number = 1
for seat in Position.names(forPlayerCount: 9) {
let r = seat == badSeat ? badRate : rate
out += seatRun(seat, hands: handsPerSeat, wrong: Int((Double(handsPerSeat) * r).rounded()), from: number)
number += handsPerSeat
}
return out
}
func seatModeTests(_ t: TestRun) {
t.suite("which seat you lose most from") { t in
t.test("a seat far worse than the rest is named, with its habit leading") {
let hands = table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200)
let diagnosis = SeatDiagnosis(of: hands)
t.equal(diagnosis.worst?.position, "UTG+1", "expected seat 4 of 9 to be named")
t.equal(diagnosis.worst?.leading.first?.leak.id, "too-many-hands")
t.expect(diagnosis.headline?.contains("seat 4 of 9") == true, "\(diagnosis.headline ?? "nil")")
t.expect(diagnosis.headline?.contains("playing too many hands") == true, "\(diagnosis.headline ?? "nil")")
t.equal(diagnosis.reason, nil)
}
t.test("seats that differ by chance are not told apart") {
// Nine seats all within a few points of each other: the control's band.
var hands = table(rate: 0.10, handsPerSeat: 200)
// Nudge one seat up by less than the margin.
hands += seatRun(.cutoff, hands: 40, wrong: 12, from: 5_000)
let diagnosis = SeatDiagnosis(of: hands)
t.equal(diagnosis.worst, nil, "named \(diagnosis.worst?.position ?? "") on a gap inside the control's band")
t.expect(diagnosis.reason?.contains("chance") == true, "\(diagnosis.reason ?? "nil")")
}
t.test("too few decisions at any seat is said as such, with the number") {
let hands = table(rate: 0.10, badSeat: .button, badRate: 0.5, handsPerSeat: 30)
let diagnosis = SeatDiagnosis(of: hands)
t.equal(diagnosis.worst, nil, "named a seat off 30 hands")
t.expect(diagnosis.reason?.contains("Not enough hands") == true, "\(diagnosis.reason ?? "nil")")
t.expect(diagnosis.reason?.contains("more at") == true, "\(diagnosis.reason ?? "nil")")
}
t.test("arranged hands say nothing about a seat") {
// A big blind drill would make the big blind look like the worst seat in the world.
var hands = table(rate: 0.10, handsPerSeat: 200)
hands += seatRun(.bigBlind, hands: 300, wrong: 200, from: 9_000, kind: HandRecord.drillKind, drill: "bb-overfold")
hands += seatRun(.bigBlind, hands: 40, wrong: 30, from: 9_500, kind: HandRecord.assessmentKind)
let diagnosis = SeatDiagnosis(of: hands)
t.equal(diagnosis.worst, nil)
t.equal(diagnosis.report(for: .bigBlind)?.hands, 200, "arranged hands were counted at the seat")
}
t.test("a seat is ranked by how often its decisions go wrong, not by one expensive verdict") {
var hands = table(rate: 0.10, handsPerSeat: 200)
// One monstrous verdict at the lojack: 300 big blinds in one decision.
hands += [seatHand(20_000, seat: .lojack, mistake: true, cost: 300)]
let diagnosis = SeatDiagnosis(of: hands)
t.equal(diagnosis.worst, nil, "one priced verdict made a seat the worst")
// And the cost the report shows is capped at what one decision may cost.
let lojack = diagnosis.report(for: .lojack)!
t.lessThan(lojack.costInBB, 200 * 0.1 * 2.0 + DecisionEvaluator.mostOneDecisionCosts + 0.01)
}
t.test("the bars are where the sweep put them") {
// The comment on `minimumHands` is the record; this pins the numbers the record
// supports so nobody moves one without re-sweeping.
t.equal(SeatDiagnosis.minimumDecisions, 150)
t.equal(SeatDiagnosis.minimumHands, 60)
t.close(SeatDiagnosis.requiredMargin, 0.15, 1e-9)
t.greaterThan(SeatDiagnosis.bigBlindAcceptableRate, SeatDiagnosis.acceptableRate)
}
t.test("at the app's own table a flawless player is never named and the planted seat is") {
// The cheap version of `pokerlab seat-sweep`: two seeds, 2,250 hands. The full run —
// 8 seeds × 6,300, both rungs — is recorded on `SeatDiagnosis.minimumHands`: the
// control 0/60 reads, seat 4 named 6/8 at 1,350 hands and 8/8 from 2,250.
for seed in [UInt64(11), 988] {
let control = runPlantedTournament(.control, hands: 2_250, seed: seed)
let clean = SeatDiagnosis(of: control.history.hands(in: .allTime))
t.equal(clean.worst, nil, "seed \(seed): the control was told it loses most from \(clean.worst?.seatPhrase ?? "")")
let planted = runPlantedTournament(.badInSeat(.underTheGunPlus1), hands: 2_250, seed: seed)
let found = SeatDiagnosis(of: planted.history.hands(in: .allTime))
t.equal(found.worst?.position, "UTG+1", "seed \(seed): the planted seat was not found (\(found.reason ?? "named \(found.worst?.position ?? "")"))")
t.equal(found.worst?.leading.first?.leak.id, "too-many-hands", "seed \(seed)")
}
}
}
t.suite("drilling a seat") { t in
t.test("every hand of a seat drill is dealt from that seat") {
for seat in [Position.underTheGunPlus1, .lojack, .bigBlind] {
let session = TableSession(seed: 31, history: .inMemory())
session.startSeatDrill(seat)
driveDrill(session, player: .control, seed: 5, stoppingAfter: 20)
let played = session.history.hands(in: .allTime)
t.greaterThan(played.count, 15, "\(seat): the drill did not deal")
t.expect(played.allSatisfy { $0.position == seat.rawValue },
"\(seat): dealt from \(Set(played.map(\.position)))")
t.expect(played.allSatisfy { $0.isDrill }, "drill hands were not written down as drill hands")
t.expect(played.allSatisfy { $0.drill == "seat:" + seat.rawValue },
"the hands do not say which seat they drilled: \(Set(played.compactMap(\.drill)))")
}
}
t.test("a seat drill counts every decision from the seat, and clears on the seat's bar") {
let target = DrillTarget(seat: .cutoff, tableSize: 9)
t.equal(target.id, "seat:CO")
t.equal(target.name, "Seat 8 of 9")
t.close(target.acceptableRate, SeatDiagnosis.acceptableRate, 1e-9)
// 60 decisions, 5 wrong: (5+1)/(60+2) = 9.7%, under the 15% bar, at the floor.
var run = DrillRun(target: target)
for record in seatRun(.cutoff, hands: 60, wrong: 5, kind: HandRecord.drillKind, drill: target.id) {
run.observe(record)
}
t.equal(run.chances, 60)
t.equal(run.mistakes, 5)
t.expect(run.cleared, "did not clear at the floor under the bar")
// 59 decisions with the same record does not: the floor is the floor.
var short = DrillRun(target: target)
for record in seatRun(.cutoff, hands: 59, wrong: 5, kind: HandRecord.drillKind, drill: target.id) {
short.observe(record)
}
t.expect(!short.cleared, "cleared under the floor")
// 60 decisions, 12 wrong: (12+1)/62 = 21% — over the bar.
var bad = DrillRun(target: target)
for record in seatRun(.cutoff, hands: 60, wrong: 12, kind: HandRecord.drillKind, drill: target.id) {
bad.observe(record)
}
t.expect(!bad.cleared, "cleared over the bar")
t.equal(bad.acceptablePercent, 15)
}
t.test("the big blind is judged on its own, higher bar") {
let target = DrillTarget(seat: .bigBlind, tableSize: 9)
t.close(target.acceptableRate, SeatDiagnosis.bigBlindAcceptableRate, 1e-9)
}
t.test("a seat drill is written down, read back, scheduled and checked like any other") {
let target = DrillTarget(seat: .hijack, tableSize: 9)
let cleared = seatRun(.hijack, hands: 60, wrong: 4, kind: HandRecord.drillKind, drill: target.id)
let book = DrillBook(of: cleared, now: epoch.addingTimeInterval(60 * 60))
let standing = book.standing(ofSeat: .hijack)
t.equal(standing?.stage, .holding, "a cleared seat drill is not held")
t.equal(standing?.target.name, "Seat 7 of 9")
t.expect(book.seatDrills.count == 1, "the seat drill is not listed among the seat drills")
// Five days on, the check is due; a check that holds keeps it held.
let later = DrillBook(of: cleared, now: epoch.addingTimeInterval(6 * 24 * 60 * 60))
t.equal(later.standing(ofSeat: .hijack)?.stage, .due)
let check = seatRun(.hijack, hands: 12, wrong: 0, from: 200, kind: HandRecord.retestKind, drill: target.id)
.map { r in
var r = r; r.sessionID = "check"; r.sessionStarted = epoch.addingTimeInterval(6 * 24 * 60 * 60); return r
}
let checked = DrillBook(of: cleared + check, now: epoch.addingTimeInterval(7 * 24 * 60 * 60))
t.equal(checked.standing(ofSeat: .hijack)?.stage, .holding, "a passed check did not hold the seat")
t.equal(checked.standing(ofSeat: .hijack)?.checksPassed, 1)
}
t.test("an old-format drill hand and a habit drill still read back beside a seat drill") {
let habit = LeakCatalogue.named("bb-overfold")!
let mixed = drilled(habit, hands: 12, wrong: 1, session: "old")
+ seatRun(.button, hands: 12, wrong: 1, from: 100, kind: HandRecord.drillKind, drill: "seat:BTN")
let book = DrillBook(of: mixed, now: epoch)
t.expect(book.standing(of: habit.id) != nil, "the habit drill vanished")
t.expect(book.standing(ofSeat: .button) != nil, "the seat drill vanished")
// A seat name this build does not know is dropped, not filed against another seat.
let unknown = seatRun(.button, hands: 12, wrong: 1, from: 300, kind: HandRecord.drillKind, drill: "seat:NOPE")
t.equal(DrillBook(of: unknown, now: epoch).all.count, 0)
}
t.test("the drill's own words say it is a seat") {
let run = DrillRun(target: DrillTarget(seat: .underTheGunPlus1, tableSize: 9))
t.expect(run.purposeLine.contains("seat 4 of 9"), run.purposeLine)
t.expect(!run.purposeLine.contains("habit"), run.purposeLine)
}
}
t.suite("the seat mode on the screens") { t in
t.test("a subscriber may drill any seat; a free player only the seat their habit leads at") {
MainActor.assumeIsolated {
let session = TableSession(seed: 44, history: .inMemory())
session.pacedByCaller = true
// A game where seat 4 is the bad seat and the free habit is the one it leads at.
for record in table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200) {
session.history.append(record)
}
let state = AppState(session: session)
state.dismissSheet()
let tally = TrialTally(store: InMemoryTally(), key: "seat.test")
state.charge(for: tally, through: .stub(subscribed: false, price: "$9.99"))
t.equal(state.seatDiagnosis.worst?.position, "UTG+1")
t.equal(state.freeView.visibleLeakID, "too-many-hands",
"the free habit should be the most common one, which every seat here has")
t.expect(state.mayDrillSeat(.underTheGunPlus1), "the free player may not drill the seat their habit leads at")
// Every seat here leads with the same habit, so every seat is open to this free
// player — which is right: the free tier includes that one drill, wherever it is.
state.startOrResumeSeatDrill(.underTheGunPlus1)
t.equal(state.openSheet, "drill-brief-drill-seat:UTG+1", "tapping the seat did not open its brief")
state.dismissSheet()
// A seat whose leading habit is not the free one shows the price.
for record in seatRun(.button, hands: 40, wrong: 20, from: 7_000).map { r -> HandRecord in
var r = r
r.verdicts = [VerdictRecord(street: "river", spot: SpotKind.facingRiverBet.name,
leak: ErrorKind.calledTooWide.name, severity: 3, costInBB: 3,
headline: "h", detail: "d", taken: "call", best: "fold",
spoke: true, confidence: 0.9)]
return r
} { session.history.append(record) }
t.equal(state.seatDiagnosis.report(for: .button)?.leading.first?.leak.id, "river-hero-call")
t.expect(!state.mayDrillSeat(.button), "a free player was let into a seat drill at a habit they are not shown")
state.startOrResumeSeatDrill(.button)
t.equal(state.openSheet, "paywall-true", "a locked seat drill did not show the price")
state.dismissSheet()
let paid = AppState(session: session)
paid.dismissSheet()
paid.charge(for: TrialTally(store: InMemoryTally(), key: "seat.test.paid"),
through: .stub(subscribed: true, price: "$9.99"))
t.expect(paid.mayDrillSeat(.button), "a subscriber was refused a seat drill")
}
}
t.test("every sentence the seat mode says is plain English") {
let hands = table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200)
let diagnosis = SeatDiagnosis(of: hands)
var corpus: [String] = [diagnosis.headline ?? "", diagnosis.evidenceLine ?? ""]
corpus += [SeatDiagnosis(of: []).reason ?? "", SeatDiagnosis(of: table(rate: 0.1, handsPerSeat: 200)).reason ?? ""]
let run = DrillRun(target: DrillTarget(seat: .underTheGunPlus1, tableSize: 9))
corpus += [run.purposeLine, run.targetLine, run.outcomeLine, run.outcomeTitle]
corpus += [Copy.whereYouLoseMost, Copy.whereYouLoseMostSubtitle, Copy.seatDrillHint,
Copy.sameSeat, Copy.sameSeatBlurb(seat: "seat 4 of 9", lean: "with a mix of players"),
Copy.seatCounted, Copy.seatCountedBlurb, Copy.seatDrillsFooter,
Copy.seatBannerLine("Seat 4 of 9", watching: "playing too many hands")]
for line in corpus where !line.isEmpty {
for word in Glossary.jargonWatchlist {
t.expect(!line.lowercased().contains(" \(word) ") && !line.lowercased().hasPrefix("\(word) "),
"\"\(word)\" in: \(line)")
}
}
}
}
}