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 }