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