← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit
/// Drives a session the way a person does — one legal decision at a time — so the parts the app
/// only draws are exercised here instead.
private func playOut(_ session: TableSession, hands: Int, choose: (LegalActions) -> PlayerAction) {
var guardRail = 0
while session.isPlaying, session.handNumber <= hands {
guardRail += 1
precondition(guardRail < 20_000, "session failed to progress")
if let legal = session.legal {
session.act(choose(legal))
} else if session.review != nil, session.engine?.isComplete == true {
if session.handNumber >= hands { return }
session.continueAfterReview()
} else {
return
}
}
}
private func alwaysFold(_ legal: LegalActions) -> PlayerAction {
legal.canCheck ? .check : .fold
}
/// Hands as the coach would have written them down, built to rate exactly `rating` once there are
/// enough of them. The rating is `100 - costPer100 * 5.5` with no habit penalty, so the cost is
/// worked backwards from the number wanted; a fifth of the hands carry the mistake, which is under
/// what the one leak living in this spot allows.
private func rated(_ rating: Int, count: Int, session: String = "old", from: Int = 1,
kind: String? = nil, drill: String? = nil) -> [HandRecord] {
let slips = max(1, count / 5)
// The inverse of SkillRating's own arithmetic, so a fixture asking for a rating of 60 gets
// hands that actually rate 60. It was written out by hand with the old constants and quietly
// stopped agreeing the day the scale was re-anchored on tournament play — which broke a dozen
// ladder tests that were all correct.
let costPer100 = SkillRating.costFor(rating: rating)
let each = costPer100 * Double(count) / 100 / Double(slips)
return (0..<count).map { index in
let wrong = index < slips
return HandRecord(sessionID: session,
sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(from + index) * 60),
handNumber: from + index,
mode: "Tournament", bigBlind: 100, position: "BB",
holeCards: "Ah Kd", board: "2c 7h Js", netChips: 0,
wentToShowdown: false, sawFlop: true, putMoneyInPreflop: true,
raisedPreflop: false,
verdicts: [VerdictRecord(street: "flop", spot: SpotKind.flopDecision.name,
leak: wrong ? ErrorKind.wrongSize.name : "",
severity: wrong ? Severity.moderate.rawValue : 0,
costInBB: wrong ? each : 0,
headline: "h", detail: "d", taken: "bet",
best: "bet", spoke: wrong, confidence: 0.9)],
tableSize: 9, kind: kind, drill: drill)
}
}
func sessionTests(_ t: TestRun) {
t.suite("sitting at a table") { t in
t.test("a table is nine handed") {
let session = TableSession(seed: 7)
session.start(.tournament)
t.equal(TableSession.seatsAtTable, 9)
t.equal(session.seats.count, 9, "nine seats are dealt in")
t.equal(session.opponents.count, 8, "the player plus eight opponents")
t.equal(session.playersLeft, 9)
t.equal(session.label(forSeat: session.heroSeat), "You")
t.equal(Set(session.opponents.keys).count, 8, "every opponent has their own name")
}
t.test("the player is dealt in and it is a real hand") {
let session = TableSession(seed: 11)
session.start(.tournament)
t.equal(session.heroCards.count, 2)
t.equal(session.heroStack + session.seats.reduce(0) { $0 + ($1.seat == session.heroSeat ? 0 : $1.stack) }
+ session.pot, 9 * TableSession.startingStack, "chips are conserved")
t.expect(session.isPlaying)
}
t.test("the style badge follows the player, not the seat") {
let session = TableSession(seed: 3)
session.start(.tournament)
t.expect(session.personality(forSeat: session.heroSeat) == nil, "the player has no style badge")
for seat in session.seats where seat.seat != session.heroSeat {
let shown = session.personality(forSeat: seat.seat)
t.expect(shown != nil, "seat \(seat.seat) has no style")
t.expect(shown == session.opponents[seat.playerID]?.personality,
"seat \(seat.seat) is showing someone else's style")
}
}
t.test("styles stay right after players bust") {
// Folding every hand busts the player quickly and busts others around them, which is
// exactly when a seat-keyed lookup starts describing the wrong person.
let session = TableSession(seed: 21)
session.start(.tournament)
playOut(session, hands: 400, choose: alwaysFold)
for seat in session.seats where seat.playerID != session.heroID {
t.expect(session.personality(forSeat: seat.seat) == session.opponents[seat.playerID]?.personality,
"\(seat.playerID) is showing the wrong style")
t.equal(session.label(forSeat: seat.seat), seat.playerID)
}
}
t.test("an illegal decision changes nothing") {
let session = TableSession(seed: 5)
session.start(.tournament)
while session.legal == nil, session.isPlaying, session.engine?.isComplete == false {
session.continueAfterReview()
}
guard let legal = session.legal else {
return t.expect(false, "nobody was ever asked to act")
}
let potBefore = session.pot
let handsJudged = session.coach.profile.handsPlayed
let accepted = session.act(.raise(to: legal.maxRaiseTo + 5_000))
t.expect(!accepted, "an over-sized raise was accepted")
t.equal(session.pot, potBefore, "the pot moved on an illegal action")
t.expect(session.heroToAct, "the player lost their turn to a mis-click")
t.equal(session.coach.profile.handsPlayed, handsJudged)
}
t.test("the coach sees every hand the player sits through") {
let session = TableSession(seed: 9)
session.start(.tournament)
playOut(session, hands: 12, choose: alwaysFold)
t.expect(session.coach.reviews.count >= 10, "only \(session.coach.reviews.count) hands reviewed")
t.equal(session.coach.reviews.count, session.coach.profile.handsPlayed)
let numbers = session.coach.reviews.map(\.handNumber)
t.equal(numbers, numbers.sorted(), "hands are reviewed in the order they were played")
t.equal(Set(numbers).count, numbers.count, "no hand is reviewed twice")
}
t.test("a tournament ends, and says how the player finished") {
let session = TableSession(seed: 17)
session.start(.tournament)
playOut(session, hands: 5_000, choose: alwaysFold)
t.expect(!session.isPlaying, "the tournament never ended")
t.expect(session.statusLine.contains("busted") || session.statusLine.contains("finished")
|| session.statusLine.contains("won the tournament"),
"unhelpful ending: \(session.statusLine)")
t.expect(session.playersLeft < 9, "nobody ever busted")
}
t.test("blinds climb as a tournament runs") {
let session = TableSession(seed: 31)
session.start(.tournament)
let opening = session.level.bigBlind
playOut(session, hands: 60, choose: alwaysFold)
t.expect(session.level.bigBlind > opening,
"blinds stayed at \(opening) after 60 hands")
}
t.test("a cash table keeps its seats, and what is won or lost stays in front of you") {
// Cash tables used to top everybody back up between hands. They do not any more:
// stacks carry, a bust buys back in, and the whole of that rule is under "a cash
// table". This only checks that the seats and the buy-in are what they should be.
let session = TableSession(seed: 13, variant: .pokerTutorCash)
session.start(.cash)
let opening = session.seats.map(\.playerID)
t.equal(opening.count, 9)
// Measured behind plus in front: the blinds are already posted by the time anyone
// looks at the table.
let behind = { (seat: SeatState) in seat.stack + seat.committedThisHand + seat.antePaid }
t.equal(session.seats.map(behind), Array(repeating: session.cashSetup.buyIn, count: 9))
playOut(session, hands: 25, choose: alwaysFold)
t.expect(session.isPlaying, "a cash game should not end")
t.equal(session.seats.map(\.playerID), opening, "players moved seats between hands")
// Chips only enter the table as buy-ins: the nine who sat down, everybody who bought
// back in, and anybody new who took a busted player's seat. Measured off the stacks
// alone: the hand on the table has finished, so its pot is already in them and what
// each seat put in is still written against it.
t.expect(session.engine?.isComplete == true, "the driver stops on a finished hand")
let total = session.seats.map(\.stack).reduce(0, +)
let rebought = session.rebuys.values.reduce(0, +)
let newcomers = Set(session.seatedIDs).subtracting(opening).count
t.equal(total, session.cashSetup.buyIn * (9 + rebought + newcomers),
"chips move between players; the only way in is a buy-in")
t.expect(session.seats.map(\.stack).contains { $0 != session.cashSetup.buyIn },
"twenty-five hands of blinds and nobody is on exactly the buy-in")
}
t.test("leaving the table stops the game") {
let session = TableSession(seed: 4)
session.start(.tournament)
t.expect(session.isPlaying)
session.leave()
t.expect(!session.isPlaying)
t.expect(session.legal == nil)
t.expect(!session.act(.fold), "a decision was accepted after leaving")
}
t.test("hole cards stay hidden unless there was a showdown") {
let session = TableSession(seed: 8)
session.start(.tournament)
for seat in session.seats where seat.seat != session.heroSeat {
t.expect(session.revealedCards(forSeat: seat.seat) == nil,
"seat \(seat.seat) was face up mid-hand")
}
playOut(session, hands: 30, choose: alwaysFold)
if let engine = session.engine, engine.isComplete, engine.result?.wentToShowdown == false {
for seat in session.seats where seat.seat != session.heroSeat {
t.expect(session.revealedCards(forSeat: seat.seat) == nil,
"cards were shown on a hand that never got to showdown")
}
}
}
t.test("dealing the opponents out one at a time changes nothing about the hand") {
// The app plays the opponents on a timer so a hand can be watched. That must be a
// difference in pacing only — the same seed has to produce the same poker.
let atOnce = TableSession(seed: 77)
atOnce.start(.tournament)
playOut(atOnce, hands: 20, choose: alwaysFold)
let paced = TableSession(seed: 77)
paced.pacedByCaller = true
paced.start(.tournament)
var guardRail = 0
while paced.isPlaying, paced.handNumber <= 20 {
guardRail += 1
precondition(guardRail < 20_000, "paced session failed to progress")
if paced.stepOpponent() != nil { continue }
if let legal = paced.legal {
paced.act(alwaysFold(legal))
} else if paced.review != nil, paced.engine?.isComplete == true {
if paced.handNumber >= 20 { break }
paced.continueAfterReview()
} else {
break
}
}
t.equal(paced.handNumber, atOnce.handNumber, "a different number of hands was played")
t.equal(paced.coach.reviews.map(\.netChips), atOnce.coach.reviews.map(\.netChips),
"the same hands came out differently")
t.equal(paced.rating.value, atOnce.rating.value)
}
t.test("the table remembers what each player just did") {
let session = TableSession(seed: 41)
session.pacedByCaller = true
session.start(.tournament)
t.expect(session.lastActions.isEmpty, "a hand starts with nothing to show")
guard let first = session.stepOpponent() else {
return t.expect(false, "no opponent ever acted")
}
t.equal(session.lastActions[first.seat], first)
t.equal(session.lastActions[first.seat]?.playerID, session.label(forSeat: first.seat))
t.expect(!first.isHero)
t.expect(!first.caption.isEmpty, "an action with nothing to show for it")
if first.isFold { t.expect(first.isPassive, "a fold is a passive action") }
// Blinds are posted by the engine, not decided by anyone, so they are not "what they
// just did" — only real decisions are recorded.
t.greaterThan(session.lastActions.count, 0, "nobody had done anything to record")
for (seat, applied) in session.lastActions {
t.equal(applied.seat, seat)
}
while session.stepOpponent() != nil {}
if session.isPlaying, session.engine?.isComplete == true {
session.continueAfterReview()
t.expect(session.lastActions.isEmpty, "last hand's actions were still on the table")
}
}
t.test("captions say what happened") {
let fold = TableSession.AppliedAction(seat: 3, playerID: "Ray", action: .fold,
street: .preflop, chipsAdded: 0, isHero: false)
t.equal(fold.caption, "Fold")
t.expect(fold.isFold)
t.expect(fold.isPassive)
let raise = TableSession.AppliedAction(seat: 0, playerID: "You", action: .raise(to: 600),
street: .flop, chipsAdded: 600, isHero: true)
t.equal(raise.caption, "Raise 600")
t.expect(!raise.isPassive, "a raise is not passive")
let call = TableSession.AppliedAction(seat: 1, playerID: "Jo", action: .call,
street: .turn, chipsAdded: 200, isHero: false)
t.equal(call.caption, "Call 200")
}
t.test("folding ends the hand for the player straight away") {
// The opponents are dealt out on a timer while the player is in the hand. Once they
// fold they are watching a pot they have no stake in, so the rest is run out at once
// and the coach's feedback arrives immediately.
let session = TableSession(seed: 63)
session.pacedByCaller = true
session.start(.tournament)
var folded = false
var guardRail = 0
while !folded, guardRail < 20_000 {
guardRail += 1
if session.stepOpponent() != nil { continue }
guard let legal = session.legal else { break }
if legal.canFold, !legal.canCheck {
t.expect(session.heroInHand, "the player should still be in before folding")
session.act(.fold)
folded = true
} else {
session.act(legal.canCheck ? .check : .fold)
}
}
t.expect(folded, "never reached a spot where folding was possible")
t.expect(!session.heroInHand, "the player is still in the hand after folding")
session.finishHandNow()
t.expect(session.engine?.isComplete == true, "the hand did not run out")
t.expect(session.review != nil, "no feedback waiting after folding")
t.equal(session.review?.handNumber, session.handNumber)
t.expect(!session.opponentToAct, "opponents were left owing a decision")
}
t.test("running a hand out at once matches letting it play at its own pace") {
// Finishing early must be a shortcut through the same hand, not a different one.
func play(fastForward: Bool) -> (Int, Int) {
let session = TableSession(seed: 64)
session.pacedByCaller = true
session.start(.tournament)
var guardRail = 0
while session.isPlaying, session.handNumber <= 10 {
guardRail += 1
precondition(guardRail < 40_000)
if !session.heroInHand, fastForward, session.opponentToAct {
session.finishHandNow()
continue
}
if session.stepOpponent() != nil { continue }
if let legal = session.legal {
session.act(legal.canCheck ? .check : .fold)
} else if session.review != nil, session.engine?.isComplete == true {
if session.handNumber >= 10 { break }
session.continueAfterReview()
} else {
break
}
}
return (session.handNumber, session.history.hands.reduce(0) { $0 + $1.netChips })
}
let paced = play(fastForward: false)
let hurried = play(fastForward: true)
t.equal(hurried.0, paced.0, "a different number of hands was played")
t.equal(hurried.1, paced.1, "running the hand out early changed the result")
}
t.test("the rating is read off the saved hands, not out of the coach's memory") {
// There is one rating in this app. The coach's profile is the within-hand judging
// state and starts empty every launch, so reading the number off it told somebody with
// a year of hands behind them that they were an unknown quantity.
let store = HistoryStore.inMemory()
for record in rated(70, count: 220) { store.append(record) }
let session = TableSession(seed: 5, history: store)
t.equal(session.coach.profile.handsPlayed, 0, "memory starts empty, as it always does")
// The midpoint prior stays: the ladder needs a number to place somebody from, and the
// middle of the scale is the right one to start from. What moved is the claim made
// about it on screen — a rating with no hands behind it is drawn as a dash with no
// label, not as "50 — Solid amateur" over "0 hands watched". The assertion moved with
// it rather than being weakened: the value is still pinned, and what the screen does
// with it is now pinned too.
t.equal(session.coach.rating.value, 50, "an empty memory rates everybody in the middle")
t.equal(session.coach.rating.handsBehindIt, 0,
"the display keys off this, so it is part of the contract")
// Pinned against the scorecard rather than against a number typed in here: the point
// of the test is that the session reads the file, and a literal only re-states whatever
// the rating scale happened to be the day it was written.
let offTheFile = LeakScorecard(of: store.hands).rating.value
t.equal(session.rating.value, offTheFile, "the saved hands were not what got read")
t.equal(session.rating.handsBehindIt, 220)
t.equal(session.rating.value, LeakScorecard(of: store.hands).rating.value,
"two ratings with two answers; there is meant to be one")
// And practice cannot raise it. Two hundred clean drill hands would have carried the
// in-memory rating up without a hand of poker being played any differently, which is
// the exact loophole the scorecard exists to close.
for record in rated(95, count: 200, session: "drilling", from: 500,
kind: HandRecord.drillKind, drill: "bet-sizing") {
store.append(record)
}
t.equal(session.rating.value, offTheFile, "drilling moved the rating")
t.equal(session.rating.handsBehindIt, 220, "drill hands were counted as hands played")
// And the number the sidebar prints beside the rating is that one, not the size of the
// file. It used to print the file, so 200 drill hands and 220 real ones read as "420
// hands" under a rating built on 220.
t.equal(store.hands.count, 420)
t.notEqual(session.rating.handsBehindIt, store.hands.count,
"this fixture is meant to have hands the rating refuses to count")
}
t.test("what one sitting did is counted off that sitting's hands, not off memory") {
// Home's "this session" panel is built from the hands written down for the sitting on
// the table, with the arranged ones left out. It used to read the coach's own profile,
// which is nothing of the sort: the coach's memory is everything since the app opened
// and is cleared by nothing short of resetting the history, so a tournament, then an
// assessment, then a drill were added together and priced per hundred hands as one
// evening's poker — under a heading that said "this session" and beside a promise that
// drill hands touch neither the win rate nor the leaks.
let store = HistoryStore.inMemory()
let session = TableSession(seed: 21, history: store)
session.start(.tournament)
playOut(session, hands: 6, choose: alwaysFold)
let firstSitting = session.sessionID
let played = store.hands(in: .session(firstSitting)).count
t.greaterThan(played, 0, "the sitting wrote nothing down")
session.startDrill(LeakCatalogue.named("bb-overfold")!)
driveDrill(session, player: .control, seed: 21, stoppingAfter: 8)
let drilling = session.sessionID
t.notEqual(drilling, firstSitting, "a drill is a sitting of its own")
let memory = session.coach.profile.handsPlayed
t.greaterThan(memory, played, "memory should be carrying both sittings by now")
// What the panel reads: this sitting, arranged hands out. A drill sitting is all
// arranged hands, so it has nothing to report and the panel does not appear.
let sitting = store.hands(in: .session(drilling)).filter { !$0.isArranged }
t.equal(sitting.count, 0, "drill hands reached the sitting's own numbers")
t.equal(GameAnalysis(of: sitting).hands, 0)
t.equal(LeakScorecard(of: sitting).costing.count, 0,
"a habit was confirmed off arranged hands")
// And the earlier sitting still reports itself, unchanged by what came after it.
let earlier = store.hands(in: .session(firstSitting)).filter { !$0.isArranged }
t.equal(earlier.count, played)
t.equal(GameAnalysis(of: earlier).hands, played)
t.lessThan(GameAnalysis(of: earlier).hands, memory,
"the sitting and the coach's memory are meant to be different numbers")
}
t.test("walking out mid-hand cannot put a verdict on the next hand") {
// The coach collects verdicts as the decisions are made and hands them over when the
// hand closes. A hand that never closes used to leave them sitting there: fold to a
// river bet, hit Leave, start anything, and the first hand to finish in the new sitting
// was written down carrying somebody else's mistake — into the scorecard, the drill
// counts, the ladder and the history, attributed to the wrong hand, street and seat.
// The same rule as the one about an illegal action: nothing that was not a decision in
// this hand may be filed against the player as a mistake in it.
let store = HistoryStore.inMemory()
let session = TableSession(seed: 88, history: store)
session.pacedByCaller = true
session.start(.tournament)
// Play up to the player's first decision and take it, without letting the hand finish.
var guardRail = 0
while session.isPlaying, session.legal == nil, session.engine?.isComplete == false {
guardRail += 1
precondition(guardRail < 5_000, "the hand never reached the player")
if session.stepOpponent() == nil { break }
}
guard let legal = session.legal else {
return t.expect(false, "the player never got a decision to make")
}
session.act(legal.canCall ? .call : (legal.canCheck ? .check : .fold))
t.expect(session.history.hands.isEmpty, "the hand should not have been written down yet")
// Walk out, then play a fresh sitting to the end of its first hand.
session.leave()
session.pacedByCaller = false
session.start(.tournament)
guardRail = 0
while session.isPlaying, session.history.hands.isEmpty {
guardRail += 1
precondition(guardRail < 20_000, "the new sitting never finished a hand")
if let legal = session.legal {
session.act(legal.canCheck ? .check : .fold)
} else if session.engine?.isComplete == true {
session.continueAfterReview()
} else {
break
}
}
guard let first = session.history.hands.first else {
return t.expect(false, "the new sitting wrote nothing down")
}
// The abandoned decision was made before the flop; this hand is folded out at the
// first chance, so anything it carries beyond its own decisions came from the last one.
t.lessThan(first.verdicts.count, 2,
"the first hand of a new sitting carried \(first.verdicts.count) verdicts: "
+ "\(first.verdicts.map(\.spot))")
t.equal(session.review?.verdicts.count, first.verdicts.count,
"the review and the record disagree about how many decisions were in the hand")
}
t.test("the table you sit at is the rung you are on") {
// The difficulty is no longer read off the rating directly — it is the rung's, and the
// rung is read off the hands already saved. See `LadderStanding`.
let session = TableSession(seed: 2)
session.start(.tournament)
t.equal(session.tier?.id, session.ladder.tier.id)
t.equal(session.tableDifficulty, session.ladder.tier.skill)
}
t.test("a hand that is over the moment it is dealt is still closed") {
// A stack short enough that the blind puts it all in leaves nobody with a decision, so
// nothing steps the hand. Before this was closed here, the tournament was never told
// anybody busted and the table dealt hands that finished before they started, for
// ever. Folding every hand at the bottom rung is how a real player gets there.
let session = TableSession(seed: 17, history: .inMemory())
session.start(.tournament)
playOut(session, hands: 5_000, choose: alwaysFold)
t.expect(!session.isPlaying, "the tournament never ended")
t.equal(session.history.hands.count, session.handNumber,
"hands were dealt that never made it into the history")
t.expect(session.playersLeft < 9, "nobody ever busted")
}
}
}