crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }