Advertisement

← poker-tutor

Sources/PokerTests/ReplayTests.swift

17.4 KB Download file
import Foundation
import PokerCore
import PokerCoach
import PokerSession
import PokerUI
import TestKit

/// Playing a saved hand back.
///
/// The one test that matters here is the round trip: a hand is played through a real session,
/// written to a real file, read back by a second store the way the next launch would read it, and
/// the replay has to reproduce the streets, the actions and the pot sizes the engine actually
/// produced. Everything else in this file is a guard on the format — a saved hand has to outlive
/// the code that wrote it, and the way that breaks is silently.
public func replayTests(_ t: TestRun) {

    /// Plays until a hand finishes with something worth replaying in it, and hands back what the
    /// engine says happened, before anything is read off disk.
    func playOneRealHand(seed: UInt64, store: HistoryStore)
        -> (actions: [ActionRecord], board: [Card], handNumber: Int)? {
        let session = TableSession(seed: seed, history: store)
        session.start(.tournament)

        var guardRail = 0
        while session.isPlaying {
            guardRail += 1
            precondition(guardRail < 40_000)
            if let legal = session.legal {
                // A policy that actually sees flops, so the transcript has more than one street in
                // it. Folding everything would test the empty case and call it a day.
                if legal.canCheck {
                    session.act(.check)
                } else if legal.canCall, legal.callCost * 5 <= session.heroStack {
                    session.act(.call)
                } else {
                    session.act(.fold)
                }
            } else if session.review != nil, let engine = session.engine, engine.isComplete {
                let voluntary = engine.actions.filter { !$0.isForced }
                if voluntary.count >= 4, Set(voluntary.map(\.street)).count >= 2 {
                    return (engine.actions, engine.board, session.handNumber)
                }
                session.continueAfterReview()
            } else {
                return nil
            }
        }
        return nil
    }

    t.suite("playing a hand back") { t in

        t.test("a hand played, saved and reloaded replays as the hand that was played") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-replay-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let store = HistoryStore(url: url)
            guard let played = playOneRealHand(seed: 4_811, store: store) else {
                return t.expect(false, "no hand with two streets of betting in it turned up")
            }

            // A second store opening the same file is what the next launch does.
            let reloaded = HistoryStore(url: url)
            guard let saved = reloaded.hands.first(where: { $0.handNumber == played.handNumber }) else {
                return t.expect(false, "the hand never reached the file")
            }
            guard let replay = HandReplay(saved) else {
                return t.expect(false, "a hand recorded today could not be replayed")
            }

            // The streets, in playing order.
            let dealt = played.board.count
            var expectedStreets = ["preflop"]
            if dealt >= 3 { expectedStreets.append("flop") }
            if dealt >= 4 { expectedStreets.append("turn") }
            if dealt >= 5 { expectedStreets.append("river") }
            t.equal(replay.streetStarts.map(\.street.name), expectedStreets,
                    "the replay disagreed with the engine about which rounds were played")

            // The actions, in order, with the same seat making each of them.
            let voluntary = played.actions.filter { !$0.isForced }
            let steps = replay.moments.filter { $0.kind == .acts }
            t.equal(steps.count, voluntary.count, "the replay lost or invented an action")
            t.equal(steps.map { $0.seat ?? -1 }, voluntary.map(\.seat), "the actions changed seats")
            t.equal(steps.map(\.street.name), voluntary.map(\.street.name),
                    "an action moved to a different round of betting")

            // The pot, at every single step.
            t.equal(steps.map(\.pot), voluntary.map(\.potAfter), "the pot did not grow the same way")

            // Blinds and antes are folded into the opening moment rather than being steps, so the
            // hand opens with money already in the middle.
            let forced = played.actions.filter(\.isForced)
            t.equal(replay.moments.first?.pot, forced.last?.potAfter ?? 0,
                    "the blinds and antes were not in the pot when the hand opened")

            // The shared cards come out as they came out, and never early.
            let beforeTheEnd = replay.moments.filter { $0.kind != .ends }
            t.greaterThan(beforeTheEnd.count, 0, "the replay has no moments before the end of the hand")
            for moment in beforeTheEnd {
                switch moment.street {
                case .preflop: t.equal(moment.board.count, 0, "the flop was face up before the flop")
                case .flop: t.equal(moment.board.count, 3)
                case .turn: t.equal(moment.board.count, 4)
                case .river: t.equal(moment.board.count, 5)
                default: break
                }
            }
            t.equal(replay.moments.last?.kind, .ends, "the replay does not finish the hand")
            t.equal(replay.tableSize, saved.seatsAtTable)
            t.equal(replay.hero?.cards, saved.holeCards, "the player's own cards went missing")

            // Everybody who was dealt in is accounted for, and their stacks add up: what a seat
            // sat down with plus what the hand did to it is what it stood up with.
            t.expect(replay.players.count >= 2)
            for player in replay.players {
                t.expect(player.startingStack > 0, "\(player.name) started the hand with nothing")
            }
        }

        t.test("the coach's read lands on the decision it was about") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-replay-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let store = HistoryStore(url: url)
            guard let played = playOneRealHand(seed: 77, store: store),
                  let saved = HistoryStore(url: url).hands.first(where: { $0.handNumber == played.handNumber }),
                  let replay = HandReplay(saved) else {
                return t.expect(false, "no hand to read")
            }

            let mine = replay.moments.filter { $0.kind == .acts && $0.isHero }
            t.expect(!mine.isEmpty, "the player made no decisions in this hand")
            t.equal(mine.compactMap(\.verdict).count, saved.verdicts.count,
                    "a verdict was dropped, or attached to somebody else's decision")
            for moment in mine {
                guard let verdict = moment.verdict else { continue }
                t.equal(verdict.street, moment.street.name,
                        "the coach's read on the \(verdict.street) was shown against the \(moment.street.name)")
                t.expect(!verdict.taken.isEmpty)
            }
            // Nobody else's decisions carry a verdict — the coach only watches the player.
            t.equal(replay.moments.filter { !$0.isHero && $0.verdict != nil }.count, 0)
        }

        t.test("an opponent's cards are only there where they were turned over") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-replay-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let store = HistoryStore(url: url)
            _ = playOneRealHand(seed: 2_026, store: store)
            let hands = HistoryStore(url: url).hands
            t.expect(!hands.isEmpty, "nothing was written down")

            t.greaterThan(hands.filter { $0.players != nil }.count, 0,
                          "no saved hand wrote down who was dealt in, so nothing below was checked")
            for hand in hands {
                guard let players = hand.players else { continue }
                let seen = players.filter { !$0.isHero && !$0.cards.isEmpty }
                // A hand nobody showed down must not have a single opponent's cards written down
                // against it. Otherwise the replay hands the player a read they never had.
                let shown = hand.actions?.isEmpty == false && !seen.isEmpty
                if shown {
                    t.expect(seen.allSatisfy { $0.cards.split(separator: " ").count == 2 },
                             "a revealed hand was written down half finished")
                }
                t.expect(players.filter(\.isHero).count == 1, "the player is in the hand exactly once")
            }
        }

        t.test("the words the replay puts on screen are plain English") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-replay-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let store = HistoryStore(url: url)
            guard let played = playOneRealHand(seed: 313, store: store),
                  let saved = HistoryStore(url: url).hands.first(where: { $0.handNumber == played.handNumber }),
                  let replay = HandReplay(saved) else {
                return t.expect(false, "no hand to read")
            }
            t.greaterThan(replay.moments.count, 0, "there was no writing on screen to read")
            for moment in replay.moments {
                let copy = [moment.headline, moment.action ?? "", moment.seatPhrase ?? ""]
                for line in copy {
                    let jargon = Glossary.unexplained(in: line)
                    t.expect(jargon.isEmpty, "\"\(line)\" uses \(jargon.joined(separator: ", "))")
                }
                // Every seat the replay names is named by its number, not only by the old word.
                if moment.kind == .acts, moment.position != nil {
                    t.expect(moment.seatPhrase?.hasPrefix("seat ") == true,
                             "a seat was shown as \"\(moment.seatPhrase ?? "")\" rather than by number")
                }
            }
        }
    }

    t.suite("saved hands outliving the code") { t in

        t.test("a line written before replays existed still reads") {
            // Written by hand rather than by the current encoder, on purpose: this is literally a
            // line from the old format, and the point is that adding fields to `HandRecord` cannot
            // quietly throw a year of hands away. `HistoryStore.load` skips anything it cannot
            // decode, so a required new field would delete the lot with no error anywhere.
            let old = """
            {"sessionID":"old","sessionStarted":"2026-01-01T10:00:00Z",\
            "playedAt":"2026-01-01T10:05:00Z","handNumber":7,"mode":"Tournament","bigBlind":100,\
            "position":"BTN","holeCards":"Ah Kd","board":"2c 7h Js","netChips":450,\
            "wentToShowdown":true,"sawFlop":true,"putMoneyInPreflop":true,"raisedPreflop":true,\
            "verdicts":[{"street":"flop","spot":"a spot","leak":"calling too wide","severity":3,\
            "costInBB":2.5,"headline":"h","detail":"d","taken":"call","best":"fold","spoke":true}]}
            """

            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-old-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }
            try? (old + "\n").data(using: .utf8)?.write(to: url)

            let store = HistoryStore(url: url)
            t.equal(store.hands.count, 1, "an old line was thrown away by a new field")
            guard let hand = store.hands.first else { return }
            t.equal(hand.handNumber, 7)
            t.equal(hand.netChips, 450)
            t.equal(hand.flagged.count, 1, "the verdicts on an old line did not survive")
            t.equal(hand.seatsAtTable, 9, "an old line with no table size should read as a full ring")
            t.equal(hand.seatPhrase, "seat 9 of 9")
            // It was played before the ladder existed, so it is not evidence for or against a
            // promotion — and saying so is the whole reason the rung is an optional field.
            t.equal(hand.tier, nil, "an old line invented a rung it was never played at")
            t.equal(LadderStanding(of: store.hands).handsAtTier, 0)

            // It cannot be replayed, and says so rather than drawing an empty table.
            t.expect(!hand.canReplay, "a hand with no transcript claimed it could be replayed")
            t.expect(HandReplay(hand) == nil, "a replay was built out of nothing")

            // And a hand recorded today can be, so the two answers are not both "no".
            let store2 = HistoryStore.inMemory()
            let session = TableSession(seed: 5, history: store2)
            session.start(.tournament)
            var guardRail = 0
            while session.isPlaying, store2.hands.isEmpty {
                guardRail += 1
                precondition(guardRail < 40_000)
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.review != nil {
                    session.continueAfterReview()
                } else {
                    break
                }
            }
            t.expect(store2.hands.first?.canReplay == true, "a hand recorded today cannot be replayed")
        }

        t.test("the new fields survive being written down and read back") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-replay-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }

            let record = HandRecord(
                sessionID: "s", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
                playedAt: Date(timeIntervalSince1970: 1_780_000_600), handNumber: 1,
                mode: "Cash game", bigBlind: 100, position: "CO", holeCards: "Ah Kd",
                board: "2c 7h Js", netChips: 300, wentToShowdown: false, sawFlop: true,
                putMoneyInPreflop: true, raisedPreflop: true, verdicts: [], tableSize: 6,
                smallBlind: 50, buttonSeat: 3,
                players: [
                    PlayerLine(seat: 0, name: "You", position: "CO", startingStack: 10_000,
                               cards: "Ah Kd", net: 300, isHero: true),
                    PlayerLine(seat: 1, name: "Maya", position: "BTN", startingStack: 9_000,
                               cards: "", net: -300, isHero: false),
                ],
                actions: [
                    ActionLine(seat: 0, street: "preflop", action: "blind", to: 50, chipsAdded: 50,
                               potAfter: 50, forced: true),
                    ActionLine(seat: 1, street: "preflop", action: "raise", to: 300,
                               chipsAdded: 300, potAfter: 350, forced: false),
                    ActionLine(seat: 0, street: "preflop", action: "call", to: 300,
                               chipsAdded: 250, potAfter: 600, forced: false),
                    ActionLine(seat: 0, street: "flop", action: "bet", to: 400, chipsAdded: 400,
                               potAfter: 1_000, forced: false),
                    ActionLine(seat: 1, street: "flop", action: "fold", to: 0, chipsAdded: 0,
                               potAfter: 1_000, forced: false),
                ]
            )

            let writing = HistoryStore(url: url)
            writing.append(record)
            let reading = HistoryStore(url: url)
            t.equal(reading.hands.count, 1)
            t.equal(reading.hands.first, record, "a field was lost on the way to disk and back")

            guard let replay = reading.hands.first.flatMap(HandReplay.init) else {
                return t.expect(false, "the hand would not replay")
            }
            t.equal(replay.streetStarts.map(\.street.name), ["preflop", "flop"])
            t.equal(replay.moments.filter { $0.kind == .acts }.map(\.pot), [350, 600, 1_000, 1_000])
            t.equal(replay.moments.first?.pot, 50, "the blind was not in the pot at the start")
            t.equal(replay.tableSize, 6)
            // Six handed, the cutoff is seat 5 of 6 — which is the whole reason the table size is
            // written down alongside the seat name.
            t.equal(replay.moments.first { $0.seat == 0 && $0.kind == .acts }?.seatPhrase, "seat 5 of 6")
            t.equal(replay.moments.first { $0.kind == .acts }?.action, "Raises to 300")
            t.equal(replay.moments.first { $0.kind == .acts }?.headline, "Maya raises to 300.")
            t.equal(replay.moments.last?.headline,
                    "No cards had to be shown. You won 300.")
            t.equal(replay.startingStack(seat: 1), 9_000)
        }
    }
}