Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7803a561ae
commit
3132d760ad
@ -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;
|
||||
|
||||
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
|
||||
private let sound = SoundManager.shared
|
||||
|
||||
// MARK: - Persistence
|
||||
private var persistence: CloudSyncManager<BaccaratGameData>!
|
||||
|
||||
// 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<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.
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@ -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()) { }
|
||||
}
|
||||
|
||||
|
||||
@ -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<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
|
||||
|
||||
**CasinoDesign** - Shared design constants.
|
||||
|
||||
@ -37,3 +37,7 @@
|
||||
// - SoundManager
|
||||
// - 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