crossmate

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

PlayerNamePublisher.swift (9414B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Observation
      5 
      6 /// The local user's monotonic display-name generation, UserDefaults-backed and
      7 /// keyed by authorID. Each rename bumps it by one; every copy of the name
      8 /// Decision written in that rename (account zone + friend zones) carries the
      9 /// same generation, and the send path's version-wins conflict rule lets a
     10 /// newer rename overwrite an older copy wherever they collide. Inbound echoes
     11 /// of our own Decision (another device renamed) are adopted via `adopt` so
     12 /// this device's next rename supersedes the account's highest known
     13 /// generation instead of colliding with it.
     14 enum NameVersionStore {
     15     private static func key(_ authorID: String) -> String {
     16         "NameVersionStore.version.\(authorID)"
     17     }
     18 
     19     /// Highest generation this device has published or seen. 0 until the
     20     /// first rename — friendship seeding publishes at this resting value so a
     21     /// seed never outranks (or ties) a real rename.
     22     static func current(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
     23         (defaults.object(forKey: key(authorID)) as? NSNumber)?.int64Value ?? 0
     24     }
     25 
     26     /// The generation for a new rename: one past everything seen. Stores.
     27     static func next(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
     28         let value = current(authorID: authorID, defaults: defaults) + 1
     29         defaults.set(NSNumber(value: value), forKey: key(authorID))
     30         return value
     31     }
     32 
     33     /// Adopts a generation observed on an inbound copy of our own Decision.
     34     static func adopt(
     35         _ version: Int64,
     36         authorID: String,
     37         defaults: UserDefaults = .standard
     38     ) {
     39         guard version > current(authorID: authorID, defaults: defaults) else { return }
     40         defaults.set(NSNumber(value: version), forKey: key(authorID))
     41     }
     42 }
     43 
     44 /// Observes `PlayerPreferences.name` and, when it changes, publishes the new
     45 /// name as a `name` Decision into the account zone (own-device convergence)
     46 /// and every non-blocked friend zone (how friends learn the rename). The
     47 /// fan-out therefore scales with friendships, not games, and reaches friends
     48 /// even after every shared game is completed or deleted.
     49 ///
     50 /// Per-game `Player` records still carry a snapshot of the name, written once
     51 /// at game open (`publishName(for:)`) — that's what collaborators see before
     52 /// a friendship's first name Decision lands, and what not-yet-friended
     53 /// participants fall back to.
     54 ///
     55 /// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
     56 /// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
     57 /// a single rename should produce exactly one fan-out.
     58 @MainActor
     59 final class PlayerNamePublisher {
     60     private let preferences: PlayerPreferences
     61     private let persistence: PersistenceController
     62     private let authorIdentity: AuthorIdentity
     63     private let enqueuePlayer: (UUID, String, String) async -> Void
     64     /// (authorID, name, version, zoneID, scope) → `SyncEngine.enqueueNameDecision`.
     65     private let enqueueNameDecision: (String, String, Int64, CKRecordZone.ID, Int16) async -> Void
     66 
     67     private var debounceTask: Task<Void, Never>?
     68     private var observationTask: Task<Void, Never>?
     69 
     70     /// Testing-only observer fired after each fan-out completes. Receives the
     71     /// name that was just broadcast. Production callers should leave this nil.
     72     var onFanOutForTesting: ((String) -> Void)?
     73 
     74     init(
     75         preferences: PlayerPreferences,
     76         persistence: PersistenceController,
     77         authorIdentity: AuthorIdentity,
     78         enqueuePlayer: @escaping (UUID, String, String) async -> Void,
     79         enqueueNameDecision: @escaping (String, String, Int64, CKRecordZone.ID, Int16) async -> Void
     80     ) {
     81         self.preferences = preferences
     82         self.persistence = persistence
     83         self.authorIdentity = authorIdentity
     84         self.enqueuePlayer = enqueuePlayer
     85         self.enqueueNameDecision = enqueueNameDecision
     86         startObserving()
     87     }
     88 
     89     private func startObserving() {
     90         observationTask = Task { [weak self] in
     91             guard let self else { return }
     92             while !Task.isCancelled {
     93                 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
     94                     withObservationTracking {
     95                         _ = self.preferences.name
     96                     } onChange: {
     97                         cont.resume()
     98                     }
     99                 }
    100                 guard !Task.isCancelled else { break }
    101                 self.scheduleDebounce()
    102             }
    103         }
    104     }
    105 
    106     private func scheduleDebounce() {
    107         debounceTask?.cancel()
    108         debounceTask = Task { [weak self] in
    109             do {
    110                 try await Task.sleep(for: .milliseconds(250))
    111             } catch {
    112                 return
    113             }
    114             guard let self, !Task.isCancelled else { return }
    115             await self.fanOut(newName: self.preferences.name)
    116         }
    117     }
    118 
    119     /// Publishes the local user's current name as a Decision to the account
    120     /// zone and every non-blocked friend zone, at a freshly bumped generation.
    121     func broadcastName() async {
    122         await fanOut(newName: preferences.name)
    123     }
    124 
    125     /// Writes the local user's name into one game's `PlayerEntity` row. Used
    126     /// when opening a shared puzzle so the game zone itself carries a name
    127     /// snapshot — collaborators who aren't friends yet (or whose friend zone
    128     /// hasn't delivered a name Decision) read this.
    129     func publishName(for gameID: UUID) async {
    130         guard let authorID = authorIdentity.currentID else { return }
    131 
    132         let ctx = persistence.container.newBackgroundContext()
    133         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    134         guard Self.upsertPlayerRecord(
    135             for: gameID,
    136             in: ctx,
    137             authorID: authorID,
    138             name: preferences.name
    139         ) else { return }
    140 
    141         await enqueuePlayer(gameID, authorID, "name-open")
    142     }
    143 
    144     private func fanOut(newName: String) async {
    145         guard let authorID = authorIdentity.currentID else { return }
    146         let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
    147         guard !name.isEmpty else { return }
    148 
    149         let version = NameVersionStore.next(authorID: authorID)
    150         await enqueueNameDecision(
    151             authorID, name, version, RecordSerializer.accountZoneID, 0
    152         )
    153         let ctx = persistence.container.newBackgroundContext()
    154         for target in Self.friendZoneTargets(in: ctx) {
    155             await enqueueNameDecision(
    156                 authorID, name, version, target.zoneID, target.scope
    157             )
    158         }
    159 
    160         onFanOutForTesting?(name)
    161     }
    162 
    163     /// Background-context work — main-actor isolation does not apply here.
    164     private nonisolated static func friendZoneTargets(
    165         in ctx: NSManagedObjectContext
    166     ) -> [(zoneID: CKRecordZone.ID, scope: Int16)] {
    167         ctx.performAndWait {
    168             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    169             req.predicate = NSPredicate(format: "isBlocked == NO")
    170             let friends = (try? ctx.fetch(req)) ?? []
    171             return friends.compactMap { friend in
    172                 guard let zoneName = friend.friendZoneName,
    173                       let ownerName = friend.friendZoneOwnerName
    174                 else { return nil }
    175                 return (
    176                     CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    177                     friend.databaseScope
    178                 )
    179             }
    180         }
    181     }
    182 
    183     private nonisolated static func upsertPlayerRecord(
    184         for gameID: UUID,
    185         in ctx: NSManagedObjectContext,
    186         authorID: String,
    187         name: String
    188     ) -> Bool {
    189         ctx.performAndWait {
    190             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    191             req.predicate = NSPredicate(
    192                 format: "id == %@ AND (ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO",
    193                 gameID as CVarArg
    194             )
    195             req.fetchLimit = 1
    196             guard let game = try? ctx.fetch(req).first else { return false }
    197             upsertPlayerEntity(
    198                 for: game,
    199                 authorID: authorID,
    200                 name: name,
    201                 now: Date(),
    202                 in: ctx
    203             )
    204             if ctx.hasChanges { try? ctx.save() }
    205             return true
    206         }
    207     }
    208 
    209     private nonisolated static func upsertPlayerEntity(
    210         for game: GameEntity,
    211         authorID: String,
    212         name: String,
    213         now: Date,
    214         in ctx: NSManagedObjectContext
    215     ) {
    216         guard let gameID = game.id else { return }
    217         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    218         let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    219         lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    220         lookup.fetchLimit = 1
    221 
    222         let entity: PlayerEntity
    223         if let existing = try? ctx.fetch(lookup).first {
    224             entity = existing
    225         } else {
    226             entity = PlayerEntity(context: ctx)
    227             entity.game = game
    228             entity.ckRecordName = recordName
    229             entity.authorID = authorID
    230         }
    231         entity.name = name
    232         entity.updatedAt = now
    233     }
    234 }