GamePlayerColorStore.swift (4818B)
1 import Foundation 2 3 extension Notification.Name { 4 static let gamePlayerColorsChanged = Notification.Name("gamePlayerColorsChanged") 5 } 6 7 /// Persists per-game, per-author colour assignments in `UserDefaults`. 8 /// Colours are device-local by design — they are never synced to CloudKit. 9 /// 10 /// Layout under the `"gamePlayerColors"` key: 11 /// `[gameID.uuidString: [authorID: colorID]]` 12 @MainActor 13 final class GamePlayerColorStore { 14 private let defaults: UserDefaults 15 private let defaultsKey = "gamePlayerColors" 16 17 init(defaults: UserDefaults = .standard) { 18 self.defaults = defaults 19 } 20 21 // MARK: - Read 22 23 func color(forGame gameID: UUID, authorID: String) -> PlayerColor? { 24 guard let colorID = rawStore[gameID.uuidString]?[authorID] else { return nil } 25 return PlayerColor.color(for: colorID) 26 } 27 28 /// Returns the stored colour for `authorID` if present; otherwise picks a 29 /// random palette entry not in `reservedColorIDs` ∪ already-assigned 30 /// colours for this game, persists it, and returns it. 31 func ensureColor( 32 forGame gameID: UUID, 33 authorID: String, 34 reservedColorIDs: Set<String> 35 ) -> PlayerColor { 36 var rng = SystemRandomNumberGenerator() 37 return ensureColor( 38 forGame: gameID, 39 authorID: authorID, 40 reservedColorIDs: reservedColorIDs, 41 using: &rng 42 ) 43 } 44 45 func ensureColor<RNG: RandomNumberGenerator>( 46 forGame gameID: UUID, 47 authorID: String, 48 reservedColorIDs: Set<String>, 49 using rng: inout RNG 50 ) -> PlayerColor { 51 if let existing = color(forGame: gameID, authorID: authorID) { 52 return existing 53 } 54 let alreadyAssigned = assignedColorIDs(forGame: gameID, excludingAuthorID: authorID) 55 let taken = reservedColorIDs.union(alreadyAssigned) 56 let available = PlayerColor.palette.filter { !taken.contains($0.id) } 57 let chosen: PlayerColor 58 if let pick = available.randomElement(using: &rng) { 59 chosen = pick 60 } else { 61 // Palette exhausted — fall back to any palette colour. 62 chosen = PlayerColor.palette.randomElement(using: &rng) ?? .blue 63 } 64 writeColor(chosen, forGame: gameID, authorID: authorID, notify: false) 65 return chosen 66 } 67 68 /// Returns the set of colorIDs already assigned to any author for `gameID`, 69 /// optionally skipping one author (useful for collision reassignment). 70 func assignedColorIDs(forGame gameID: UUID, excludingAuthorID: String?) -> Set<String> { 71 let gameMap = rawStore[gameID.uuidString] ?? [:] 72 let ids = gameMap.compactMap { key, value -> String? in 73 key == excludingAuthorID ? nil : value 74 } 75 return Set(ids) 76 } 77 78 /// Returns all authorIDs that have a stored colour entry for `gameID`. 79 func storedAuthorIDs(forGame gameID: UUID) -> Set<String> { 80 Set((rawStore[gameID.uuidString] ?? [:]).keys) 81 } 82 83 // MARK: - Write 84 85 func setColor(_ color: PlayerColor, forGame gameID: UUID, authorID: String) { 86 writeColor(color, forGame: gameID, authorID: authorID, notify: true) 87 } 88 89 func clearColor(forGame gameID: UUID, authorID: String, notify: Bool = true) { 90 var s = rawStore 91 guard s[gameID.uuidString]?[authorID] != nil else { return } 92 s[gameID.uuidString]?.removeValue(forKey: authorID) 93 if s[gameID.uuidString]?.isEmpty == true { 94 s.removeValue(forKey: gameID.uuidString) 95 } 96 rawStore = s 97 if notify { 98 postChange(gameID: gameID) 99 } 100 } 101 102 private func writeColor(_ color: PlayerColor, forGame gameID: UUID, authorID: String, notify: Bool) { 103 if self.color(forGame: gameID, authorID: authorID)?.id == color.id { 104 return 105 } 106 var s = rawStore 107 var gameMap = s[gameID.uuidString] ?? [:] 108 gameMap[authorID] = color.id 109 s[gameID.uuidString] = gameMap 110 rawStore = s 111 if notify { 112 postChange(gameID: gameID) 113 } 114 } 115 116 func clearColors(forGame gameID: UUID) { 117 var s = rawStore 118 guard s[gameID.uuidString] != nil else { return } 119 s.removeValue(forKey: gameID.uuidString) 120 rawStore = s 121 postChange(gameID: gameID) 122 } 123 124 // MARK: - Private 125 126 private var rawStore: [String: [String: String]] { 127 get { defaults.dictionary(forKey: defaultsKey) as? [String: [String: String]] ?? [:] } 128 set { defaults.set(newValue, forKey: defaultsKey) } 129 } 130 131 private func postChange(gameID: UUID) { 132 NotificationCenter.default.post( 133 name: .gamePlayerColorsChanged, 134 object: nil, 135 userInfo: ["gameID": gameID] 136 ) 137 } 138 }