From 3132d760ad60719a6bc63613ae2fb3ce41cf32d0 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 17 Dec 2025 12:04:42 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat.xcodeproj/project.pbxproj | 2 + Baccarat/Baccarat.entitlements | 10 + Baccarat/Engine/GameState.swift | 170 +++++- Baccarat/Resources/Localizable.xcstrings | 566 ++++++++++++++++++ Baccarat/Storage/BaccaratGameData.swift | 154 +++++ Baccarat/Views/GameTableView.swift | 8 +- Baccarat/Views/SettingsView.swift | 127 +++- CasinoKit/README.md | 64 ++ CasinoKit/Sources/CasinoKit/Exports.swift | 4 + .../CasinoKit/Storage/CloudSyncManager.swift | 368 ++++++++++++ 10 files changed, 1459 insertions(+), 14 deletions(-) create mode 100644 Baccarat/Baccarat.entitlements create mode 100644 Baccarat/Storage/BaccaratGameData.swift create mode 100644 CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift 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") +} +