PlayerRoster.swift (14897B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Observation 5 6 /// Observable view-model that represents all participants (local + remote) 7 /// in a single shared game. Drives the "Players" menu in `PuzzleView`. 8 @Observable 9 @MainActor 10 final class PlayerRoster { 11 12 struct Entry: Identifiable { 13 let authorID: String 14 let name: String 15 let color: PlayerColor 16 let isLocal: Bool 17 var id: String { authorID } 18 } 19 20 struct RemoteSelection: Equatable { 21 let authorID: String 22 let row: Int 23 let col: Int 24 let direction: Puzzle.Direction 25 let color: PlayerColor 26 let updatedAt: Date 27 } 28 29 private struct RawSelection { 30 let authorID: String 31 let row: Int 32 let col: Int 33 let direction: Puzzle.Direction 34 let updatedAt: Date 35 } 36 37 /// Peer cursors keyed by `authorID`. Stale entries (older than the 38 /// freshness window) are dropped on each refresh. The local player is 39 /// never present in this map. 40 private(set) var remoteSelections: [String: RemoteSelection] = [:] 41 42 /// Selections older than this are treated as stale and hidden — covers 43 /// the case where the peer crashed or lost connectivity without writing 44 /// a "cleared" record. The polling interval is 5s plus push, so 60s is 45 /// generous enough to ride out a brief network hiccup. 46 private let selectionFreshnessWindow: TimeInterval = 60 47 48 private(set) var entries: [Entry] = [] 49 private(set) var localAuthorID: String? 50 51 private let gameID: UUID 52 private let colorStore: GamePlayerColorStore 53 private let authorIdentity: AuthorIdentity 54 private let preferences: PlayerPreferences 55 private let persistence: PersistenceController 56 private let container: CKContainer 57 private let tracer: (@MainActor @Sendable (String) -> Void)? 58 59 private var cachedShare: CKShare? 60 private var observationTasks: [Task<Void, Never>] = [] 61 private var lastTracedSignature: String? 62 63 init( 64 gameID: UUID, 65 colorStore: GamePlayerColorStore, 66 authorIdentity: AuthorIdentity, 67 preferences: PlayerPreferences, 68 persistence: PersistenceController, 69 container: CKContainer, 70 tracer: (@MainActor @Sendable (String) -> Void)? = nil 71 ) { 72 self.gameID = gameID 73 self.colorStore = colorStore 74 self.authorIdentity = authorIdentity 75 self.preferences = preferences 76 self.persistence = persistence 77 self.container = container 78 self.tracer = tracer 79 startObserving() 80 } 81 82 isolated deinit { 83 for task in observationTasks { 84 task.cancel() 85 } 86 } 87 88 // MARK: - Observation 89 90 private func startObserving() { 91 let gameID = self.gameID 92 93 // Local colour assignments changed (from `setColor` / collision 94 // resolution / gc) — refresh so the menu reflects the new mapping. 95 observationTasks.append( 96 Task { [weak self] in 97 for await note in NotificationCenter.default.notifications( 98 named: .gamePlayerColorsChanged 99 ) { 100 guard let self else { return } 101 guard let id = note.userInfo?["gameID"] as? UUID, 102 id == gameID else { continue } 103 await self.refresh() 104 } 105 } 106 ) 107 108 // Remote record changes affecting this game — name records arriving 109 // from other participants, new participants joining (move records 110 // carry a fresh authorID), share metadata changes. Invalidate the 111 // cached share so the next refresh re-fetches it; participant lists 112 // may have moved. 113 observationTasks.append( 114 Task { [weak self] in 115 for await note in NotificationCenter.default.notifications( 116 named: .playerRosterShouldRefresh 117 ) { 118 guard let self else { return } 119 guard let ids = note.userInfo?["gameIDs"] as? Set<UUID>, 120 ids.contains(gameID) else { continue } 121 self.cachedShare = nil 122 await self.refresh() 123 } 124 } 125 ) 126 } 127 128 // MARK: - Refresh 129 130 func refresh() async { 131 // Without a known local authorID we can't classify any participant as 132 // self vs. remote, so the only safe answer is an empty roster. The 133 // next refresh (after AuthorIdentity populates) will do the real work. 134 guard let localAuthorID = authorIdentity.currentID else { 135 self.localAuthorID = nil 136 entries = [] 137 remoteSelections = [:] 138 return 139 } 140 self.localAuthorID = localAuthorID 141 142 // Pull Core Data fields off a background context. 143 let ctx = persistence.container.newBackgroundContext() 144 let (databaseScope, ckShareRecordName, ckZoneName, ckZoneOwnerName, namesMap, moveAuthorIDs, rawSelections) = 145 ctx.performAndWait { () -> (Int16, String?, String?, String?, [String: String], [String], [RawSelection]) in 146 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 147 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 148 req.fetchLimit = 1 149 guard let entity = try? ctx.fetch(req).first else { 150 return (0, nil, nil, nil, [:], [], []) 151 } 152 let nameReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 153 nameReq.predicate = NSPredicate(format: "game == %@", entity) 154 let nameEntities = (try? ctx.fetch(nameReq)) ?? [] 155 var namesMap: [String: String] = [:] 156 var selections: [RawSelection] = [] 157 for nr in nameEntities { 158 guard let aid = nr.authorID, !aid.isEmpty else { continue } 159 if let name = nr.name, !name.isEmpty { 160 namesMap[aid] = name 161 } 162 if aid == localAuthorID { continue } 163 if let row = nr.selRow, 164 let col = nr.selCol, 165 let dir = nr.selDir, 166 let direction = Puzzle.Direction(rawValue: dir.intValue), 167 let updatedAt = nr.updatedAt { 168 selections.append(RawSelection( 169 authorID: aid, 170 row: row.intValue, 171 col: col.intValue, 172 direction: direction, 173 updatedAt: updatedAt 174 )) 175 } 176 } 177 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 178 movesReq.predicate = NSPredicate(format: "game == %@", entity) 179 let movesEntities = (try? ctx.fetch(movesReq)) ?? [] 180 let authorIDs = Array( 181 Set(movesEntities.compactMap { $0.authorID }) 182 .subtracting([localAuthorID, CKCurrentUserDefaultName, ""]) 183 ) 184 return ( 185 entity.databaseScope, 186 entity.ckShareRecordName, 187 entity.ckZoneName, 188 entity.ckZoneOwnerName, 189 namesMap, 190 authorIDs, 191 selections 192 ) 193 } 194 195 applyRoster(localAuthorID: localAuthorID, namesMap: namesMap, moveAuthorIDs: moveAuthorIDs, rawSelections: rawSelections, share: nil) 196 197 // Fetch the CKShare if not already cached. This can be noticeably 198 // slower on device, so publish the local Core Data roster first and 199 // then refine names/participants if share metadata arrives. 200 let share = await fetchShare( 201 databaseScope: databaseScope, 202 ckShareRecordName: ckShareRecordName, 203 ckZoneName: ckZoneName, 204 ckZoneOwnerName: ckZoneOwnerName 205 ) 206 applyRoster(localAuthorID: localAuthorID, namesMap: namesMap, moveAuthorIDs: moveAuthorIDs, rawSelections: rawSelections, share: share) 207 } 208 209 private func applyRoster( 210 localAuthorID: String, 211 namesMap: [String: String], 212 moveAuthorIDs: [String], 213 rawSelections: [RawSelection], 214 share: CKShare? 215 ) { 216 // Collect all remote participant authorIDs. 217 var otherAuthorIDs = Set<String>() 218 for key in namesMap.keys 219 where key != localAuthorID 220 && key != CKCurrentUserDefaultName 221 && !key.isEmpty { 222 otherAuthorIDs.insert(key) 223 } 224 if let share { 225 for participant in share.participants { 226 guard participant.acceptanceStatus == .accepted, 227 let recordName = participant.userIdentity.userRecordID?.recordName, 228 recordName != localAuthorID, 229 recordName != CKCurrentUserDefaultName 230 else { continue } 231 otherAuthorIDs.insert(recordName) 232 } 233 } 234 for authorID in moveAuthorIDs where authorID != CKCurrentUserDefaultName { 235 otherAuthorIDs.insert(authorID) 236 } 237 238 // Diagnostic — surfaces the inputs that drive the entries list so we 239 // can tell whether a "ghost" authorID came from a stale PlayerEntity, 240 // a stray MovesEntity, or the share's participant list. Trim noisy 241 // re-entries by only emitting when the signature actually changes. 242 if let tracer { 243 let participantIDs: [String] = share?.participants.compactMap { 244 $0.userIdentity.userRecordID?.recordName 245 } ?? [] 246 let signature = "local=\(localAuthorID) | names=\(namesMap.keys.sorted()) | moves=\(moveAuthorIDs.sorted()) | share=\(participantIDs.sorted())" 247 if signature != lastTracedSignature { 248 lastTracedSignature = signature 249 tracer("PlayerRoster[\(gameID.uuidString.prefix(8))]: \(signature)") 250 } 251 } 252 253 // Build remote entries, assigning stable colours. 254 var remoteEntries: [Entry] = [] 255 let reservedColorIDs: Set<String> = [preferences.color.id] 256 for authorID in otherAuthorIDs.sorted() { 257 let name = resolveName(authorID: authorID, namesMap: namesMap) 258 let color = colorStore.ensureColor( 259 forGame: gameID, 260 authorID: authorID, 261 reservedColorIDs: reservedColorIDs 262 ) 263 remoteEntries.append(Entry(authorID: authorID, name: name, color: color, isLocal: false)) 264 } 265 remoteEntries.sort { 266 $0.name == $1.name ? $0.authorID < $1.authorID : $0.name < $1.name 267 } 268 269 // Garbage-collect stale colour entries. 270 let currentRemoteIDs = Set(remoteEntries.map { $0.authorID }) 271 for staleID in colorStore.storedAuthorIDs(forGame: gameID).subtracting(currentRemoteIDs) { 272 colorStore.clearColor(forGame: gameID, authorID: staleID, notify: false) 273 } 274 275 let localEntry = Entry( 276 authorID: localAuthorID, 277 name: preferences.name, 278 color: preferences.color, 279 isLocal: true 280 ) 281 entries = [localEntry] + remoteEntries 282 283 // Map raw selections to the resolved colour from the entry list, 284 // dropping anything stale or with no matching entry. 285 let colorByAuthor = Dictionary( 286 uniqueKeysWithValues: remoteEntries.map { ($0.authorID, $0.color) } 287 ) 288 let now = Date() 289 var fresh: [String: RemoteSelection] = [:] 290 for raw in rawSelections { 291 guard let color = colorByAuthor[raw.authorID] else { continue } 292 guard now.timeIntervalSince(raw.updatedAt) < selectionFreshnessWindow else { 293 continue 294 } 295 fresh[raw.authorID] = RemoteSelection( 296 authorID: raw.authorID, 297 row: raw.row, 298 col: raw.col, 299 direction: raw.direction, 300 color: color, 301 updatedAt: raw.updatedAt 302 ) 303 } 304 remoteSelections = fresh 305 } 306 307 // MARK: - Collision resolution 308 309 /// When the local user picks a colour that collides with a remote entry, 310 /// silently reassign the victim to the first free palette colour. 311 func reassignOnLocalColorChange(newColor: PlayerColor) async { 312 guard let victim = entries.first(where: { !$0.isLocal && $0.color.id == newColor.id }) else { 313 return 314 } 315 let taken = Set([newColor.id]).union( 316 colorStore.assignedColorIDs(forGame: gameID, excludingAuthorID: victim.authorID) 317 ) 318 let replacement = PlayerColor.palette.first { !taken.contains($0.id) } 319 ?? PlayerColor.palette.randomElement() 320 ?? .blue 321 colorStore.setColor(replacement, forGame: gameID, authorID: victim.authorID) 322 await refresh() 323 } 324 325 // MARK: - Private helpers 326 327 private func fetchShare( 328 databaseScope: Int16, 329 ckShareRecordName: String?, 330 ckZoneName: String?, 331 ckZoneOwnerName: String? 332 ) async -> CKShare? { 333 if let cached = cachedShare { return cached } 334 guard let zoneName = ckZoneName else { return nil } 335 let ownerName = ckZoneOwnerName ?? CKCurrentUserDefaultName 336 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 337 do { 338 if databaseScope == 0, let shareRecordName = ckShareRecordName { 339 let shareID = CKRecord.ID(recordName: shareRecordName, zoneID: zoneID) 340 let share = try await container.privateCloudDatabase.record(for: shareID) as? CKShare 341 cachedShare = share 342 return share 343 } else if databaseScope == 1 { 344 let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID) 345 let share = try await container.sharedCloudDatabase.record(for: shareID) as? CKShare 346 cachedShare = share 347 return share 348 } 349 } catch { 350 // Best effort — proceed without share metadata. 351 } 352 return nil 353 } 354 355 private func resolveName(authorID: String, namesMap: [String: String]) -> String { 356 // The game-specific Player record is authoritative for display names. 357 // CKShare metadata can arrive earlier, but it may expose an unrelated 358 // contact/iCloud name and then visibly rename the row a moment later. 359 if let name = namesMap[authorID], !name.isEmpty { return name } 360 return "Waiting for player..." 361 } 362 }