diff --git a/Baccarat.xcodeproj/project.pbxproj b/Baccarat.xcodeproj/project.pbxproj
index 0ba5be4..4cea904 100644
--- a/Baccarat.xcodeproj/project.pbxproj
+++ b/Baccarat.xcodeproj/project.pbxproj
@@ -423,6 +423,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Baccarat/Baccarat.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -459,6 +460,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Baccarat/Baccarat.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
diff --git a/Baccarat/Baccarat.entitlements b/Baccarat/Baccarat.entitlements
new file mode 100644
index 0000000..c280ba7
--- /dev/null
+++ b/Baccarat/Baccarat.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.icloud-container-identifiers
+
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)$(CFBundleIdentifier)
+
+
diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift
index e55997d..d453fa8 100644
--- a/Baccarat/Engine/GameState.swift
+++ b/Baccarat/Engine/GameState.swift
@@ -54,6 +54,9 @@ final class GameState {
// MARK: - Sound
private let sound = SoundManager.shared
+ // MARK: - Persistence
+ private var persistence: CloudSyncManager!
+
// MARK: - Game Engine
private(set) var engine: BaccaratEngine
@@ -143,6 +146,121 @@ final class GameState {
// Sync sound settings with SoundManager
syncSoundSettings()
+
+ // Initialize persistence with cloud data callback
+ self.persistence = CloudSyncManager()
+ persistence.onCloudDataReceived = { [weak self] cloudData in
+ self?.handleCloudDataReceived(cloudData)
+ }
+
+ // Load saved game data
+ loadSavedGame()
+ }
+
+ /// Handles data received from iCloud (e.g., after fresh install or from another device).
+ private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
+ print("GameState: Received cloud data with \(cloudData.roundsPlayed) rounds")
+
+ // Only update if cloud has more progress than current state
+ guard cloudData.roundsPlayed > roundHistory.count else {
+ print("GameState: Local data is newer, ignoring cloud data")
+ return
+ }
+
+ // Restore balance
+ self.balance = cloudData.balance
+
+ // Restore round history
+ self.roundHistory = cloudData.roundHistory.compactMap { saved in
+ guard let result = GameResult(persistenceKey: saved.result) else { return nil }
+ return RoundResult(
+ result: result,
+ playerValue: saved.playerValue,
+ bankerValue: saved.bankerValue,
+ playerPair: saved.playerPair,
+ bankerPair: saved.bankerPair
+ )
+ }
+
+ print("GameState: Restored from cloud - \(cloudData.roundsPlayed) rounds, balance: \(cloudData.balance)")
+ }
+
+ // MARK: - Persistence
+
+ /// Loads saved game data from iCloud/local storage.
+ private func loadSavedGame() {
+ let savedData = persistence.data
+
+ // Only restore if there's saved progress
+ guard savedData.roundsPlayed > 0 else { return }
+
+ // Restore balance
+ self.balance = savedData.balance
+
+ // Restore round history (convert saved to RoundResult)
+ self.roundHistory = savedData.roundHistory.compactMap { saved in
+ guard let result = GameResult(persistenceKey: saved.result) else { return nil }
+ return RoundResult(
+ result: result,
+ playerValue: saved.playerValue,
+ bankerValue: saved.bankerValue,
+ playerPair: saved.playerPair,
+ bankerPair: saved.bankerPair
+ )
+ }
+
+ print("GameState: Restored \(savedData.roundsPlayed) rounds, balance: \(savedData.balance)")
+ }
+
+ /// Saves current game state to iCloud/local storage.
+ private func saveGame(netWinnings: Int = 0) {
+ var data = persistence.data
+
+ // Update balance
+ data.balance = balance
+
+ // Update statistics
+ data.totalWinnings += netWinnings
+ if netWinnings > data.biggestWin {
+ data.biggestWin = netWinnings
+ }
+ if netWinnings < 0 && abs(netWinnings) > data.biggestLoss {
+ data.biggestLoss = abs(netWinnings)
+ }
+
+ // Update round history from current session
+ data.roundHistory = roundHistory.enumerated().map { index, round in
+ // Try to get existing saved result for net winnings
+ if index < data.roundHistory.count {
+ return data.roundHistory[index]
+ }
+ // New round - calculate net winnings from betResults if available
+ let netForRound = betResults.reduce(0) { $0 + $1.payout }
+ return SavedRoundResult(from: round, netWinnings: index == roundHistory.count - 1 ? netWinnings : netForRound)
+ }
+
+ persistence.save(data)
+ }
+
+ /// Whether iCloud sync is available.
+ var iCloudAvailable: Bool {
+ persistence.iCloudAvailable
+ }
+
+ /// Whether iCloud sync is enabled.
+ var iCloudEnabled: Bool {
+ get { persistence.iCloudEnabled }
+ set { persistence.iCloudEnabled = newValue }
+ }
+
+ /// Last sync date.
+ var lastSyncDate: Date? {
+ persistence.lastSyncDate
+ }
+
+ /// Forces a sync with iCloud.
+ func syncWithCloud() {
+ persistence.sync()
}
/// Syncs sound settings from GameSettings to SoundManager.
@@ -410,17 +528,32 @@ final class GameState {
betResults = results
lastWinnings = totalWinnings
- // Play result sound
- if totalWinnings > 0 {
- // Determine if it's a big win (>= 5x any bet amount or >= 500)
- let maxBetAmount = currentBets.map(\.amount).max() ?? 0
- let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
- sound.playWin(isBigWin: isBigWin)
- } else if totalWinnings < 0 {
- sound.playLose()
+ // Play result sound based on MAIN BET outcome (not total winnings)
+ // This way winning the main hand plays win sound even if side bets lost
+ let mainBetResult = results.first(where: { $0.type == .player || $0.type == .banker })
+
+ if let mainResult = mainBetResult {
+ if mainResult.isWin {
+ // Main bet won - play win sound
+ let maxBetAmount = currentBets.map(\.amount).max() ?? 0
+ let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
+ sound.playWin(isBigWin: isBigWin && totalWinnings > 0)
+ } else if mainResult.isPush {
+ // Main bet pushed (tie)
+ sound.playPush()
+ } else {
+ // Main bet lost
+ sound.playLose()
+ }
} else {
- // Push (tie with main bet push)
- sound.playPush()
+ // No main bet (only side bets) - use total winnings
+ if totalWinnings > 0 {
+ sound.playWin(isBigWin: false)
+ } else if totalWinnings < 0 {
+ sound.playLose()
+ } else {
+ sound.playPush()
+ }
}
// Record result in history
@@ -432,6 +565,9 @@ final class GameState {
bankerPair: bankerHadPair
))
+ // Save game state to iCloud/local
+ saveGame(netWinnings: totalWinnings)
+
// Show result banner - stays until user taps New Round
showResultBanner = true
currentPhase = .roundComplete
@@ -489,10 +625,24 @@ final class GameState {
bankerHadPair = false
betResults = []
+ // Save the reset state (keeps lifetime stats, resets balance and session history)
+ saveGame()
+
// Play new game sound
sound.playNewRound()
}
+ /// Completely clears all saved data and starts fresh (including lifetime stats).
+ func clearAllData() {
+ persistence.reset()
+ resetGame()
+ }
+
+ /// Returns lifetime statistics from saved data.
+ var lifetimeStats: BaccaratGameData {
+ persistence.data
+ }
+
/// Applies new settings (call after settings change).
func applySettings() {
resetGame()
diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings
index bb4942b..7353cca 100644
--- a/Baccarat/Resources/Localizable.xcstrings
+++ b/Baccarat/Resources/Localizable.xcstrings
@@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "-$%lld" : {
+
+ },
"%lld" : {
"comment" : "The number of rounds a player has played in the game.",
"localizations" : {
@@ -157,6 +160,9 @@
}
}
}
+ },
+ "+$%lld" : {
+
},
"$" : {
"comment" : "The currency symbol \"$\".",
@@ -1330,6 +1336,166 @@
}
}
},
+ "Clear All Data" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clear All Data"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar todos los datos"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar todos los datos"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar todos los datos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer toutes les données"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer toutes les données"
+ }
+ }
+ }
+ },
+ "Clear All Data?" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clear All Data?"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Borrar todos los datos?"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Borrar todos los datos?"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Borrar todos los datos?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer toutes les données?"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer toutes les données?"
+ }
+ }
+ }
+ },
+ "CLOUD SYNC" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CLOUD SYNC"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SINCRONIZACIÓN EN LA NUBE"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SINCRONIZACIÓN EN LA NUBE"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SINCRONIZACIÓN EN LA NUBE"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SYNCHRONISATION iCLOUD"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SYNCHRONISATION iCLOUD"
+ }
+ }
+ }
+ },
+ "DATA" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DATA"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DATOS"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DATOS"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DATOS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DONNÉES"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DONNÉES"
+ }
+ }
+ }
+ },
"Deal" : {
"comment" : "The label of a button that deals cards in a game.",
"localizations" : {
@@ -2123,6 +2289,86 @@
}
}
},
+ "iCloud Sync" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud Sync"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronización iCloud"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronización iCloud"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronización iCloud"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchronisation iCloud"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchronisation iCloud"
+ }
+ }
+ }
+ },
+ "iCloud Unavailable" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud Unavailable"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud no disponible"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud no disponible"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud no disponible"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud non disponible"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iCloud non disponible"
+ }
+ }
+ }
+ },
"Icon" : {
"comment" : "The title for the tab that displays the app icon preview.",
"isCommentAutoGenerated" : true
@@ -2172,10 +2418,90 @@
}
}
},
+ "Last Synced" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Last Synced"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Última sincronización"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Última sincronización"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Última sincronización"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernière synchronisation"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernière synchronisation"
+ }
+ }
+ }
+ },
"Launch" : {
"comment" : "A tab label for the launch screen preview.",
"isCommentAutoGenerated" : true
},
+ "Lifetime Rounds" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lifetime Rounds"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rondas totales"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rondas totales"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rondas totales"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tours joués"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tours joués"
+ }
+ }
+ }
+ },
"MAX" : {
"comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.",
"localizations" : {
@@ -2298,6 +2624,46 @@
}
}
},
+ "Never" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Never"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nunca"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nunca"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nunca"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jamais"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jamais"
+ }
+ }
+ }
+ },
"New Round" : {
"comment" : "The label of a button that starts a new round of the game.",
"localizations" : {
@@ -3345,6 +3711,46 @@
}
}
},
+ "Sign in to iCloud to sync progress" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sign in to iCloud to sync progress"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión en iCloud para sincronizar"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión en iCloud para sincronizar"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión en iCloud para sincronizar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connectez-vous à iCloud pour synchroniser"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connectez-vous à iCloud pour synchroniser"
+ }
+ }
+ }
+ },
"SOUND & HAPTICS" : {
"comment" : "Section header for sound and haptic settings.",
"localizations" : {
@@ -3508,6 +3914,86 @@
}
}
},
+ "Sync Now" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sync Now"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar ahora"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar ahora"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar ahora"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchroniser maintenant"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchroniser maintenant"
+ }
+ }
+ }
+ },
+ "Sync progress across devices" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sync progress across devices"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar progreso entre dispositivos"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar progreso entre dispositivos"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sincronizar progreso entre dispositivos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchroniser la progression entre les appareils"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchroniser la progression entre les appareils"
+ }
+ }
+ }
+ },
"TABLE LIMITS" : {
"comment" : "Section header for table limits settings.",
"localizations" : {
@@ -3635,6 +4121,46 @@
}
}
},
+ "This will delete all saved progress, statistics, and reset your balance. This cannot be undone." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This will delete all saved progress, statistics, and reset your balance. This cannot be undone."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esto eliminará todo el progreso guardado, las estadísticas y restablecerá tu saldo. Esta acción no se puede deshacer."
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esto eliminará todo el progreso guardado, las estadísticas y restablecerá tu saldo. Esta acción no se puede deshacer."
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esto eliminará todo el progreso guardado, las estadísticas y restablecerá tu saldo. Esta acción no se puede deshacer."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cela supprimera toutes les données sauvegardées, les statistiques et réinitialisera votre solde. Cette action est irréversible."
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cela supprimera toutes les données sauvegardées, les statistiques et réinitialisera votre solde. Cette action est irréversible."
+ }
+ }
+ }
+ },
"Tie" : {
"localizations" : {
"en" : {
@@ -3840,6 +4366,46 @@
}
}
},
+ "Total Winnings" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Total Winnings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ganancias totales"
+ }
+ },
+ "es-MX" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ganancias totales"
+ }
+ },
+ "es-US" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ganancias totales"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gains totaux"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gains totaux"
+ }
+ }
+ }
+ },
"Upload the 1024px icon to appicon.co or makeappicon.com to generate all sizes automatically." : {
"comment" : "A description of an alternative method for generating app icons.",
"isCommentAutoGenerated" : true
diff --git a/Baccarat/Storage/BaccaratGameData.swift b/Baccarat/Storage/BaccaratGameData.swift
new file mode 100644
index 0000000..8aa5fb5
--- /dev/null
+++ b/Baccarat/Storage/BaccaratGameData.swift
@@ -0,0 +1,154 @@
+//
+// BaccaratGameData.swift
+// Baccarat
+//
+// Baccarat-specific game data that persists to iCloud.
+//
+
+import Foundation
+import CasinoKit
+
+/// Persisted data for Baccarat game.
+public struct BaccaratGameData: PersistableGameData {
+
+ // MARK: - PersistableGameData
+
+ public static let gameIdentifier = "baccarat"
+
+ public var roundsPlayed: Int {
+ roundHistory.count
+ }
+
+ public static var empty: BaccaratGameData {
+ BaccaratGameData(
+ balance: 10_000,
+ roundHistory: [],
+ totalWinnings: 0,
+ biggestWin: 0,
+ biggestLoss: 0,
+ lastModified: Date()
+ )
+ }
+
+ // MARK: - Game Data
+
+ /// Current chip balance.
+ public var balance: Int
+
+ /// History of all rounds played.
+ public var roundHistory: [SavedRoundResult]
+
+ // MARK: - Lifetime Statistics
+
+ /// Total net winnings (can be negative).
+ public var totalWinnings: Int
+
+ /// Biggest single-round win.
+ public var biggestWin: Int
+
+ /// Biggest single-round loss (stored as positive number).
+ public var biggestLoss: Int
+
+ /// Last time data was modified (required by PersistableGameData).
+ public var lastModified: Date
+
+ // MARK: - Computed Stats
+
+ /// Number of Player wins.
+ public var playerWins: Int {
+ roundHistory.filter { $0.result == "player" }.count
+ }
+
+ /// Number of Banker wins.
+ public var bankerWins: Int {
+ roundHistory.filter { $0.result == "banker" }.count
+ }
+
+ /// Number of Tie games.
+ public var tieGames: Int {
+ roundHistory.filter { $0.result == "tie" }.count
+ }
+
+ /// Win rate percentage.
+ public var winRate: Double {
+ guard roundsPlayed > 0 else { return 0 }
+ let wins = roundHistory.filter { $0.netWinnings > 0 }.count
+ return Double(wins) / Double(roundsPlayed) * 100
+ }
+}
+
+/// Codable round result for persistence.
+public struct SavedRoundResult: Codable, Identifiable, Sendable {
+ public let id: UUID
+ public let result: String // "player", "banker", "tie"
+ public let playerValue: Int
+ public let bankerValue: Int
+ public let playerPair: Bool
+ public let bankerPair: Bool
+ public let isNatural: Bool
+ public let timestamp: Date
+ public let netWinnings: Int
+
+ public init(
+ id: UUID = UUID(),
+ result: String,
+ playerValue: Int,
+ bankerValue: Int,
+ playerPair: Bool,
+ bankerPair: Bool,
+ isNatural: Bool,
+ timestamp: Date = Date(),
+ netWinnings: Int
+ ) {
+ self.id = id
+ self.result = result
+ self.playerValue = playerValue
+ self.bankerValue = bankerValue
+ self.playerPair = playerPair
+ self.bankerPair = bankerPair
+ self.isNatural = isNatural
+ self.timestamp = timestamp
+ self.netWinnings = netWinnings
+ }
+}
+
+// MARK: - Conversion from RoundResult
+
+extension SavedRoundResult {
+ /// Creates a SavedRoundResult from a RoundResult and net winnings.
+ init(from roundResult: RoundResult, netWinnings: Int) {
+ self.id = roundResult.id
+ self.result = roundResult.result.persistenceKey
+ self.playerValue = roundResult.playerValue
+ self.bankerValue = roundResult.bankerValue
+ self.playerPair = roundResult.playerPair
+ self.bankerPair = roundResult.bankerPair
+ self.isNatural = roundResult.isNatural
+ self.timestamp = roundResult.timestamp
+ self.netWinnings = netWinnings
+ }
+}
+
+// MARK: - GameResult Extension
+
+extension GameResult {
+ /// String key for persistence.
+ var persistenceKey: String {
+ switch self {
+ case .playerWins: return "player"
+ case .bankerWins: return "banker"
+ case .tie: return "tie"
+ }
+ }
+
+ /// Creates GameResult from persistence key.
+ init?(persistenceKey: String) {
+ switch persistenceKey {
+ case "player": self = .playerWins
+ case "banker": self = .bankerWins
+ case "tie": self = .tie
+ default: return nil
+ }
+ }
+}
+
diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift
index 2556184..ca947ae 100644
--- a/Baccarat/Views/GameTableView.swift
+++ b/Baccarat/Views/GameTableView.swift
@@ -149,9 +149,11 @@ struct GameTableView: View {
}
}
.sheet(isPresented: $showSettings) {
- SettingsView(settings: settings) {
- // Apply settings when changed
- gameState?.applySettings()
+ if let state = gameState {
+ SettingsView(settings: settings, gameState: state) {
+ // Apply settings when changed
+ gameState?.applySettings()
+ }
}
}
.fullScreenCover(isPresented: $showRules) {
diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift
index 05181f4..4b90753 100644
--- a/Baccarat/Views/SettingsView.swift
+++ b/Baccarat/Views/SettingsView.swift
@@ -11,10 +11,12 @@ import CasinoKit
/// The settings screen for customizing game options.
struct SettingsView: View {
@Bindable var settings: GameSettings
+ let gameState: GameState
@Environment(\.dismiss) private var dismiss
let onApplyChanges: () -> Void
@State private var hasChanges = false
+ @State private var showClearDataAlert = false
var body: some View {
SheetContainerView(
@@ -114,6 +116,121 @@ struct SettingsView: View {
}
}
+ // iCloud Sync Section
+ SheetSection(title: String(localized: "CLOUD SYNC"), icon: "icloud") {
+ if gameState.iCloudAvailable {
+ Toggle(isOn: Binding(
+ get: { gameState.iCloudEnabled },
+ set: { gameState.iCloudEnabled = $0 }
+ )) {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(String(localized: "iCloud Sync"))
+ .font(.system(size: Design.BaseFontSize.subheadline, weight: .medium))
+ .foregroundStyle(.white)
+
+ Text(String(localized: "Sync progress across devices"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+ }
+ }
+ .tint(.yellow)
+
+ if gameState.iCloudEnabled {
+ Divider()
+ .background(Color.white.opacity(Design.Opacity.subtle))
+
+ HStack {
+ Text(String(localized: "Last Synced"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+
+ Spacer()
+
+ if let lastSync = gameState.lastSyncDate {
+ Text(lastSync, style: .relative)
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+ } else {
+ Text(String(localized: "Never"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+ }
+ }
+
+ Divider()
+ .background(Color.white.opacity(Design.Opacity.subtle))
+
+ Button {
+ gameState.syncWithCloud()
+ } label: {
+ HStack {
+ Image(systemName: "arrow.triangle.2.circlepath")
+ Text(String(localized: "Sync Now"))
+ }
+ .font(.system(size: Design.BaseFontSize.body, weight: .medium))
+ .foregroundStyle(.yellow)
+ }
+ }
+ } else {
+ HStack {
+ Image(systemName: "icloud.slash")
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(String(localized: "iCloud Unavailable"))
+ .font(.system(size: Design.BaseFontSize.subheadline, weight: .medium))
+ .foregroundStyle(.white)
+
+ Text(String(localized: "Sign in to iCloud to sync progress"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+ }
+ }
+ }
+ }
+
+ // Data Section
+ SheetSection(title: String(localized: "DATA"), icon: "externaldrive") {
+ HStack {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(String(localized: "Lifetime Rounds"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+
+ Text("\(gameState.lifetimeStats.roundsPlayed)")
+ .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) {
+ Text(String(localized: "Total Winnings"))
+ .font(.system(size: Design.BaseFontSize.body))
+ .foregroundStyle(.white.opacity(Design.Opacity.medium))
+
+ let winnings = gameState.lifetimeStats.totalWinnings
+ Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
+ .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
+ .foregroundStyle(winnings >= 0 ? .green : .red)
+ }
+ }
+
+ Divider()
+ .background(Color.white.opacity(Design.Opacity.subtle))
+
+ Button(role: .destructive) {
+ showClearDataAlert = true
+ } label: {
+ HStack {
+ Image(systemName: "trash")
+ Text(String(localized: "Clear All Data"))
+ }
+ .font(.system(size: Design.BaseFontSize.body, weight: .medium))
+ .foregroundStyle(.red)
+ }
+ }
+
// Reset Button
Button {
settings.resetToDefaults()
@@ -149,6 +266,14 @@ struct SettingsView: View {
doneButtonText: String(localized: "Done"),
cancelButtonText: String(localized: "Cancel")
)
+ .alert(String(localized: "Clear All Data?"), isPresented: $showClearDataAlert) {
+ Button(String(localized: "Cancel"), role: .cancel) { }
+ Button(String(localized: "Clear"), role: .destructive) {
+ gameState.clearAllData()
+ }
+ } message: {
+ Text(String(localized: "This will delete all saved progress, statistics, and reset your balance. This cannot be undone."))
+ }
}
}
@@ -394,6 +519,6 @@ struct VolumePicker: View {
}
#Preview {
- SettingsView(settings: GameSettings()) { }
+ SettingsView(settings: GameSettings(), gameState: GameState()) { }
}
diff --git a/CasinoKit/README.md b/CasinoKit/README.md
index bc1deb6..ccbedfe 100644
--- a/CasinoKit/README.md
+++ b/CasinoKit/README.md
@@ -208,6 +208,70 @@ sound.hapticError() // Error notification
sound.hapticWarning() // Warning notification
```
+### 💾 Cloud Storage
+
+**CloudSyncManager** - Saves game data locally and syncs with iCloud.
+
+```swift
+// 1. Define your game's data structure
+struct MyGameData: PersistableGameData {
+ static let gameIdentifier = "mygame"
+ var roundsPlayed: Int { rounds.count }
+ var lastModified: Date
+
+ static var empty: MyGameData {
+ MyGameData(rounds: [], balance: 10000, lastModified: Date())
+ }
+
+ var rounds: [RoundData]
+ var balance: Int
+}
+
+// 2. Create sync manager
+let persistence = CloudSyncManager()
+
+// 3. Save data (auto-syncs to iCloud)
+var data = persistence.data
+data.balance = 5000
+persistence.save(data)
+
+// 4. Data is automatically loaded on init
+print(persistence.data.balance)
+
+// 5. Check sync status
+if persistence.iCloudAvailable {
+ print("Last sync: \(persistence.lastSyncDate)")
+}
+
+// 6. Force sync
+persistence.sync()
+
+// 7. Listen for changes from other devices
+persistence.onCloudDataReceived = { newData in
+ print("Got \(newData.roundsPlayed) rounds from iCloud")
+}
+
+// 8. Reset all data
+persistence.reset()
+```
+
+**PersistableGameData Protocol:**
+```swift
+public protocol PersistableGameData: Codable, Sendable {
+ static var gameIdentifier: String { get } // e.g., "baccarat"
+ var roundsPlayed: Int { get } // For conflict resolution
+ var lastModified: Date { get set } // Updated automatically
+ static var empty: Self { get } // Default/new game state
+}
+```
+
+**Features:**
+- 📱 **Local Storage** - Always saved to UserDefaults
+- ☁️ **iCloud Sync** - Automatic sync when signed in
+- 🔄 **Conflict Resolution** - Uses `roundsPlayed` to pick newer data
+- 📢 **Change Notifications** - Callbacks when data changes from other devices
+- 🔒 **Privacy** - Uses Apple ID, no Game Center required
+
### 🎨 Design System
**CasinoDesign** - Shared design constants.
diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift
index 25b2beb..5a9b182 100644
--- a/CasinoKit/Sources/CasinoKit/Exports.swift
+++ b/CasinoKit/Sources/CasinoKit/Exports.swift
@@ -37,3 +37,7 @@
// - SoundManager
// - GameSound
+// MARK: - Storage
+// - CloudSyncManager
+// - PersistableGameData (protocol)
+
diff --git a/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift
new file mode 100644
index 0000000..204eb55
--- /dev/null
+++ b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift
@@ -0,0 +1,368 @@
+//
+// CloudSyncManager.swift
+// CasinoKit
+//
+// Generic iCloud sync manager for casino game data.
+//
+
+import Foundation
+import SwiftUI
+
+/// Protocol for game data that can be persisted and synced.
+public protocol PersistableGameData: Codable, Sendable {
+ /// Unique identifier for this game's data (e.g., "baccarat", "blackjack").
+ static var gameIdentifier: String { get }
+
+ /// The number of rounds/hands played - used to determine which data is newer.
+ var roundsPlayed: Int { get }
+
+ /// Last time this data was modified.
+ var lastModified: Date { get set }
+
+ /// Creates empty/default game data.
+ static var empty: Self { get }
+}
+
+/// Manages game data persistence to local storage and iCloud.
+@MainActor
+@Observable
+public final class CloudSyncManager {
+
+ // MARK: - Properties
+
+ /// The current game data.
+ public private(set) var data: T
+
+ /// Whether iCloud sync is available (user signed in).
+ public var iCloudAvailable: Bool {
+ let token = FileManager.default.ubiquityIdentityToken
+ let available = token != nil
+ print("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))")
+ return available
+ }
+
+ /// Whether iCloud sync is enabled by the user.
+ public var iCloudEnabled: Bool {
+ get { UserDefaults.standard.bool(forKey: iCloudEnabledKey) }
+ set {
+ UserDefaults.standard.set(newValue, forKey: iCloudEnabledKey)
+ if newValue { sync() }
+ }
+ }
+
+ /// Last successful sync date.
+ public private(set) var lastSyncDate: Date?
+
+ /// Whether a sync is in progress.
+ public private(set) var isSyncing: Bool = false
+
+ /// Sync status message.
+ public private(set) var syncStatus: String = ""
+
+ // MARK: - Private Properties
+
+ private let iCloudStore = NSUbiquitousKeyValueStore.default
+ private let encoder = JSONEncoder()
+ private let decoder = JSONDecoder()
+
+ private var localKey: String { "\(T.gameIdentifier).gameData" }
+ private var cloudKey: String { "\(T.gameIdentifier).gameData" }
+ private var syncDateKey: String { "\(T.gameIdentifier).lastSync" }
+ private var iCloudEnabledKey: String { "\(T.gameIdentifier).iCloudEnabled" }
+
+ /// Callback when data changes from iCloud.
+ public var onCloudDataReceived: ((T) -> Void)?
+
+ /// Whether initial iCloud sync has completed.
+ public private(set) var hasCompletedInitialSync: Bool = false
+
+ // MARK: - Initialization
+
+ public init() {
+ // Start with empty data
+ self.data = T.empty
+
+ // Set default iCloud enabled to true
+ if UserDefaults.standard.object(forKey: iCloudEnabledKey) == nil {
+ UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
+ }
+
+ // Register for iCloud changes BEFORE syncing
+ NotificationCenter.default.addObserver(
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
+ object: iCloudStore,
+ queue: .main
+ ) { [weak self] notification in
+ // Extract values before crossing isolation boundary (for Sendable compliance)
+ guard let userInfo = notification.userInfo,
+ let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
+ let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
+ return
+ }
+ Task { @MainActor in
+ self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
+ }
+ }
+
+ // Trigger iCloud sync FIRST (before loading local)
+ if iCloudAvailable && iCloudEnabled {
+ iCloudStore.synchronize()
+ }
+
+ // Load data (may get updated when iCloud sync completes)
+ self.data = load()
+
+ // On fresh install, wait briefly for iCloud data to arrive
+ if data.roundsPlayed == 0 && iCloudAvailable && iCloudEnabled {
+ scheduleDelayedCloudCheck()
+ }
+ }
+
+ /// Checks for iCloud data after a brief delay (for fresh installs).
+ private func scheduleDelayedCloudCheck() {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
+
+ Task { @MainActor in
+ // Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
+ try? await Task.sleep(for: .seconds(2))
+
+ print("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...")
+
+ // Force another sync (on main thread to avoid concurrency warning)
+ var syncResult = false
+ await MainActor.run {
+ syncResult = iCloudStore.synchronize()
+ }
+ print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
+
+ // Check what's in the store
+ let allKeys = iCloudStore.dictionaryRepresentation.keys
+ print("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))")
+
+ // Try loading cloud data again
+ if let cloudData = loadCloud() {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds")
+ if cloudData.roundsPlayed > data.roundsPlayed {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
+ data = cloudData
+ hasCompletedInitialSync = true
+ onCloudDataReceived?(cloudData)
+
+ // Save to local as well
+ if let encoded = try? encoder.encode(cloudData) {
+ UserDefaults.standard.set(encoded, forKey: localKey)
+ }
+
+ // Post notification
+ NotificationCenter.default.post(
+ name: .casinoGameDataDidChange,
+ object: nil,
+ userInfo: ["gameIdentifier": T.gameIdentifier]
+ )
+ } else {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds")
+ }
+ } else {
+ hasCompletedInitialSync = true
+ print("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay")
+ }
+ }
+ }
+
+ // MARK: - Save
+
+ /// Saves the current game data locally and to iCloud.
+ public func save(_ newData: T) {
+ var dataToSave = newData
+ dataToSave.lastModified = Date()
+ self.data = dataToSave
+
+ guard let encoded = try? encoder.encode(dataToSave) else {
+ print("CloudSyncManager: Failed to encode game data")
+ return
+ }
+
+ // Save locally
+ UserDefaults.standard.set(encoded, forKey: localKey)
+
+ // Save to iCloud
+ if iCloudAvailable && iCloudEnabled {
+ iCloudStore.set(encoded, forKey: cloudKey)
+ iCloudStore.set(Date(), forKey: syncDateKey)
+ iCloudStore.synchronize()
+ lastSyncDate = Date()
+ syncStatus = "Synced"
+ }
+
+ print("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))")
+ }
+
+ /// Convenience to update and save in one call.
+ public func update(_ transform: (inout T) -> Void) {
+ var updated = data
+ transform(&updated)
+ save(updated)
+ }
+
+ // MARK: - Load
+
+ /// Loads game data, preferring iCloud if it has more progress.
+ public func load() -> T {
+ let localData = loadLocal()
+ let cloudData = loadCloud()
+
+ // Determine which data to use
+ let finalData: T
+
+ switch (localData, cloudData) {
+ case (nil, nil):
+ print("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty")
+ finalData = T.empty
+
+ case (let local?, nil):
+ print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
+ finalData = local
+
+ case (nil, let cloud?):
+ print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
+ finalData = cloud
+
+ case (let local?, let cloud?):
+ // Use whichever has more rounds played
+ if cloud.roundsPlayed > local.roundsPlayed {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))")
+ finalData = cloud
+ // Update local with cloud data
+ if let encoded = try? encoder.encode(cloud) {
+ UserDefaults.standard.set(encoded, forKey: localKey)
+ }
+ } else if local.lastModified > cloud.lastModified {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))")
+ finalData = local
+ } else {
+ print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
+ finalData = local
+ }
+ }
+
+ return finalData
+ }
+
+ private func loadLocal() -> T? {
+ guard let data = UserDefaults.standard.data(forKey: localKey),
+ let decoded = try? decoder.decode(T.self, from: data) else {
+ return nil
+ }
+ return decoded
+ }
+
+ private func loadCloud() -> T? {
+ guard iCloudAvailable && iCloudEnabled,
+ let data = iCloudStore.data(forKey: cloudKey),
+ let decoded = try? decoder.decode(T.self, from: data) else {
+ return nil
+ }
+
+ if let syncDate = iCloudStore.object(forKey: syncDateKey) as? Date {
+ lastSyncDate = syncDate
+ }
+
+ return decoded
+ }
+
+ // MARK: - Sync
+
+ /// Forces a sync with iCloud.
+ public func sync() {
+ guard iCloudAvailable && iCloudEnabled else {
+ syncStatus = iCloudAvailable ? "Sync disabled" : "iCloud unavailable"
+ return
+ }
+
+ isSyncing = true
+ syncStatus = "Syncing..."
+
+ iCloudStore.synchronize()
+
+ // Reload to get any changes
+ let latestData = load()
+ if latestData.roundsPlayed != data.roundsPlayed {
+ data = latestData
+ onCloudDataReceived?(latestData)
+ }
+
+ isSyncing = false
+ lastSyncDate = Date()
+ syncStatus = "Synced"
+ }
+
+ // MARK: - Cloud Change Handling
+
+ private func handleCloudChange(reason: Int, changedKeys: [String]) {
+ // Check if our data changed
+ guard changedKeys.contains(cloudKey) else { return }
+
+ switch reason {
+ case NSUbiquitousKeyValueStoreServerChange,
+ NSUbiquitousKeyValueStoreInitialSyncChange:
+ print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
+ syncStatus = "Received update"
+
+ // Reload and notify
+ if let cloudData = loadCloud(), cloudData.roundsPlayed > data.roundsPlayed {
+ data = cloudData
+ onCloudDataReceived?(cloudData)
+
+ // Also update local
+ if let encoded = try? encoder.encode(cloudData) {
+ UserDefaults.standard.set(encoded, forKey: localKey)
+ }
+
+ // Post notification
+ NotificationCenter.default.post(
+ name: .casinoGameDataDidChange,
+ object: nil,
+ userInfo: ["gameIdentifier": T.gameIdentifier]
+ )
+ }
+
+ case NSUbiquitousKeyValueStoreQuotaViolationChange:
+ print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
+ syncStatus = "Storage full"
+
+ case NSUbiquitousKeyValueStoreAccountChange:
+ print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
+ syncStatus = "Account changed"
+ // Reload with new account
+ data = load()
+
+ default:
+ break
+ }
+ }
+
+ // MARK: - Reset
+
+ /// Clears all saved data locally and from iCloud.
+ public func reset() {
+ UserDefaults.standard.removeObject(forKey: localKey)
+
+ if iCloudAvailable {
+ iCloudStore.removeObject(forKey: cloudKey)
+ iCloudStore.removeObject(forKey: syncDateKey)
+ iCloudStore.synchronize()
+ }
+
+ data = T.empty
+ syncStatus = "Data cleared"
+ print("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
+ }
+}
+
+// MARK: - Notifications
+
+public extension Notification.Name {
+ /// Posted when game data changes from iCloud.
+ static let casinoGameDataDidChange = Notification.Name("casinoGameDataDidChange")
+}
+