Advertisement

← poker-tutor

Sources/PokerTests/SessionTests.swift

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