crossmate

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

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 }