Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7803a561ae
commit
3132d760ad
@ -423,6 +423,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Baccarat/Baccarat.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@ -459,6 +460,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Baccarat/Baccarat.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|||||||
10
Baccarat/Baccarat.entitlements
Normal file
10
Baccarat/Baccarat.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array/>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -54,6 +54,9 @@ final class GameState {
|
|||||||
// MARK: - Sound
|
// MARK: - Sound
|
||||||
private let sound = SoundManager.shared
|
private let sound = SoundManager.shared
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
private var persistence: CloudSyncManager<BaccaratGameData>!
|
||||||
|
|
||||||
// MARK: - Game Engine
|
// MARK: - Game Engine
|
||||||
private(set) var engine: BaccaratEngine
|
private(set) var engine: BaccaratEngine
|
||||||
|
|
||||||
@ -143,6 +146,121 @@ final class GameState {
|
|||||||
|
|
||||||
// Sync sound settings with SoundManager
|
// Sync sound settings with SoundManager
|
||||||
syncSoundSettings()
|
syncSoundSettings()
|
||||||
|
|
||||||
|
// Initialize persistence with cloud data callback
|
||||||
|
self.persistence = CloudSyncManager<BaccaratGameData>()
|
||||||
|
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.
|
/// Syncs sound settings from GameSettings to SoundManager.
|
||||||
@ -410,17 +528,32 @@ final class GameState {
|
|||||||
betResults = results
|
betResults = results
|
||||||
lastWinnings = totalWinnings
|
lastWinnings = totalWinnings
|
||||||
|
|
||||||
// Play result sound
|
// Play result sound based on MAIN BET outcome (not total winnings)
|
||||||
if totalWinnings > 0 {
|
// This way winning the main hand plays win sound even if side bets lost
|
||||||
// Determine if it's a big win (>= 5x any bet amount or >= 500)
|
let mainBetResult = results.first(where: { $0.type == .player || $0.type == .banker })
|
||||||
let maxBetAmount = currentBets.map(\.amount).max() ?? 0
|
|
||||||
let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
|
if let mainResult = mainBetResult {
|
||||||
sound.playWin(isBigWin: isBigWin)
|
if mainResult.isWin {
|
||||||
} else if totalWinnings < 0 {
|
// Main bet won - play win sound
|
||||||
sound.playLose()
|
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 {
|
} else {
|
||||||
// Push (tie with main bet push)
|
// No main bet (only side bets) - use total winnings
|
||||||
sound.playPush()
|
if totalWinnings > 0 {
|
||||||
|
sound.playWin(isBigWin: false)
|
||||||
|
} else if totalWinnings < 0 {
|
||||||
|
sound.playLose()
|
||||||
|
} else {
|
||||||
|
sound.playPush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record result in history
|
// Record result in history
|
||||||
@ -432,6 +565,9 @@ final class GameState {
|
|||||||
bankerPair: bankerHadPair
|
bankerPair: bankerHadPair
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// Save game state to iCloud/local
|
||||||
|
saveGame(netWinnings: totalWinnings)
|
||||||
|
|
||||||
// Show result banner - stays until user taps New Round
|
// Show result banner - stays until user taps New Round
|
||||||
showResultBanner = true
|
showResultBanner = true
|
||||||
currentPhase = .roundComplete
|
currentPhase = .roundComplete
|
||||||
@ -489,10 +625,24 @@ final class GameState {
|
|||||||
bankerHadPair = false
|
bankerHadPair = false
|
||||||
betResults = []
|
betResults = []
|
||||||
|
|
||||||
|
// Save the reset state (keeps lifetime stats, resets balance and session history)
|
||||||
|
saveGame()
|
||||||
|
|
||||||
// Play new game sound
|
// Play new game sound
|
||||||
sound.playNewRound()
|
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).
|
/// Applies new settings (call after settings change).
|
||||||
func applySettings() {
|
func applySettings() {
|
||||||
resetGame()
|
resetGame()
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"-$%lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
"%lld" : {
|
"%lld" : {
|
||||||
"comment" : "The number of rounds a player has played in the game.",
|
"comment" : "The number of rounds a player has played in the game.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -157,6 +160,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"+$%lld" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"$" : {
|
"$" : {
|
||||||
"comment" : "The currency symbol \"$\".",
|
"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" : {
|
"Deal" : {
|
||||||
"comment" : "The label of a button that deals cards in a game.",
|
"comment" : "The label of a button that deals cards in a game.",
|
||||||
"localizations" : {
|
"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" : {
|
"Icon" : {
|
||||||
"comment" : "The title for the tab that displays the app icon preview.",
|
"comment" : "The title for the tab that displays the app icon preview.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Launch" : {
|
||||||
"comment" : "A tab label for the launch screen preview.",
|
"comment" : "A tab label for the launch screen preview.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"MAX" : {
|
||||||
"comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.",
|
"comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.",
|
||||||
"localizations" : {
|
"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" : {
|
"New Round" : {
|
||||||
"comment" : "The label of a button that starts a new round of the game.",
|
"comment" : "The label of a button that starts a new round of the game.",
|
||||||
"localizations" : {
|
"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" : {
|
"SOUND & HAPTICS" : {
|
||||||
"comment" : "Section header for sound and haptic settings.",
|
"comment" : "Section header for sound and haptic settings.",
|
||||||
"localizations" : {
|
"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" : {
|
"TABLE LIMITS" : {
|
||||||
"comment" : "Section header for table limits settings.",
|
"comment" : "Section header for table limits settings.",
|
||||||
"localizations" : {
|
"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" : {
|
"Tie" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"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." : {
|
"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.",
|
"comment" : "A description of an alternative method for generating app icons.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
154
Baccarat/Storage/BaccaratGameData.swift
Normal file
154
Baccarat/Storage/BaccaratGameData.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -149,9 +149,11 @@ struct GameTableView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(settings: settings) {
|
if let state = gameState {
|
||||||
// Apply settings when changed
|
SettingsView(settings: settings, gameState: state) {
|
||||||
gameState?.applySettings()
|
// Apply settings when changed
|
||||||
|
gameState?.applySettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showRules) {
|
.fullScreenCover(isPresented: $showRules) {
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import CasinoKit
|
|||||||
/// The settings screen for customizing game options.
|
/// The settings screen for customizing game options.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var settings: GameSettings
|
@Bindable var settings: GameSettings
|
||||||
|
let gameState: GameState
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
let onApplyChanges: () -> Void
|
let onApplyChanges: () -> Void
|
||||||
|
|
||||||
@State private var hasChanges = false
|
@State private var hasChanges = false
|
||||||
|
@State private var showClearDataAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SheetContainerView(
|
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
|
// Reset Button
|
||||||
Button {
|
Button {
|
||||||
settings.resetToDefaults()
|
settings.resetToDefaults()
|
||||||
@ -149,6 +266,14 @@ struct SettingsView: View {
|
|||||||
doneButtonText: String(localized: "Done"),
|
doneButtonText: String(localized: "Done"),
|
||||||
cancelButtonText: String(localized: "Cancel")
|
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 {
|
#Preview {
|
||||||
SettingsView(settings: GameSettings()) { }
|
SettingsView(settings: GameSettings(), gameState: GameState()) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -208,6 +208,70 @@ sound.hapticError() // Error notification
|
|||||||
sound.hapticWarning() // Warning 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<MyGameData>()
|
||||||
|
|
||||||
|
// 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
|
### 🎨 Design System
|
||||||
|
|
||||||
**CasinoDesign** - Shared design constants.
|
**CasinoDesign** - Shared design constants.
|
||||||
|
|||||||
@ -37,3 +37,7 @@
|
|||||||
// - SoundManager
|
// - SoundManager
|
||||||
// - GameSound
|
// - GameSound
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
// - CloudSyncManager
|
||||||
|
// - PersistableGameData (protocol)
|
||||||
|
|
||||||
|
|||||||
368
CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift
Normal file
368
CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift
Normal file
@ -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<T: PersistableGameData> {
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user