← poker-tutor
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)
}