Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-17 12:04:42 -06:00
parent 7803a561ae
commit 3132d760ad
10 changed files with 1459 additions and 14 deletions

View File

@ -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;

View 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>

View File

@ -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,18 +528,33 @@ 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)
// 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)
sound.playWin(isBigWin: isBigWin && totalWinnings > 0)
} else if mainResult.isPush {
// Main bet pushed (tie)
sound.playPush()
} else {
// Main bet lost
sound.playLose()
}
} else {
// No main bet (only side bets) - use total winnings
if totalWinnings > 0 {
sound.playWin(isBigWin: false)
} else if totalWinnings < 0 {
sound.playLose()
} else {
// Push (tie with main bet push)
sound.playPush()
}
}
// Record result in history
roundHistory.append(RoundResult(
@ -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()

View File

@ -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

View 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
}
}
}

View File

@ -149,11 +149,13 @@ struct GameTableView: View {
}
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings) {
if let state = gameState {
SettingsView(settings: settings, gameState: state) {
// Apply settings when changed
gameState?.applySettings()
}
}
}
.fullScreenCover(isPresented: $showRules) {
RulesHelpView()
}

View File

@ -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()) { }
}

View File

@ -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.

View File

@ -37,3 +37,7 @@
// - SoundManager
// - GameSound
// MARK: - Storage
// - CloudSyncManager
// - PersistableGameData (protocol)

View 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")
}