Advertisement

← poker-tutor

Sources/PokerSession/HistoryStore.swift

6.2 KB Download file
import Foundation

/// Every hand ever played, kept between launches.
///
/// One JSON object per line, appended as each hand ends. A line-per-hand file is chosen over a
/// database for a specific reason: a half-written last line loses one hand, not the history. It is
/// also readable — anyone can open it and see what happened, which matters for a tool whose whole
/// job is to tell you the truth about your game.
public final class HistoryStore {
    public private(set) var hands: [HandRecord] = []
    private let url: URL?
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()

    /// Where the history lives when nothing else is specified: this product's own folder, so
    /// the tournament app and the cash app never read each other's hands.
    public static func defaultURL(folder name: String = AppVariant.current.historyFolder) -> URL? {
        guard let base = try? FileManager.default.url(for: .applicationSupportDirectory,
                                                      in: .userDomainMask,
                                                      appropriateFor: nil, create: true) else { return nil }
        let folder = base.appendingPathComponent(name, isDirectory: true)
        try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        return folder.appendingPathComponent("hands.jsonl")
    }

    /// Pass a url to keep a history somewhere specific; pass nil for one that is never written
    /// down, which is what the tests and the command line want.
    public init(url: URL?) {
        self.url = url
        encoder.dateEncodingStrategy = .iso8601
        decoder.dateDecodingStrategy = .iso8601
        load()
    }

    public convenience init() {
        self.init(url: HistoryStore.defaultURL())
    }

    /// A history that exists only for as long as it is held.
    public static func inMemory() -> HistoryStore { HistoryStore(url: nil) }

    private func load() {
        guard let url, let text = try? String(contentsOf: url, encoding: .utf8) else { return }
        for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
            guard let data = line.data(using: .utf8),
                  let record = try? decoder.decode(HandRecord.self, from: data) else { continue }
            hands.append(record)
        }
    }

    public func append(_ record: HandRecord) {
        hands.append(record)
        guard let url, let data = try? encoder.encode(record),
              var line = String(data: data, encoding: .utf8) else { return }
        line += "\n"
        guard let bytes = line.data(using: .utf8) else { return }

        if let handle = try? FileHandle(forWritingTo: url) {
            defer { try? handle.close() }
            let end = (try? handle.seekToEnd()) ?? 0
            // A torn write is the thing this file format exists to survive, and appending blindly
            // turned the cost of one into two. A write cut off half way leaves the file ending in a
            // partial object with no newline on it; the next launch reads past that fragment
            // correctly, and then the next hand played was written straight onto the end of it,
            // fusing the two into one line that decodes as neither. So the crash cost the hand it
            // interrupted *and* the first hand played afterwards — a hand lost after the thing that
            // lost it had already happened. Finish the broken line before starting a new one.
            if end > 0 {
                _ = try? handle.seek(toOffset: end - 1)
                let last = (try? handle.read(upToCount: 1)) ?? Data()
                if last != Data([0x0A]) { try? handle.write(contentsOf: Data([0x0A])) }
            }
            try? handle.write(contentsOf: bytes)
        } else {
            try? bytes.write(to: url)
        }
    }

    /// Wipes the history, on disk as well as in memory.
    ///
    /// The old file is moved aside rather than deleted. Clearing is a button someone can hit by
    /// mistake, and a year of hands is not something to lose to a mis-tap — `backupURL` is where
    /// the previous one goes, and it survives until the next reset.
    public func clear() {
        hands.removeAll()
        guard let url, FileManager.default.fileExists(atPath: url.path) else { return }
        if let backup = backupURL {
            try? FileManager.default.removeItem(at: backup)
            if (try? FileManager.default.moveItem(at: url, to: backup)) != nil { return }
        }
        try? FileManager.default.removeItem(at: url)
    }

    /// Where the last cleared history was put, if there is one.
    public var backupURL: URL? {
        url?.deletingLastPathComponent().appendingPathComponent("hands.previous.jsonl")
    }

    public var hasBackup: Bool {
        guard let backupURL else { return false }
        return FileManager.default.fileExists(atPath: backupURL.path)
    }

    // MARK: - Reading it back

    /// Every sitting, most recent first.
    public func sessions() -> [SessionSummary] {
        var order: [String] = []
        var grouped: [String: [HandRecord]] = [:]
        for hand in hands {
            if grouped[hand.sessionID] == nil { order.append(hand.sessionID) }
            grouped[hand.sessionID, default: []].append(hand)
        }
        return order.reversed().compactMap { id in
            guard let group = grouped[id], let first = group.first else { return nil }
            return SessionSummary(
                id: id,
                started: first.sessionStarted,
                mode: first.mode,
                hands: group.count,
                netChips: group.reduce(0) { $0 + $1.netChips },
                netBB: group.reduce(0) { $0 + $1.netBB },
                flagged: group.reduce(0) { $0 + $1.flagged.count }
            )
        }
    }

    public func hands(in scope: HistoryScope) -> [HandRecord] {
        switch scope {
        case .allTime: return hands
        case .session(let id): return hands.filter { $0.sessionID == id }
        }
    }
}

/// Which hands are being looked at.
public enum HistoryScope: Equatable, Hashable, Sendable {
    case allTime
    case session(String)
}