PlayerSelectionPublisher.swift (6386B)
1 import CoreData 2 import Foundation 3 4 /// Debounced writer for the local player's cursor selection. Updates the 5 /// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/ 6 /// `selDir` and asks the sync engine to push the Player record. Cursor edits 7 /// don't go through `MovesUpdater` because they aren't cell edits — they live 8 /// on `PlayerEntity` with last-writer-wins semantics. 9 actor PlayerSelectionPublisher { 10 private let debounceInterval: Duration 11 private let persistence: PersistenceController 12 private let sink: @Sendable (UUID, String) async -> Void 13 14 private var pending: PlayerSelection? 15 private var lastPublished: PlayerSelection? 16 private var debounceTask: Task<Void, Never>? 17 private var gameID: UUID? 18 private var authorID: String? 19 /// The local user's display name at session start. Used as a fallback 20 /// when `write` has to insert a fresh `PlayerEntity` row before 21 /// `PlayerNamePublisher` has fanned out — without it the row would have no 22 /// name and the SyncEngine would refuse to build a CKRecord, dropping 23 /// the cursor on the floor. 24 private var fallbackName: String = "" 25 26 init( 27 debounceInterval: Duration = .milliseconds(300), 28 persistence: PersistenceController, 29 sink: @escaping @Sendable (UUID, String) async -> Void 30 ) { 31 self.debounceInterval = debounceInterval 32 self.persistence = persistence 33 self.sink = sink 34 } 35 36 /// Starts publishing for a new puzzle session. Resets dedupe state so the 37 /// first selection from the new session always flushes. `currentName` is 38 /// the local user's display name at the time the puzzle was opened — used 39 /// only when no PlayerEntity row exists yet for this (game, author). 40 func begin(gameID: UUID, authorID: String, currentName: String) { 41 self.gameID = gameID 42 self.authorID = authorID 43 fallbackName = currentName 44 pending = nil 45 lastPublished = nil 46 debounceTask?.cancel() 47 debounceTask = nil 48 } 49 50 /// Registers a new selection. Coalesces with any prior pending value and 51 /// schedules a trailing-edge flush. Repeated identical selections are 52 /// dropped. 53 func publish(_ selection: PlayerSelection) { 54 guard gameID != nil, authorID != nil else { return } 55 if pending == selection || (pending == nil && lastPublished == selection) { 56 return 57 } 58 pending = selection 59 scheduleDebounce() 60 } 61 62 /// Records a "no selection" — used on puzzle teardown so the peer's 63 /// outline disappears promptly instead of waiting for staleness. 64 func clear() { 65 guard gameID != nil, authorID != nil else { return } 66 pending = nil 67 debounceTask?.cancel() 68 debounceTask = nil 69 Task { await self.flushClear() } 70 } 71 72 /// Flushes any pending selection immediately and cancels the debounce. 73 func flush() async { 74 debounceTask?.cancel() 75 debounceTask = nil 76 await performFlush() 77 } 78 79 private func scheduleDebounce() { 80 debounceTask?.cancel() 81 let interval = debounceInterval 82 debounceTask = Task { [weak self] in 83 try? await Task.sleep(for: interval) 84 if Task.isCancelled { return } 85 await self?.debouncedFlush() 86 } 87 } 88 89 private func debouncedFlush() async { 90 debounceTask = nil 91 await performFlush() 92 } 93 94 private func performFlush() async { 95 guard let gameID, let authorID, let selection = pending else { return } 96 if selection == lastPublished { return } 97 pending = nil 98 lastPublished = selection 99 await write(gameID: gameID, authorID: authorID, selection: selection) 100 await sink(gameID, authorID) 101 } 102 103 private func flushClear() async { 104 guard let gameID, let authorID else { return } 105 if lastPublished == nil { return } 106 lastPublished = nil 107 await write(gameID: gameID, authorID: authorID, selection: nil) 108 await sink(gameID, authorID) 109 } 110 111 private func write( 112 gameID: UUID, 113 authorID: String, 114 selection: PlayerSelection? 115 ) async { 116 let context = persistence.container.newBackgroundContext() 117 context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 118 let now = Date() 119 let fallbackName = self.fallbackName 120 context.performAndWait { 121 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 122 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 123 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 124 req.fetchLimit = 1 125 let entity: PlayerEntity 126 if let existing = try? context.fetch(req).first { 127 entity = existing 128 // Don't overwrite a name that PlayerNamePublisher has set; only 129 // backfill if it's missing or empty so the outgoing record is 130 // never `name=""`. 131 if (entity.name ?? "").isEmpty, !fallbackName.isEmpty { 132 entity.name = fallbackName 133 } 134 } else { 135 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 136 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 137 gameReq.fetchLimit = 1 138 guard let game = try? context.fetch(gameReq).first else { return } 139 entity = PlayerEntity(context: context) 140 entity.game = game 141 entity.ckRecordName = recordName 142 entity.authorID = authorID 143 if !fallbackName.isEmpty { 144 entity.name = fallbackName 145 } 146 } 147 entity.updatedAt = now 148 if let selection { 149 entity.selRow = NSNumber(value: Int64(selection.row)) 150 entity.selCol = NSNumber(value: Int64(selection.col)) 151 entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) 152 } else { 153 entity.selRow = nil 154 entity.selCol = nil 155 entity.selDir = nil 156 } 157 if context.hasChanges { 158 try? context.save() 159 } 160 } 161 } 162 }