GameCursorStore.swift (2062B)
1 import Foundation 2 3 /// Persists the local cursor (row, column, direction) per game in 4 /// `UserDefaults`. 5 /// 6 /// The cursor is device-local by design — it is never synced to CloudKit. The 7 /// coarser collaborator-visible "cursor track" is published separately by 8 /// `PlayerSession`; this store only remembers where *this* player left off so 9 /// reopening a puzzle (even after a cold launch) restores their position. 10 /// 11 /// Layout under the `"gameCursors"` key: 12 /// `[gameID.uuidString: ["row": Int, "col": Int, "dir": Int]]` 13 /// where `dir` is `0` for across and `1` for down. 14 @MainActor 15 final class GameCursorStore { 16 struct Cursor: Equatable { 17 var row: Int 18 var col: Int 19 var direction: Puzzle.Direction 20 } 21 22 private let defaults: UserDefaults 23 private let defaultsKey = "gameCursors" 24 25 init(defaults: UserDefaults = .standard) { 26 self.defaults = defaults 27 } 28 29 // MARK: - Read 30 31 func cursor(forGame gameID: UUID) -> Cursor? { 32 guard let entry = rawStore[gameID.uuidString], 33 let row = entry["row"], 34 let col = entry["col"], 35 let dir = entry["dir"] else { return nil } 36 return Cursor(row: row, col: col, direction: dir == 1 ? .down : .across) 37 } 38 39 // MARK: - Write 40 41 func setCursor(_ cursor: Cursor, forGame gameID: UUID) { 42 if self.cursor(forGame: gameID) == cursor { return } 43 var s = rawStore 44 s[gameID.uuidString] = [ 45 "row": cursor.row, 46 "col": cursor.col, 47 "dir": cursor.direction == .down ? 1 : 0 48 ] 49 rawStore = s 50 } 51 52 func clearCursor(forGame gameID: UUID) { 53 var s = rawStore 54 guard s[gameID.uuidString] != nil else { return } 55 s.removeValue(forKey: gameID.uuidString) 56 rawStore = s 57 } 58 59 // MARK: - Private 60 61 private var rawStore: [String: [String: Int]] { 62 get { defaults.dictionary(forKey: defaultsKey) as? [String: [String: Int]] ?? [:] } 63 set { defaults.set(newValue, forKey: defaultsKey) } 64 } 65 }