LocalSessionTracker.swift (2249B)
1 import Foundation 2 3 /// A point-in-time view of this device's `MovesEntity` row for a game, 4 /// partitioned by whether the cell currently holds a letter. The session 5 /// tracker captures one at session start and one at session end, then diffs 6 /// each set to derive net adds and net clears. 7 struct LocalMovesSnapshot: Equatable, Sendable, Codable { 8 /// Positions where the local Moves row currently has a non-empty letter. 9 let filled: Set<GridPosition> 10 /// Positions where the local Moves row currently has an explicit empty 11 /// entry (cells the user cleared, including ones a peer had filled). 12 let cleared: Set<GridPosition> 13 14 static let empty = LocalMovesSnapshot(filled: [], cleared: []) 15 16 func encoded() throws -> Data { 17 try JSONEncoder().encode(self) 18 } 19 20 static func decode(_ data: Data) throws -> LocalMovesSnapshot { 21 try JSONDecoder().decode(LocalMovesSnapshot.self, from: data) 22 } 23 } 24 25 /// Snapshots the local Moves row at session start, then diffs against the 26 /// same row at session end to build the body text of the pause APN. Net 27 /// delta — not keystroke count — so overtypes and trial-and-error don't 28 /// inflate the figure. Reset on every `begin(gameID:snapshot:)` so a 29 /// background/foreground cycle on the same puzzle starts each segment from 30 /// a fresh baseline. 31 @MainActor 32 final class LocalSessionTracker { 33 private(set) var activeGameID: UUID? 34 private var baseline: LocalMovesSnapshot = .empty 35 36 func begin(gameID: UUID, snapshot: LocalMovesSnapshot) { 37 activeGameID = gameID 38 baseline = snapshot 39 } 40 41 /// Diffs `snapshot` against the baseline captured at `begin` and clears 42 /// state. A nil or mismatched `gameID` returns zero counts and leaves 43 /// state untouched, so a stray end-call from a stale view doesn't drain a 44 /// fresh session. 45 func consume(gameID: UUID, snapshot: LocalMovesSnapshot) -> (added: Int, cleared: Int) { 46 guard gameID == activeGameID else { return (0, 0) } 47 let added = snapshot.filled.subtracting(baseline.filled).count 48 let cleared = snapshot.cleared.subtracting(baseline.cleared).count 49 baseline = .empty 50 activeGameID = nil 51 return (added, cleared) 52 } 53 }