PlayerSelectionPublisher.swift (11535B)
1 import CoreData 2 import Foundation 3 4 /// Debounced writer for the local player's cursor track. 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-track 7 /// edits don't go through `MovesUpdater` because they aren't cell edits — they 8 /// live on `PlayerEntity` with last-writer-wins semantics. 9 actor PlayerSelectionPublisher { 10 /// How long to wait before flushing the durable cursor write for `gameID`. 11 /// Injected (rather than a fixed `Duration`) so the caller can lengthen it 12 /// while an engagement room is live: the live websocket already carries the 13 /// cursor to the peer, so the durable PlayerEntity/CloudKit write is only a 14 /// post-disconnect fallback and can lag well behind. A longer interval there 15 /// cuts the bulk of the Player-record traffic during co-solving, while the 16 /// trailing flush still lands a resting position on each pause so the 17 /// fallback stays usable. Defaults to a flat 500 ms so tests and 18 /// non-collaborative contexts keep the prior single-interval behaviour. 19 /// `@MainActor` so the provider can read main-actor state (the engagement 20 /// liveness flag) directly; the `await` at the call site does the hop. 21 private let debounceInterval: @MainActor @Sendable (UUID) async -> Duration 22 private let persistence: PersistenceController 23 /// Pushes the local Player record. The `Bool` is `drain`: `true` forces an 24 /// immediate CKSyncEngine send (live cursor updates), `false` enqueues 25 /// durably and lets the engine ship on its own schedule — used by the 26 /// leave-path clear, which mustn't race the suspension budget. 27 private let sink: @Sendable (UUID, String, Bool) async -> Void 28 /// Returns `true` if any non-local peer currently has a fresh cursor track. 29 /// When the predicate returns `false` the publisher still writes the local 30 /// PlayerEntity row but skips the CloudKit enqueue — the cursor track is 31 /// presence data nobody is watching for. Defaults to "always present" so 32 /// callers that don't care about the gate (tests, contexts without peer 33 /// info) get the pre-gate behaviour. 34 private let peerPresent: @Sendable (UUID) async -> Bool 35 /// Sleep primitive used by the debounce timer. Injected so tests can 36 /// drive flushes deterministically instead of racing against wall-clock 37 /// `Task.sleep` from the actor's own task queue. Mirrors the pattern in 38 /// `MovesUpdater`. 39 private let sleep: @Sendable (Duration) async throws -> Void 40 41 private var pending: PlayerSelection? 42 private var lastPublished: PlayerSelection? 43 private var debounceTask: Task<Void, Never>? 44 private var gameID: UUID? 45 private var authorID: String? 46 /// The local user's display name at session start. Used as a fallback 47 /// when `write` has to insert a fresh `PlayerEntity` row before 48 /// `PlayerNamePublisher` has fanned out — without it the row would have no 49 /// name and the SyncEngine would refuse to build a CKRecord, dropping 50 /// the cursor on the floor. 51 private var fallbackName: String = "" 52 53 init( 54 debounceInterval: @escaping @MainActor @Sendable (UUID) async -> Duration = { _ in .milliseconds(500) }, 55 persistence: PersistenceController, 56 sink: @escaping @Sendable (UUID, String, Bool) async -> Void, 57 peerPresent: @escaping @Sendable (UUID) async -> Bool = { _ in true }, 58 sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } 59 ) { 60 self.debounceInterval = debounceInterval 61 self.persistence = persistence 62 self.sink = sink 63 self.peerPresent = peerPresent 64 self.sleep = sleep 65 } 66 67 /// Starts publishing for a new puzzle session. Resets dedupe state so the 68 /// first selection from the new session always flushes. `currentName` is 69 /// the local user's display name at the time the puzzle was opened — used 70 /// only when no PlayerEntity row exists yet for this (game, author). 71 func begin(gameID: UUID, authorID: String, currentName: String) { 72 self.gameID = gameID 73 self.authorID = authorID 74 fallbackName = currentName 75 pending = nil 76 lastPublished = nil 77 debounceTask?.cancel() 78 debounceTask = nil 79 } 80 81 /// Registers a new cursor track. Coalesces with any prior pending value 82 /// and schedules a trailing-edge flush. Repeated identical tracks are 83 /// dropped. 84 func publish(_ selection: PlayerSelection) { 85 guard gameID != nil, authorID != nil else { return } 86 if pending == selection || (pending == nil && lastPublished == selection) { 87 return 88 } 89 pending = selection 90 scheduleDebounce() 91 } 92 93 /// Records a "no cursor track" — used on puzzle teardown so the peer's 94 /// overlay disappears promptly instead of waiting for staleness. Awaits 95 /// the flush so callers can sequence work that depends on the cleared 96 /// state being visible to the sink (and so tests don't have to race a 97 /// detached Task). 98 func clear() async { 99 guard gameID != nil, authorID != nil else { return } 100 pending = nil 101 debounceTask?.cancel() 102 debounceTask = nil 103 await flushClear() 104 } 105 106 /// Flushes any pending selection immediately and cancels the debounce. 107 func flush() async { 108 debounceTask?.cancel() 109 debounceTask = nil 110 await performFlush() 111 } 112 113 /// Publishes `selection` synchronously, skipping the trailing-edge 114 /// debounce and discarding any pending debounce. Used by the 115 /// puzzle-open path so the initial cursor track ships in the same 116 /// CKSyncEngine drain as the read cursor and name-open enqueues 117 /// instead of arriving ~500 ms later. 118 func publishImmediately(_ selection: PlayerSelection) async { 119 guard let gameID, let authorID else { return } 120 if selection == lastPublished { return } 121 debounceTask?.cancel() 122 debounceTask = nil 123 pending = nil 124 lastPublished = selection 125 await write(gameID: gameID, authorID: authorID, selection: selection) 126 await sink(gameID, authorID, true) 127 } 128 129 private func scheduleDebounce() { 130 debounceTask?.cancel() 131 guard let gameID else { return } 132 let resolveInterval = debounceInterval 133 let sleep = self.sleep 134 debounceTask = Task { [weak self] in 135 let interval = await resolveInterval(gameID) 136 if Task.isCancelled { return } 137 try? await sleep(interval) 138 if Task.isCancelled { return } 139 await self?.debouncedFlush() 140 } 141 } 142 143 private func debouncedFlush() async { 144 debounceTask = nil 145 await performFlush() 146 } 147 148 private func performFlush() async { 149 guard let gameID, let authorID, let selection = pending else { return } 150 if selection == lastPublished { return } 151 // Always keep the local PlayerEntity row in sync — that's the 152 // source of truth for the eventual outbound CKRecord, so the row 153 // must reflect the latest selection whether or not we're shipping 154 // it right now. 155 await write(gameID: gameID, authorID: authorID, selection: selection) 156 // If no peer is currently in the puzzle, hold the send. Keep 157 // `pending` set (and don't advance `lastPublished`) so that a peer 158 // joining later triggers a flush of the latest value. 159 if !(await peerPresent(gameID)) { return } 160 pending = nil 161 lastPublished = selection 162 await sink(gameID, authorID, true) 163 } 164 165 /// Called when peer-presence state may have changed (e.g. an inbound 166 /// Player record updated a peer's cursor track). Re-evaluates the gate 167 /// and ships any held `pending` selection. `gameIDs`, if provided, scopes 168 /// the call to player records in those games — a no-op if the active 169 /// session isn't one of them. 170 /// 171 /// Only resumes when no debounce is currently scheduled — i.e. the 172 /// pending selection is being held by the gate, not by the normal 173 /// trailing-edge debounce. Otherwise a co-solving partner whose own 174 /// cursor updates pre-empt every one of our debounce windows would 175 /// pressure us into one CloudKit write per partner-move instead of the 176 /// 500 ms-throttled cadence. 177 func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async { 178 guard let gameID, pending != nil, debounceTask == nil else { return } 179 if let gameIDs, !gameIDs.contains(gameID) { return } 180 await performFlush() 181 } 182 183 private func flushClear() async { 184 guard let gameID, let authorID else { return } 185 if lastPublished == nil { return } 186 lastPublished = nil 187 await write(gameID: gameID, authorID: authorID, selection: nil) 188 // Leave-path clear: enqueue durably but don't force a drain that would 189 // race the suspension budget. The peer's live overlay is already gone 190 // via the socket tear-down; this is the durable cleanup. 191 await sink(gameID, authorID, false) 192 } 193 194 private func write( 195 gameID: UUID, 196 authorID: String, 197 selection: PlayerSelection? 198 ) async { 199 let context = persistence.container.newBackgroundContext() 200 context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 201 let now = Date() 202 let fallbackName = self.fallbackName 203 context.performAndWait { 204 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 205 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 206 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 207 req.fetchLimit = 1 208 let entity: PlayerEntity 209 if let existing = try? context.fetch(req).first { 210 entity = existing 211 // Don't overwrite a name that PlayerNamePublisher has set; only 212 // backfill if it's missing or empty so the outgoing record is 213 // never `name=""`. 214 if (entity.name ?? "").isEmpty, !fallbackName.isEmpty { 215 entity.name = fallbackName 216 } 217 } else { 218 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 219 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 220 gameReq.fetchLimit = 1 221 guard let game = try? context.fetch(gameReq).first else { return } 222 entity = PlayerEntity(context: context) 223 entity.game = game 224 entity.ckRecordName = recordName 225 entity.authorID = authorID 226 if !fallbackName.isEmpty { 227 entity.name = fallbackName 228 } 229 } 230 entity.updatedAt = now 231 if let selection { 232 entity.selRow = NSNumber(value: Int64(selection.row)) 233 entity.selCol = NSNumber(value: Int64(selection.col)) 234 entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) 235 } else { 236 entity.selRow = nil 237 entity.selCol = nil 238 entity.selDir = nil 239 } 240 if context.hasChanges { 241 try? context.save() 242 } 243 } 244 } 245 }