PlayerNamePublisher.swift (9717B)
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, DatabaseScope) 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, DatabaseScope) 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 await 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, .private 152 ) 153 let ctx = persistence.container.newBackgroundContext() 154 for target in await 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 — runs on `ctx`'s queue via `await perform`, so 164 /// it never blocks the main actor even though the caller is `@MainActor`. 165 private nonisolated static func friendZoneTargets( 166 in ctx: NSManagedObjectContext 167 ) async -> [(zoneID: CKRecordZone.ID, scope: DatabaseScope)] { 168 await ctx.perform { 169 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 170 req.predicate = NSPredicate(format: "isBlocked == NO") 171 let friends = (try? ctx.fetch(req)) ?? [] 172 return friends.compactMap { friend in 173 // Our name is told to a friend by writing into their inbox — 174 // our outbox (shared DB). 175 guard let pairKey = friend.pairKey, 176 let authorID = friend.authorID, 177 FriendZone.outboxAccepted(pairKey: pairKey) 178 else { return nil } 179 return ( 180 FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID), 181 .shared 182 ) 183 } 184 } 185 } 186 187 private nonisolated static func upsertPlayerRecord( 188 for gameID: UUID, 189 in ctx: NSManagedObjectContext, 190 authorID: String, 191 name: String 192 ) async -> Bool { 193 await ctx.perform { 194 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 195 req.predicate = NSPredicate( 196 format: "id == %@ AND (ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO", 197 gameID as CVarArg 198 ) 199 req.fetchLimit = 1 200 guard let game = try? ctx.fetch(req).first else { return false } 201 upsertPlayerEntity( 202 for: game, 203 authorID: authorID, 204 name: name, 205 now: Date(), 206 in: ctx 207 ) 208 if ctx.hasChanges { try? ctx.save() } 209 return true 210 } 211 } 212 213 private nonisolated static func upsertPlayerEntity( 214 for game: GameEntity, 215 authorID: String, 216 name: String, 217 now: Date, 218 in ctx: NSManagedObjectContext 219 ) { 220 guard let gameID = game.id else { return } 221 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 222 let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 223 lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 224 lookup.fetchLimit = 1 225 226 let entity: PlayerEntity 227 if let existing = try? ctx.fetch(lookup).first { 228 entity = existing 229 } else { 230 entity = PlayerEntity(context: ctx) 231 entity.game = game 232 entity.ckRecordName = recordName 233 entity.authorID = authorID 234 } 235 entity.name = name 236 entity.updatedAt = now 237 } 238 }