crossmate

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

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 }