crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }