PlayerRoster.swift (23411B)
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: Equatable, 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 /// The Core Data snapshot read off the background context in `refresh()`. 38 /// A struct rather than a tuple so the empty-game path, the populated 39 /// return, and the consumers stay in sync by name rather than position. 40 private struct FetchedRoster { 41 var databaseScope: Int16 = 0 42 var ckShareRecordName: String? 43 var ckZoneName: String? 44 var ckZoneOwnerName: String? 45 var namesMap: [String: String] = [:] 46 /// The user's private nicknames (`FriendEntity.nickname`), keyed by 47 /// authorID. A nickname overrides the peer's own published name 48 /// everywhere the roster surfaces it (players menu, cursor chips, 49 /// presence traces). 50 var nicknamesByAuthor: [String: String] = [:] 51 var moveAuthorIDs: [String] = [] 52 var rawSelections: [RawSelection] = [] 53 var readAtByAuthor: [String: Date] = [:] 54 } 55 56 /// Last-known peer cursor tracks keyed by `authorID`, from the synced 57 /// Player record. Held unconditionally; visibility is decided at read time 58 /// by the presence gate in `remoteSelections`. The local player is never 59 /// present in this map. 60 private var persistedRemoteSelections: [String: RemoteSelection] = [:] 61 62 /// Each non-local peer's active-session lease (`Player.readAt`). A cursor 63 /// is shown only while its peer is present (`PeerPresence.isPresent`), so a 64 /// peer who pauses keeps their cursor and a departed peer's cursor clears 65 /// when the lease lapses — the same heuristic that gates engagement. 66 private var remoteReadAt: [String: Date] = [:] 67 68 /// The non-local authorIDs whose lease last read as present, so each 69 /// present↔absent edge is logged once rather than on every refresh. This 70 /// is the same gate that shows/hides a peer's cursor, so the log records 71 /// *when* a remote player actually left — the one thing a co-solve log was 72 /// previously blind to. 73 private var lastPresentAuthors: Set<String> = [] 74 75 var remoteSelections: [String: RemoteSelection] { 76 var merged = persistedRemoteSelections 77 let colorByAuthor = Dictionary( 78 uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.color) } 79 ) 80 for (authorID, engagement) in engagementStore.selections(for: gameID) { 81 guard let color = colorByAuthor[authorID] else { continue } 82 let selection = RemoteSelection( 83 authorID: authorID, 84 row: engagement.row, 85 col: engagement.col, 86 direction: engagement.direction, 87 color: color, 88 updatedAt: engagement.updatedAt 89 ) 90 if merged[authorID].map({ $0.updatedAt <= selection.updatedAt }) ?? true { 91 merged[authorID] = selection 92 } 93 } 94 let now = Date() 95 return merged.filter { PeerPresence.isPresent(readAt: remoteReadAt[$0.key], asOf: now) } 96 } 97 98 private(set) var entries: [Entry] = [] 99 private(set) var localAuthorID: String? 100 101 /// Active solve time across every player in this game as of `now` — the 102 /// length of the union of all devices' play intervals, so simultaneous 103 /// co-solving counts once and disjoint play sums. In-progress sessions are 104 /// extrapolated to `now`, so the puzzle clock ticks between syncs simply by 105 /// re-reading this with a fresh date. Once the game is finished the union is 106 /// bounded at `completedAt`, freezing the displayed value at the win. 107 func solveTime(asOf now: Date = Date()) -> TimeInterval { 108 guard !isStaticPreview else { return 0 } 109 let context = persistence.container.viewContext 110 111 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 112 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 113 gameRequest.fetchLimit = 1 114 let game = try? context.fetch(gameRequest).first 115 // A materialised archive has no `timeLog` rows to union — it carries the 116 // frozen final time (whole seconds) the live clock reached. 117 if let stored = game?.finalSolveSeconds { 118 return TimeInterval(stored.int64Value) 119 } 120 let asOf = game?.completedAt.map { min(now, $0) } ?? now 121 122 let playerRequest = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 123 playerRequest.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 124 let logs = ((try? context.fetch(playerRequest)) ?? []).map { TimeLog.decode($0.timeLog) } 125 return TimeLog.accumulatedSeconds( 126 forLogs: logs, 127 localDeviceID: RecordSerializer.localDeviceID, 128 asOf: asOf 129 ) 130 } 131 132 private let gameID: UUID 133 private let authorIdentity: AuthorIdentity 134 private let preferences: PlayerPreferences 135 private let persistence: PersistenceController 136 private let container: CKContainer 137 private let engagementStore: EngagementStore 138 private let tracer: (@MainActor @Sendable (String) -> Void)? 139 private let isStaticPreview: Bool 140 141 private var cachedShare: CKShare? 142 private var observationTasks: [Task<Void, Never>] = [] 143 private var lastTracedSignature: String? 144 private var refreshGeneration = 0 145 /// One-shot recompute scheduled for the soonest peer lease expiry, so a 146 /// departed peer's ghost cursor clears precisely when its lease lapses 147 /// rather than waiting for an unrelated record to nudge a refresh. 148 private var leaseExpiryTask: Task<Void, Never>? 149 150 init( 151 gameID: UUID, 152 authorIdentity: AuthorIdentity, 153 preferences: PlayerPreferences, 154 persistence: PersistenceController, 155 container: CKContainer, 156 engagementStore: EngagementStore = EngagementStore(), 157 tracer: (@MainActor @Sendable (String) -> Void)? = nil 158 ) { 159 self.gameID = gameID 160 self.authorIdentity = authorIdentity 161 self.preferences = preferences 162 self.persistence = persistence 163 self.container = container 164 self.engagementStore = engagementStore 165 self.tracer = tracer 166 self.isStaticPreview = false 167 startObserving() 168 } 169 170 init( 171 previewGameID gameID: UUID, 172 localName: String, 173 localColor: PlayerColor, 174 remoteSelection: RemoteSelection 175 ) { 176 self.gameID = gameID 177 self.authorIdentity = AuthorIdentity(testing: "marketing-local") 178 self.preferences = PlayerPreferences() 179 self.persistence = PersistenceController(inMemory: true) 180 self.container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 181 self.engagementStore = EngagementStore() 182 self.tracer = nil 183 self.isStaticPreview = true 184 self.localAuthorID = "marketing-local" 185 self.entries = [ 186 Entry(authorID: "marketing-local", name: localName, color: localColor, isLocal: true), 187 Entry( 188 authorID: remoteSelection.authorID, 189 name: "Teammate", 190 color: remoteSelection.color, 191 isLocal: false 192 ), 193 ] 194 self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection] 195 self.remoteReadAt = [remoteSelection.authorID: Date().addingTimeInterval(600)] 196 } 197 198 isolated deinit { 199 for task in observationTasks { 200 task.cancel() 201 } 202 leaseExpiryTask?.cancel() 203 } 204 205 // MARK: - Observation 206 207 private func startObserving() { 208 let gameID = self.gameID 209 210 // Roster-relevant remote changes for this game — a peer's Player 211 // record (name / cursor), a Game record, a deletion, or a new 212 // contributor's first Moves row (see `BatchEffects.rosterRelevant`). 213 // `refresh()` discovers participants from PlayerEntity, the CKShare, 214 // and Moves authorIDs, so a brand-new collaborator surfaces here even 215 // before their Player record arrives; repeat moves from a known author 216 // don't post, since they add nothing to the roster. Invalidate the 217 // cached share so the next refresh re-fetches it; participant lists 218 // may have moved. 219 observationTasks.append( 220 Task { [weak self] in 221 for await note in NotificationCenter.default.notifications( 222 named: .playerRosterShouldRefresh 223 ) { 224 guard let self else { return } 225 guard let ids = note.userInfo?["gameIDs"] as? Set<UUID>, 226 ids.contains(gameID) else { continue } 227 self.cachedShare = nil 228 await self.refresh() 229 } 230 } 231 ) 232 } 233 234 // MARK: - Refresh 235 236 func refresh() async { 237 guard !isStaticPreview else { return } 238 refreshGeneration += 1 239 let generation = refreshGeneration 240 // Without a known local authorID we can't classify any participant as 241 // self vs. remote, so the only safe answer is an empty roster. The 242 // next refresh (after AuthorIdentity populates) will do the real work. 243 guard let localAuthorID = authorIdentity.currentID else { 244 self.localAuthorID = nil 245 entries = [] 246 persistedRemoteSelections = [:] 247 remoteReadAt = [:] 248 leaseExpiryTask?.cancel() 249 leaseExpiryTask = nil 250 return 251 } 252 self.localAuthorID = localAuthorID 253 254 // Pull Core Data fields off a background context. 255 let ctx = persistence.container.newBackgroundContext() 256 let fetched = ctx.performAndWait { () -> FetchedRoster in 257 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 258 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 259 req.fetchLimit = 1 260 guard let entity = try? ctx.fetch(req).first else { 261 return FetchedRoster() 262 } 263 let nameReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 264 nameReq.predicate = NSPredicate(format: "game == %@", entity) 265 let nameEntities = (try? ctx.fetch(nameReq)) ?? [] 266 var namesMap: [String: String] = [:] 267 var selections: [RawSelection] = [] 268 var readAtByAuthor: [String: Date] = [:] 269 for nr in nameEntities { 270 guard let aid = nr.authorID, !aid.isEmpty else { continue } 271 if let name = nr.name, !name.isEmpty { 272 namesMap[aid] = name 273 } 274 if aid == localAuthorID { continue } 275 if let readAt = nr.readAt { 276 readAtByAuthor[aid] = readAt 277 } 278 if let row = nr.selRow, 279 let col = nr.selCol, 280 let dir = nr.selDir, 281 let direction = Puzzle.Direction(rawValue: dir.intValue), 282 let updatedAt = nr.updatedAt { 283 selections.append(RawSelection( 284 authorID: aid, 285 row: row.intValue, 286 col: col.intValue, 287 direction: direction, 288 updatedAt: updatedAt 289 )) 290 } 291 } 292 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 293 movesReq.predicate = NSPredicate(format: "game == %@", entity) 294 let movesEntities = (try? ctx.fetch(movesReq)) ?? [] 295 let authorIDs = Array( 296 Set(movesEntities.compactMap { $0.authorID }) 297 .subtracting([localAuthorID, CKCurrentUserDefaultName, ""]) 298 ) 299 let nicknameReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 300 nicknameReq.predicate = NSPredicate( 301 format: "isBlocked == NO AND nickname != nil AND nickname != %@", "" 302 ) 303 var nicknamesByAuthor: [String: String] = [:] 304 for friend in (try? ctx.fetch(nicknameReq)) ?? [] { 305 guard let aid = friend.authorID, !aid.isEmpty, 306 let nickname = friend.nickname, !nickname.isEmpty 307 else { continue } 308 nicknamesByAuthor[aid] = nickname 309 } 310 return FetchedRoster( 311 databaseScope: entity.databaseScope, 312 ckShareRecordName: entity.ckShareRecordName, 313 ckZoneName: entity.ckZoneName, 314 ckZoneOwnerName: entity.ckZoneOwnerName, 315 namesMap: namesMap, 316 nicknamesByAuthor: nicknamesByAuthor, 317 moveAuthorIDs: authorIDs, 318 rawSelections: selections, 319 readAtByAuthor: readAtByAuthor 320 ) 321 } 322 323 applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: nil) 324 325 // Fetch the CKShare if not already cached. This can be noticeably 326 // slower on device, so publish the local Core Data roster first and 327 // then refine names/participants if share metadata arrives. 328 let share = await fetchShare( 329 databaseScope: fetched.databaseScope, 330 ckShareRecordName: fetched.ckShareRecordName, 331 ckZoneName: fetched.ckZoneName, 332 ckZoneOwnerName: fetched.ckZoneOwnerName, 333 generation: generation 334 ) 335 guard generation == refreshGeneration else { return } 336 applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: share) 337 scheduleLeaseExpiryRecompute() 338 // Trace only the post-fetch roster. The interim publish above always 339 // has `share: nil` and would otherwise emit a second signature on 340 // every refresh, defeating the dedup and producing the 341 // `share=[]` ↔ `share=[…]` flicker seen in the logs. 342 traceRoster( 343 localAuthorID: localAuthorID, 344 namesMap: fetched.namesMap, 345 moveAuthorIDs: fetched.moveAuthorIDs, 346 share: share 347 ) 348 } 349 350 private func applyRoster( 351 localAuthorID: String, 352 fetched: FetchedRoster, 353 share: CKShare? 354 ) { 355 var shareAuthorIDs: [String] = [] 356 if let share { 357 for participant in share.participants { 358 guard participant.acceptanceStatus == .accepted, 359 let recordName = participant.userIdentity.userRecordID?.recordName 360 else { continue } 361 shareAuthorIDs.append(recordName) 362 } 363 } 364 365 // Assign each collaborator a colour for this game. Colours are derived 366 // from (authorID, gameID) and de-conflicted against each other and the 367 // local user's stable colour, so they are distinct within the game but 368 // deliberately vary from game to game — see 369 // `ParticipantSummaries.remoteParticipants`. 370 let remoteEntries = ParticipantSummaries.remoteParticipants( 371 gameID: gameID, 372 namesByAuthor: fetched.namesMap, 373 moveAuthorIDs: fetched.moveAuthorIDs, 374 nicknamesByAuthor: fetched.nicknamesByAuthor, 375 localAuthorID: localAuthorID, 376 localColor: preferences.color, 377 additionalAuthorIDs: shareAuthorIDs 378 ).map { participant in 379 Entry( 380 authorID: participant.authorID, 381 name: participant.name, 382 color: participant.color, 383 isLocal: false 384 ) 385 } 386 387 let localEntry = Entry( 388 authorID: localAuthorID, 389 name: preferences.name, 390 color: preferences.color, 391 isLocal: true 392 ) 393 let updatedEntries = [localEntry] + remoteEntries 394 if entries != updatedEntries { 395 entries = updatedEntries 396 } 397 398 // Map raw cursor tracks to the resolved colour from the entry list, 399 // dropping anything with no matching entry. Visibility (the presence 400 // gate) is applied lazily in `remoteSelections`, so the last-known 401 // track is retained here regardless of age. 402 let colorByAuthor = Dictionary( 403 uniqueKeysWithValues: remoteEntries.map { ($0.authorID, $0.color) } 404 ) 405 var tracks: [String: RemoteSelection] = [:] 406 for raw in fetched.rawSelections { 407 guard let color = colorByAuthor[raw.authorID] else { continue } 408 tracks[raw.authorID] = RemoteSelection( 409 authorID: raw.authorID, 410 row: raw.row, 411 col: raw.col, 412 direction: raw.direction, 413 color: color, 414 updatedAt: raw.updatedAt 415 ) 416 } 417 persistedRemoteSelections = tracks 418 remoteReadAt = fetched.readAtByAuthor 419 logPresenceTransitions() 420 } 421 422 /// Emits a tracer line on each non-local peer's present↔absent edge — the 423 /// same `readAt`-lease gate that drives their cursor (`remoteSelections`) 424 /// and the `leaseExpiryTask` recompute. Deduped via `lastPresentAuthors`, 425 /// so the interim and post-share `applyRoster` of one refresh, plus the 426 /// lease-expiry refresh, log a transition only once. A departure prints the 427 /// lapsed lease and how long ago it expired, so the log can answer "when 428 /// did the peer leave?" instead of leaving it to inference. 429 private func logPresenceTransitions() { 430 guard let tracer else { return } 431 let now = Date() 432 let present = Set(remoteReadAt.keys.filter { 433 PeerPresence.isPresent(readAt: remoteReadAt[$0], asOf: now) 434 }) 435 guard present != lastPresentAuthors else { return } 436 let nameByAuthor = Dictionary( 437 uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.name) } 438 ) 439 let prefix = "PlayerRoster[\(gameID.uuidString.prefix(8))]" 440 func describe(_ authorID: String) -> String { 441 "\(authorID.prefix(8)) (\(nameByAuthor[authorID] ?? "?"))" 442 } 443 func iso(_ date: Date) -> String { 444 ISO8601DateFormatter().string(from: date) 445 } 446 for authorID in present.subtracting(lastPresentAuthors).sorted() { 447 let lease = remoteReadAt[authorID] 448 let until = lease.map(iso) ?? "—" 449 let secs = lease.map { Int($0.timeIntervalSince(now)) } ?? 0 450 tracer("\(prefix): peer \(describe(authorID)) present (lease until \(until), +\(secs)s)") 451 } 452 for authorID in lastPresentAuthors.subtracting(present).sorted() { 453 let lease = remoteReadAt[authorID] 454 let lapsed = lease.map(iso) ?? "—" 455 let ago = lease.map { Int(now.timeIntervalSince($0)) } ?? 0 456 tracer("\(prefix): peer \(describe(authorID)) no longer present (lease \(lapsed) lapsed \(ago)s ago)") 457 } 458 lastPresentAuthors = present 459 } 460 461 /// Schedules a single recompute at the soonest moment a present peer drops 462 /// out of presence — its `readAt` plus the presence grace, since a lapsed 463 /// lease still counts as present through the grace. When it fires, 464 /// `refresh()` re-reads `readAt` (reassigning `remoteReadAt`, which triggers 465 /// observation so the cursor and engagement icon re-evaluate) and 466 /// reschedules for the next-soonest. No-op when no peer is present. 467 private func scheduleLeaseExpiryRecompute() { 468 leaseExpiryTask?.cancel() 469 leaseExpiryTask = nil 470 let now = Date() 471 guard let soonest = remoteReadAt.values 472 .filter({ PeerPresence.isPresent(readAt: $0, asOf: now) }) 473 .min() else { return } 474 let interval = max(0, soonest.addingTimeInterval(PeerPresence.presenceGrace).timeIntervalSince(now)) 475 leaseExpiryTask = Task { [weak self] in 476 try? await Task.sleep(for: .seconds(interval)) 477 guard !Task.isCancelled, let self else { return } 478 await self.refresh() 479 } 480 } 481 482 // MARK: - Private helpers 483 484 /// Diagnostic — surfaces the inputs that drive the entries list so we 485 /// can tell whether a "ghost" authorID came from a stale `PlayerEntity`, 486 /// a stray `MovesEntity`, or the share's participant list. Called once 487 /// per refresh after the final `applyRoster` so the interim `share: nil` 488 /// publish doesn't push a second signature through and defeat the dedup. 489 private func traceRoster( 490 localAuthorID: String, 491 namesMap: [String: String], 492 moveAuthorIDs: [String], 493 share: CKShare? 494 ) { 495 guard let tracer else { return } 496 let participantIDs: [String] = share?.participants.compactMap { 497 $0.userIdentity.userRecordID?.recordName 498 } ?? [] 499 let signature = "local=\(localAuthorID) | names=\(namesMap.keys.sorted()) | moves=\(moveAuthorIDs.sorted()) | share=\(participantIDs.sorted())" 500 guard signature != lastTracedSignature else { return } 501 lastTracedSignature = signature 502 tracer("PlayerRoster[\(gameID.uuidString.prefix(8))]: \(signature)") 503 } 504 505 private func fetchShare( 506 databaseScope: Int16, 507 ckShareRecordName: String?, 508 ckZoneName: String?, 509 ckZoneOwnerName: String?, 510 generation: Int 511 ) async -> CKShare? { 512 if let cached = cachedShare { return cached } 513 guard let zoneName = ckZoneName else { return nil } 514 let ownerName = ckZoneOwnerName ?? CKCurrentUserDefaultName 515 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 516 do { 517 if databaseScope == 0, let shareRecordName = ckShareRecordName { 518 let shareID = CKRecord.ID(recordName: shareRecordName, zoneID: zoneID) 519 let share = try await container.privateCloudDatabase.record(for: shareID) as? CKShare 520 if generation == refreshGeneration { 521 cachedShare = share 522 } 523 return share 524 } else if databaseScope == 1 { 525 let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID) 526 let share = try await container.sharedCloudDatabase.record(for: shareID) as? CKShare 527 if generation == refreshGeneration { 528 cachedShare = share 529 } 530 return share 531 } 532 } catch { 533 // Best effort — proceed without share metadata. 534 } 535 return nil 536 } 537 538 }