diff --git a/Baccarat/Baccarat/Engine/GameState.swift b/Baccarat/Baccarat/Engine/GameState.swift index 90533c6..0662881 100644 --- a/Baccarat/Baccarat/Engine/GameState.swift +++ b/Baccarat/Baccarat/Engine/GameState.swift @@ -47,7 +47,29 @@ struct BetResult: Identifiable { /// Main observable game state class managing all game logic and UI state. @Observable @MainActor -final class GameState { +final class GameState: SessionManagedGame { + // MARK: - SessionManagedGame + + typealias Stats = BaccaratStats + + /// The currently active session. + var currentSession: BaccaratSession? + + /// History of completed sessions. + var sessionHistory: [BaccaratSession] = [] + + /// Starting balance for new sessions (from settings). + var startingBalance: Int { settings.startingBalance } + + /// Current game style identifier (deck count for Baccarat). + var currentGameStyle: String { settings.deckCount.displayName } + + /// Whether a session end has been requested (shows confirmation). + var showEndSessionConfirmation: Bool = false + + /// Round histories for completed sessions (keyed by session ID string). + private var sessionRoundHistories: [String: [SavedRoundResult]] = [:] + // MARK: - Settings let settings: GameSettings @@ -58,13 +80,13 @@ final class GameState { private let sound = SoundManager.shared // MARK: - Persistence - private var persistence: CloudSyncManager! + let persistence: CloudSyncManager // MARK: - Game Engine private(set) var engine: BaccaratEngine // MARK: - Player State - var balance: Int = 10_000 + var balance: Int = 1_000 var currentBets: [Bet] = [] // MARK: - Round State @@ -135,6 +157,11 @@ final class GameState { Array(roundHistory.suffix(20)) } + /// Whether the game is over (can't afford to meet minimum bet). + var isGameOver: Bool { + currentPhase == .betting && balance < settings.minBet + } + // MARK: - Hint System /// The current streak type (player wins, banker wins, or alternating/none). @@ -365,41 +392,39 @@ final class GameState { self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue) self.balance = settings.startingBalance self.onboarding = OnboardingState(gameIdentifier: "baccarat") + self.persistence = CloudSyncManager() // Sync sound settings with SoundManager syncSoundSettings() - // Initialize persistence with cloud data callback - self.persistence = CloudSyncManager() + // Set up iCloud callback persistence.onCloudDataReceived = { [weak self] cloudData in self?.handleCloudDataReceived(cloudData) } // Load saved game data loadSavedGame() + + // Ensure we have an active session + ensureActiveSession() } /// Handles data received from iCloud (e.g., after fresh install or from another device). private func handleCloudDataReceived(_ cloudData: BaccaratGameData) { - // Only update if cloud has more progress than current state - guard cloudData.roundsPlayed > roundHistory.count else { + guard cloudData.roundsPlayed > (currentSession?.roundsPlayed ?? 0) + sessionHistory.reduce(0, { $0 + $1.roundsPlayed }) else { return } - // Restore balance + // Restore balance and sessions self.balance = cloudData.balance + self.currentSession = cloudData.currentSession + self.sessionHistory = cloudData.sessionHistory + self.sessionRoundHistories = cloudData.sessionRoundHistories - // 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 - ) + // Restore round history for road map display + self.roundHistory = cloudData.currentSessionRoundHistory.compactMap { saved in + saved.toRoundResult() } } @@ -407,55 +432,59 @@ final class GameState { /// Loads saved game data from iCloud/local storage. private func loadSavedGame() { - let savedData = persistence.data + let data = persistence.load() + self.balance = data.balance + self.currentSession = data.currentSession + self.sessionHistory = data.sessionHistory + self.sessionRoundHistories = data.sessionRoundHistories - // Only restore if there's saved progress - guard savedData.roundsPlayed > 0 else { return } + // Restore round history for road map display + self.roundHistory = data.currentSessionRoundHistory.compactMap { saved in + saved.toRoundResult() + } - // 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 - ) + CasinoDesign.debugLog("📂 Loaded game data:") + CasinoDesign.debugLog(" - balance: \(data.balance)") + CasinoDesign.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")") + CasinoDesign.debugLog(" - sessionHistory count: \(data.sessionHistory.count)") + CasinoDesign.debugLog(" - roundHistory count: \(roundHistory.count)") + CasinoDesign.debugLog(" - sessionRoundHistories count: \(sessionRoundHistories.count)") + if let session = data.currentSession { + CasinoDesign.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s") } } /// 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] + func saveGameData() { + // Update current session before saving + if var session = currentSession { + session.endingBalance = balance + // Keep session's game style in sync with current settings + session.gameStyle = currentGameStyle + currentSession = session + + // Always keep the current session's round history in the archive + // This ensures it's available when the session ends and becomes historical + if !roundHistory.isEmpty { + let savedHistory = roundHistory.map { SavedRoundResult(from: $0) } + sessionRoundHistories[session.id.uuidString] = savedHistory } - // 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) } + // Convert round history for persistence + let savedRoundHistory = roundHistory.map { SavedRoundResult(from: $0) } + + let data = BaccaratGameData( + lastModified: Date(), + balance: balance, + currentSession: currentSession, + sessionHistory: sessionHistory, + currentSessionRoundHistory: savedRoundHistory, + sessionRoundHistories: sessionRoundHistories + ) persistence.save(data) + + CasinoDesign.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0), road history: \(roundHistory.count)") } /// Whether iCloud sync is available. @@ -748,10 +777,17 @@ final class GameState { playerHadPair = engine.playerHasPair bankerHadPair = engine.bankerHasPair + // Check for naturals + let isNatural = engine.playerHand.isNatural || engine.bankerHand.isNatural + // Calculate and apply payouts, track individual results var totalWinnings = 0 var results: [BetResult] = [] + // Track dragon bonus wins + var dragonPlayerWon = false + var dragonBankerWon = false + for bet in currentBets { let payout = engine.calculatePayout(bet: bet, result: result) totalWinnings += payout @@ -759,6 +795,14 @@ final class GameState { // Track individual bet result results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout)) + // Track dragon bonus wins + if bet.type == .dragonBonusPlayer && payout > 0 { + dragonPlayerWon = true + } + if bet.type == .dragonBonusBanker && payout > 0 { + dragonBankerWon = true + } + // Return original bet if not a loss if payout >= 0 { balance += bet.amount @@ -773,6 +817,40 @@ final class GameState { betResults = results lastWinnings = totalWinnings + // Determine round outcome for session stats + let isWin = totalWinnings > 0 + let isLoss = totalWinnings < 0 + let outcome: RoundOutcome = isWin ? .win : (isLoss ? .lose : .push) + + // Capture values for closure + let roundBetAmount = totalBetAmount + let wasPlayerWin = result == .playerWins + let wasBankerWin = result == .bankerWins + let wasTie = result == .tie + let hadPlayerPair = playerHadPair + let hadBankerPair = bankerHadPair + + // Record round in session using CasinoKit protocol + recordSessionRound( + winnings: totalWinnings, + betAmount: roundBetAmount, + outcome: outcome + ) { stats in + // Update Baccarat-specific stats + if isNatural { stats.naturals += 1 } + if wasPlayerWin { stats.playerWins += 1 } + if wasBankerWin { stats.bankerWins += 1 } + if wasTie { stats.ties += 1 } + if hadPlayerPair { stats.playerPairs += 1 } + if hadBankerPair { stats.bankerPairs += 1 } + if dragonPlayerWon { stats.dragonBonusPlayerWins += 1 } + if dragonBankerWon { stats.dragonBonusBankerWins += 1 } + } + + CasinoDesign.debugLog("📊 Session stats update:") + CasinoDesign.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)") + CasinoDesign.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds") + // 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 }) @@ -801,7 +879,7 @@ final class GameState { } } - // Record result in history + // Record result in history (for road map) roundHistory.append(RoundResult( result: result, playerValue: playerHandValue, @@ -810,8 +888,8 @@ final class GameState { bankerPair: bankerHadPair )) - // Save game state to iCloud/local - saveGame(netWinnings: totalWinnings) + // Save game data to iCloud + saveGameData() // Show result banner - stays until user taps New Round showResultBanner = true @@ -851,41 +929,98 @@ final class GameState { } } - /// Resets the game to initial state with current settings. - func resetGame() { + // MARK: - Session History Management + + /// Deletes a session from history by ID. + func deleteSession(id: UUID) { + sessionHistory.removeAll { $0.id == id } + sessionRoundHistories.removeValue(forKey: id.uuidString) + saveGameData() + } + + /// Deletes all session history. + func deleteAllSessionHistory() { + sessionHistory.removeAll() + sessionRoundHistories.removeAll() + saveGameData() + } + + // MARK: - Session Round History + + /// Gets round history for a specific session. + func roundHistory(for session: BaccaratSession) -> [RoundResult] { + // If it's the current session, return the live round history + if session.id == currentSession?.id { + return roundHistory + } + + // Otherwise look up from archived histories + guard let savedHistory = sessionRoundHistories[session.id.uuidString] else { + return [] + } + + return savedHistory.compactMap { $0.toRoundResult() } + } + + // MARK: - SessionManagedGame Implementation + + /// Resets game-specific state when starting a new session. + func resetForNewSession() { + // Note: Round history is already archived during saveGameData() calls + // Just clear the local round history for the new session + roundHistory = [] engine = BaccaratEngine(deckCount: settings.deckCount.rawValue) - balance = settings.startingBalance + newRoundInternal() + } + + /// Internal new round reset (without sound). + private func newRoundInternal() { currentBets = [] - currentPhase = .betting - lastResult = nil - lastWinnings = 0 visiblePlayerCards = [] visibleBankerCards = [] playerCardsFaceUp = [] bankerCardsFaceUp = [] - roundHistory = [] - isAnimating = false - showResultBanner = false + lastResult = nil + lastWinnings = 0 playerHadPair = false bankerHadPair = false betResults = [] - - // Save the reset state (keeps lifetime stats, resets balance and session history) - saveGame() + currentPhase = .betting + } + + /// Aggregated Baccarat-specific stats from all sessions. + var aggregatedBaccaratStats: BaccaratStats { + allSessions.aggregatedBaccaratStats() + } + + // MARK: - Game Reset + + /// Resets the entire game (keeps statistics). + func resetGame() { + balance = settings.startingBalance + roundHistory = [] + engine = BaccaratEngine(deckCount: settings.deckCount.rawValue) + startNewSession() + newRoundInternal() + saveGameData() // Play new game sound sound.playNewRound() } - /// Completely clears all saved data and starts fresh (including lifetime stats). + /// Completely clears all saved data and starts fresh. func clearAllData() { persistence.reset() - resetGame() - } - - /// Returns lifetime statistics from saved data. - var lifetimeStats: BaccaratGameData { - persistence.data + balance = settings.startingBalance + currentSession = nil + sessionHistory = [] + sessionRoundHistories = [:] + roundHistory = [] + startNewSession() + newRoundInternal() + + // Play new game sound + sound.playNewRound() } /// Applies new settings (call after settings change). diff --git a/Baccarat/Baccarat/Models/BaccaratStats.swift b/Baccarat/Baccarat/Models/BaccaratStats.swift new file mode 100644 index 0000000..74d08b7 --- /dev/null +++ b/Baccarat/Baccarat/Models/BaccaratStats.swift @@ -0,0 +1,111 @@ +// +// BaccaratStats.swift +// Baccarat +// +// Baccarat-specific statistics that conform to CasinoKit's GameSpecificStats. +// + +import Foundation +import SwiftUI +import CasinoKit + +/// Baccarat-specific session statistics. +/// Tracks naturals, banker/player wins, ties, pairs, and dragon bonus wins. +public struct BaccaratStats: GameSpecificStats { + /// Number of natural hands (8 or 9 on initial deal). + public var naturals: Int = 0 + + /// Number of banker win rounds. + public var bankerWins: Int = 0 + + /// Number of player win rounds. + public var playerWins: Int = 0 + + /// Number of tie rounds. + public var ties: Int = 0 + + /// Number of player pair occurrences. + public var playerPairs: Int = 0 + + /// Number of banker pair occurrences. + public var bankerPairs: Int = 0 + + /// Number of dragon bonus wins (player side). + public var dragonBonusPlayerWins: Int = 0 + + /// Number of dragon bonus wins (banker side). + public var dragonBonusBankerWins: Int = 0 + + // MARK: - GameSpecificStats + + public init() {} + + /// Display items for the statistics UI. + public var displayItems: [StatDisplayItem] { + [ + StatDisplayItem( + icon: "sparkles", + iconColor: .yellow, + label: String(localized: "Naturals"), + value: "\(naturals)", + valueColor: .yellow + ), + StatDisplayItem( + icon: "person.fill", + iconColor: .blue, + label: String(localized: "Player Wins"), + value: "\(playerWins)", + valueColor: .blue + ), + StatDisplayItem( + icon: "building.columns.fill", + iconColor: .red, + label: String(localized: "Banker Wins"), + value: "\(bankerWins)", + valueColor: .red + ), + StatDisplayItem( + icon: "equal.circle.fill", + iconColor: .green, + label: String(localized: "Ties"), + value: "\(ties)", + valueColor: .green + ), + StatDisplayItem( + icon: "suit.diamond.fill", + iconColor: .purple, + label: String(localized: "Pairs"), + value: "\(playerPairs + bankerPairs)", + valueColor: .purple + ) + ] + } +} + +// MARK: - Aggregation Extension + +extension Array where Element == GameSession { + /// Aggregates Baccarat-specific stats from all sessions. + func aggregatedBaccaratStats() -> BaccaratStats { + var combined = BaccaratStats() + + for session in self { + combined.naturals += session.gameStats.naturals + combined.bankerWins += session.gameStats.bankerWins + combined.playerWins += session.gameStats.playerWins + combined.ties += session.gameStats.ties + combined.playerPairs += session.gameStats.playerPairs + combined.bankerPairs += session.gameStats.bankerPairs + combined.dragonBonusPlayerWins += session.gameStats.dragonBonusPlayerWins + combined.dragonBonusBankerWins += session.gameStats.dragonBonusBankerWins + } + + return combined + } +} + +// MARK: - Type Aliases for Convenience + +/// Baccarat session type alias. +public typealias BaccaratSession = GameSession + diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index d85e97b..829a53e 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -354,6 +354,9 @@ } } } + }, + "$%lld" : { + }, "2-9: Face value" : { "comment" : "Description of the card values for cards with values from 2 to 9.", @@ -562,6 +565,10 @@ } } }, + "ALL TIME SUMMARY" : { + "comment" : "Title of a section in the statistics sheet that provides a summary of the user's performance over all time.", + "isCommentAutoGenerated" : true + }, "Alternative: Use an online tool" : { "comment" : "A section header that suggests using an online tool to generate app icon sizes.", "localizations" : { @@ -677,6 +684,10 @@ } } }, + "Average bet" : { + "comment" : "The value of this row is calculated as the total bet amount divided by the number of rounds played.", + "isCommentAutoGenerated" : true + }, "Avoid the Tie bet — 14.4% house edge!" : { "comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.", "localizations" : { @@ -701,6 +712,7 @@ } }, "B Pair" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -747,6 +759,9 @@ } } } + }, + "BALANCE" : { + }, "Banker" : { "localizations" : { @@ -1012,6 +1027,18 @@ "comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.", "isCommentAutoGenerated" : true }, + "Banker Wins" : { + "comment" : "Label for the number of banker win rounds in the statistics display.", + "isCommentAutoGenerated" : true + }, + "Best gain" : { + "comment" : "\"Best gain\" is a colloquial term for the largest positive winnings in a single game.", + "isCommentAutoGenerated" : true + }, + "Best session" : { + "comment" : "A label for the best session amount in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "Bet on Player, Banker, or Tie" : { }, @@ -1040,6 +1067,14 @@ }, "Betting tips and trend analysis" : { + }, + "BIG ROAD" : { + "comment" : "Title for the section in the statistics sheet that shows the user's performance on the Big Road.", + "isCommentAutoGenerated" : true + }, + "Biggest bet" : { + "comment" : "The label for the \"Biggest bet\" row in the statistics sheet.", + "isCommentAutoGenerated" : true }, "Blackjack" : { "comment" : "The name of a blackjack game.", @@ -1227,6 +1262,10 @@ }, "Change table limits and display options" : { + }, + "CHIPS STATS" : { + "comment" : "Section that shows statistics related to the user's chip count during a Baccarat session.", + "isCommentAutoGenerated" : true }, "Chips, cards, and result sounds" : { "comment" : "Subtitle describing sound effects toggle.", @@ -1343,6 +1382,13 @@ } } } + }, + "Completed sessions will appear here." : { + "comment" : "A description below the label indicating that completed sessions will be displayed here.", + "isCommentAutoGenerated" : true + }, + "Current" : { + }, "Customize Settings" : { @@ -1439,6 +1485,16 @@ } } } + }, + "Delete" : { + "comment" : "A button to delete a session.", + "isCommentAutoGenerated" : true + }, + "Delete Session" : { + + }, + "Delete Session?" : { + }, "DISPLAY" : { "comment" : "Section header for display settings.", @@ -1580,6 +1636,21 @@ "Dragon Bonus: high risk, high reward" : { "comment" : "Warning text for dragon bonus bets, advising players to be cautious.", "isCommentAutoGenerated" : true + }, + "End Session" : { + "comment" : "The text for a button that ends the current game session.", + "isCommentAutoGenerated" : true + }, + "End Session?" : { + "comment" : "A confirmation dialog title.", + "isCommentAutoGenerated" : true + }, + "Ended manually" : { + "comment" : "A description of a session that ended manually, rather than automatically.", + "isCommentAutoGenerated" : true + }, + "Ending balance" : { + }, "Example: 5♥ + 5♣ = Pair (wins!)" : { "comment" : "Example of a pair bet winning.", @@ -1675,6 +1746,10 @@ } } }, + "GAME STATS" : { + "comment" : "Section in the statistics sheet dedicated to displaying statistics specific to baccarat.", + "isCommentAutoGenerated" : true + }, "Generate & Save Icons" : { "comment" : "A button label that triggers the generation of app icons.", "localizations" : { @@ -1744,6 +1819,10 @@ } } }, + "Global" : { + "comment" : "Title of the statistics tab that shows global statistics.", + "isCommentAutoGenerated" : true + }, "Green Circle (T): Tie between Player and Banker" : { "comment" : "Explains the green circle icon in the history.", "localizations" : { @@ -1790,6 +1869,10 @@ } } }, + "Hands" : { + "comment" : "Label for the number of hands played in a summary stat column.", + "isCommentAutoGenerated" : true + }, "handValueFormat" : { "comment" : "Format for displaying hand value. The argument is the numeric value of the hand.", "localizations" : { @@ -1836,6 +1919,10 @@ } } }, + "History" : { + "comment" : "Title of the statistics tab that shows the user's session history.", + "isCommentAutoGenerated" : true + }, "HISTORY" : { "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", "localizations" : { @@ -2245,6 +2332,14 @@ } } }, + "Losing sessions" : { + "comment" : "A label describing the number of sessions the user has lost.", + "isCommentAutoGenerated" : true + }, + "Lost" : { + "comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.", + "isCommentAutoGenerated" : true + }, "Main Bets" : { "comment" : "Title of a rule page in the \"Rules\" help view, describing the main bets available in baccarat.", "localizations" : { @@ -2381,6 +2476,13 @@ } } } + }, + "Net" : { + "comment" : "Label for the net winnings in the statistics sheet.", + "isCommentAutoGenerated" : true + }, + "Net result" : { + }, "Never" : { "localizations" : { @@ -2427,6 +2529,10 @@ } } }, + "No Active Session" : { + "comment" : "A message displayed when there is no active session to display in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "No cards" : { "comment" : "A description of the player's hand when they have no cards.", "localizations" : { @@ -2495,6 +2601,10 @@ } } }, + "No Session History" : { + "comment" : "A description displayed when a user has no session history.", + "isCommentAutoGenerated" : true + }, "Objective" : { "comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.", "localizations" : { @@ -2634,6 +2744,7 @@ } }, "P Pair" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2732,6 +2843,10 @@ } } }, + "Pairs" : { + "comment" : "Label for the total number of player and banker pair occurrences in the statistics UI.", + "isCommentAutoGenerated" : true + }, "Pairs occur roughly once every 15 hands." : { "comment" : "Explanation of how often pairs occur in a typical game of baccarat.", "localizations" : { @@ -2886,6 +3001,10 @@ "comment" : "A hint to place a bet on the Player, given a significant house edge in favor of the Player. The percentage is included as a context hint.", "isCommentAutoGenerated" : true }, + "Player Wins" : { + "comment" : "Label for the \"Player Wins\" stat in the statistics UI.", + "isCommentAutoGenerated" : true + }, "Player with 0-5: Draws a third card" : { "comment" : "Description of the action for the Player when their third card is 0-5.", "localizations" : { @@ -3003,6 +3122,14 @@ } } }, + "Push" : { + "comment" : "A label for the \"Push\" outcome in the game stats section.", + "isCommentAutoGenerated" : true + }, + "Ran out of chips" : { + "comment" : "A description of why a session might have ended with a \"Ran out of chips\" result.", + "isCommentAutoGenerated" : true + }, "Red Circle (B): Banker won the hand" : { "comment" : "Explains the red circle icon in the history.", "localizations" : { @@ -3129,6 +3256,7 @@ } }, "Rounds" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3150,6 +3278,10 @@ } } }, + "Rounds played" : { + "comment" : "A label displayed next to the number of rounds played in a session.", + "isCommentAutoGenerated" : true + }, "Rounds Played" : { "comment" : "A label displayed next to the number of rounds played in the game over screen.", "extractionState" : "stale", @@ -3176,6 +3308,14 @@ }, "Select a chip and tap a bet zone" : { + }, + "SESSION PERFORMANCE" : { + "comment" : "Title of a section in the statistics sheet that details session performance metrics.", + "isCommentAutoGenerated" : true + }, + "Sessions" : { + "comment" : "Label for the number of sessions played in the statistics sheet.", + "isCommentAutoGenerated" : true }, "Set a budget and stick to it." : { "comment" : "Tip for players to set a budget and stick to it when playing baccarat.", @@ -3389,8 +3529,15 @@ } } }, + "Start playing to begin tracking your session." : { + "comment" : "A description below the \"No Active Session\" label, instructing the user to start playing to view their session statistics.", + "isCommentAutoGenerated" : true + }, "Start with $1,000 and play risk-free" : { + }, + "Starting balance" : { + }, "STARTING BALANCE" : { "comment" : "Section header for starting balance settings.", @@ -3724,6 +3871,9 @@ } } } + }, + "This will permanently remove this session from your history." : { + }, "Tie" : { "localizations" : { @@ -3797,6 +3947,14 @@ "comment" : "Warning message for a tie bet, explaining the high house edge.", "isCommentAutoGenerated" : true }, + "Ties" : { + "comment" : "Description of a baccarat statistics category for tie rounds.", + "isCommentAutoGenerated" : true + }, + "Time" : { + "comment" : "Label for the duration of a session in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "TOTAL" : { "comment" : "A label displayed next to the total winnings in the result banner.", "extractionState" : "stale", @@ -3821,6 +3979,18 @@ } } }, + "Total bet" : { + "comment" : "The value string for the \"Total bet\" row in the statistics sheet.", + "isCommentAutoGenerated" : true + }, + "Total gain" : { + "comment" : "Label for the total gain in the statistics sheet.", + "isCommentAutoGenerated" : true + }, + "Total game time" : { + "comment" : "Rows in the \"Game stats\" section of the statistics sheet, showing various statistics about a Baccarat session.", + "isCommentAutoGenerated" : true + }, "Total Winnings" : { "localizations" : { "en" : { @@ -4099,6 +4269,14 @@ } } }, + "win rate" : { + "comment" : "A label describing the win rate of a session.", + "isCommentAutoGenerated" : true + }, + "Win Rate" : { + "comment" : "Label for the win rate in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "Winner" : { "comment" : "A description of the player's hand, including its value and whether they won.", "localizations" : { @@ -4122,6 +4300,22 @@ } } }, + "Winning sessions" : { + "comment" : "A title describing the number of sessions the user has won.", + "isCommentAutoGenerated" : true + }, + "Won" : { + "comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.", + "isCommentAutoGenerated" : true + }, + "Worst loss" : { + "comment" : "The label and value for the \"Worst loss\" row are identical to those for the \"Best gain\" row. This is intentional, as it highlights the symmetry in the data.", + "isCommentAutoGenerated" : true + }, + "Worst session" : { + "comment" : "A label for the worst session amount in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "Yellow Dot (bottom-left): A pair occurred in that hand" : { "comment" : "Explains the yellow dot marker in the history.", "localizations" : { @@ -4168,6 +4362,18 @@ } } }, + "You played %lld hands with a net result of %@. This session will be saved to your history." : { + "comment" : "A message displayed when a user ends a game session. The first argument is the number of rounds played in the session. The second argument is the net result of the session, formatted as currency.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You played %1$lld hands with a net result of %2$@. This session will be saved to your history." + } + } + } + }, "You've run out of chips!" : { "comment" : "A message displayed when a player runs out of money in the game over screen.", "extractionState" : "stale", diff --git a/Baccarat/Baccarat/Storage/BaccaratGameData.swift b/Baccarat/Baccarat/Storage/BaccaratGameData.swift index 8aa5fb5..065e3e0 100644 --- a/Baccarat/Baccarat/Storage/BaccaratGameData.swift +++ b/Baccarat/Baccarat/Storage/BaccaratGameData.swift @@ -9,75 +9,54 @@ import Foundation import CasinoKit /// Persisted data for Baccarat game. -public struct BaccaratGameData: PersistableGameData { +public struct BaccaratGameData: PersistableGameData, SessionPersistable { // MARK: - PersistableGameData public static let gameIdentifier = "baccarat" public var roundsPlayed: Int { - roundHistory.count + // Total rounds from all sessions + let historicalRounds = sessionHistory.reduce(0) { $0 + $1.roundsPlayed } + let currentRounds = currentSession?.roundsPlayed ?? 0 + return historicalRounds + currentRounds } public static var empty: BaccaratGameData { BaccaratGameData( - balance: 10_000, - roundHistory: [], - totalWinnings: 0, - biggestWin: 0, - biggestLoss: 0, - lastModified: Date() + lastModified: Date(), + balance: 1_000, + currentSession: nil, + sessionHistory: [], + currentSessionRoundHistory: [], + sessionRoundHistories: [:] ) } // 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 + /// Current chip balance. + public var balance: Int - /// Number of Player wins. - public var playerWins: Int { - roundHistory.filter { $0.result == "player" }.count - } + /// The currently active session (nil if no session started). + public var currentSession: BaccaratSession? - /// Number of Banker wins. - public var bankerWins: Int { - roundHistory.filter { $0.result == "banker" }.count - } + /// History of completed sessions. + public var sessionHistory: [BaccaratSession] - /// Number of Tie games. - public var tieGames: Int { - roundHistory.filter { $0.result == "tie" }.count - } + /// Round history for the current session (for road map display). + /// This is cleared when a new session starts. + public var currentSessionRoundHistory: [SavedRoundResult] - /// 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 - } + /// Round histories for completed sessions (keyed by session ID string). + /// Used to display road maps when viewing historical sessions. + public var sessionRoundHistories: [String: [SavedRoundResult]] } -/// Codable round result for persistence. +/// Codable round result for persistence (for road map display). public struct SavedRoundResult: Codable, Identifiable, Sendable { public let id: UUID public let result: String // "player", "banker", "tie" @@ -87,7 +66,6 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable { public let bankerPair: Bool public let isNatural: Bool public let timestamp: Date - public let netWinnings: Int public init( id: UUID = UUID(), @@ -97,8 +75,7 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable { playerPair: Bool, bankerPair: Bool, isNatural: Bool, - timestamp: Date = Date(), - netWinnings: Int + timestamp: Date = Date() ) { self.id = id self.result = result @@ -108,15 +85,14 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable { 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) { + /// Creates a SavedRoundResult from a RoundResult. + init(from roundResult: RoundResult) { self.id = roundResult.id self.result = roundResult.result.persistenceKey self.playerValue = roundResult.playerValue @@ -125,7 +101,18 @@ extension SavedRoundResult { self.bankerPair = roundResult.bankerPair self.isNatural = roundResult.isNatural self.timestamp = roundResult.timestamp - self.netWinnings = netWinnings + } + + /// Converts back to RoundResult for display. + func toRoundResult() -> RoundResult? { + guard let gameResult = GameResult(persistenceKey: result) else { return nil } + return RoundResult( + result: gameResult, + playerValue: playerValue, + bankerValue: bankerValue, + playerPair: playerPair, + bankerPair: bankerPair + ) } } @@ -151,4 +138,3 @@ extension GameResult { } } } - diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift index b55e545..bc4c5ba 100644 --- a/Baccarat/Baccarat/Views/Game/GameTableView.swift +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -131,7 +131,7 @@ struct GameTableView: View { RulesHelpView() } .sheet(isPresented: $showStats) { - StatisticsSheetView(results: state.roundHistory) + StatisticsSheetView(state: state) } .sheet(isPresented: $showWelcome) { WelcomeSheet( diff --git a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift index f0f3252..a3659c0 100644 --- a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift +++ b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift @@ -228,7 +228,7 @@ struct SettingsView: View { .font(.system(size: Design.BaseFontSize.body)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) - Text("\(gameState.lifetimeStats.roundsPlayed)") + Text("\(gameState.aggregatedStats.totalRoundsPlayed)") .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .foregroundStyle(.white) } @@ -240,7 +240,7 @@ struct SettingsView: View { .font(.system(size: Design.BaseFontSize.body)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) - let winnings = gameState.lifetimeStats.totalWinnings + let winnings = gameState.aggregatedStats.totalWinnings Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))") .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .foregroundStyle(winnings >= 0 ? .green : .red) diff --git a/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift b/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift index df5194a..075b9dc 100644 --- a/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift +++ b/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift @@ -2,228 +2,598 @@ // StatisticsSheetView.swift // Baccarat // -// Detailed statistics and scoreboard view. +// Game statistics with session history and per-style stats. // import SwiftUI import CasinoKit -/// A sheet that displays detailed game statistics and Big Road scoreboard. struct StatisticsSheetView: View { - let results: [RoundResult] + @Bindable var state: GameState @Environment(\.dismiss) private var dismiss - - // MARK: - Computed Statistics - - private var totalRounds: Int { results.count } - - private var playerWins: Int { - results.filter { $0.result == .playerWins }.count - } - - private var bankerWins: Int { - results.filter { $0.result == .bankerWins }.count - } - - private var tieCount: Int { - results.filter { $0.result == .tie }.count - } - - private var playerPairs: Int { - results.filter { $0.playerPair }.count - } - - private var bankerPairs: Int { - results.filter { $0.bankerPair }.count - } - - private var naturals: Int { - results.filter { $0.isNatural }.count - } - - private func percentage(_ count: Int) -> String { - guard totalRounds > 0 else { return "0%" } - let pct = Double(count) / Double(totalRounds) * 100 - return String(format: "%.0f%%", pct) - } + @State private var selectedTab: StatisticsTab = .current + @State private var selectedSession: BaccaratSession? var body: some View { SheetContainerView( title: String(localized: "Statistics"), content: { - // Summary stats - summarySection + // Tab selector + tabSelector - // Win distribution - winDistributionSection - - // Side bet frequency - sideBetSection - - // Big Road display - bigRoadSection - }, - onDone: { - dismiss() + // Content based on selected tab + switch selectedTab { + case .current: + currentSessionContent + case .global: + globalStatsContent + case .history: + sessionHistoryContent + } }, + onCancel: nil, + onDone: { dismiss() }, doneButtonText: String(localized: "Done") ) - } - - // MARK: - Summary Section - - private var summarySection: some View { - SheetSection(title: "SESSION SUMMARY", icon: "chart.pie.fill") { - HStack(spacing: Design.Spacing.xLarge) { - StatBox( - value: "\(totalRounds)", - label: String(localized: "Rounds"), - color: .white - ) - - StatBox( - value: "\(naturals)", - label: String(localized: "Naturals"), - color: .yellow - ) + .confirmationDialog( + String(localized: "End Session?"), + isPresented: $state.showEndSessionConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "End Session"), role: .destructive) { + // Dismiss first for responsive UI, then end session + dismiss() + Task { @MainActor in + state.endSessionAndStartNew() + } } - .frame(maxWidth: .infinity) + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + if let session = state.currentSession { + Text(String(localized: "You played \(session.roundsPlayed) hands with a net result of \(SessionFormatter.formatMoney(session.netResult)). This session will be saved to your history.")) + } + } + .sheet(item: $selectedSession) { session in + SessionDetailView( + session: session, + styleDisplayName: styleDisplayName(for: session.gameStyle), + roundHistory: state.roundHistory(for: session), + onDelete: { + state.deleteSession(id: session.id) + selectedSession = nil + } + ) } } - // MARK: - Win Distribution Section + // MARK: - Tab Selector - private var winDistributionSection: some View { - SheetSection(title: "WIN DISTRIBUTION", icon: "trophy.fill") { - VStack(spacing: Design.Spacing.medium) { + private var tabSelector: some View { + HStack(spacing: 0) { + ForEach(StatisticsTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(duration: Design.Animation.springDuration)) { + selectedTab = tab + } + } label: { + VStack(spacing: Design.Spacing.xSmall) { + Image(systemName: tab.icon) + .font(.system(size: Design.BaseFontSize.large)) + Text(tab.title) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + } + .foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(Design.Opacity.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(selectedTab == tab ? Color.Sheet.accent.opacity(Design.Opacity.hint) : .clear) + ) + } + } + } + .padding(.horizontal) + } + + // MARK: - Current Session Content + + @ViewBuilder + private var currentSessionContent: some View { + if let session = state.currentSession { + // Current session header + CurrentSessionHeader( + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + onEndSession: { + state.showEndSessionConfirmation = true + } + ) + .padding(.horizontal) + + // Session stats + sessionStatsSection(session: session) + + // Road displays for current session + bigRoadSection + roadMapSection + } else { + noActiveSessionView + } + } + + private var noActiveSessionView: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "play.slash") + .font(.system(size: Design.BaseFontSize.xxLarge * 2)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "No Active Session")) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Start playing to begin tracking your session.")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .multilineTextAlignment(.center) + } + .padding(Design.Spacing.xxLarge) + } + + // MARK: - Global Stats Content + + private var globalStatsContent: some View { + let stats = state.aggregatedStats + let gameStats = state.aggregatedBaccaratStats + + return Group { + // Summary section + SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") { + VStack(spacing: Design.Spacing.large) { + // Sessions overview + HStack(spacing: Design.Spacing.large) { + StatColumn( + value: "\(stats.totalSessions)", + label: String(localized: "Sessions") + ) + StatColumn( + value: "\(stats.totalRoundsPlayed)", + label: String(localized: "Hands") + ) + StatColumn( + value: SessionFormatter.formatPercent(stats.winRate), + label: String(localized: "Win Rate"), + valueColor: stats.winRate >= 50 ? .green : .orange + ) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Financial summary + HStack(spacing: Design.Spacing.large) { + StatColumn( + value: SessionFormatter.formatMoney(stats.totalWinnings), + label: String(localized: "Net"), + valueColor: stats.totalWinnings >= 0 ? .green : .red + ) + StatColumn( + value: SessionFormatter.formatDuration(stats.totalPlayTime), + label: String(localized: "Time") + ) + } + } + } + + // Baccarat-specific stats + SheetSection(title: String(localized: "GAME STATS"), icon: "suit.diamond.fill") { + VStack(spacing: Design.Spacing.medium) { + ForEach(gameStats.displayItems) { item in + GameStatRow(item: item) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Win distribution + HStack(spacing: Design.Spacing.large) { + WinStatCompact( + label: String(localized: "Player"), + count: gameStats.playerWins, + color: .blue + ) + WinStatCompact( + label: String(localized: "Banker"), + count: gameStats.bankerWins, + color: .red + ) + WinStatCompact( + label: String(localized: "Tie"), + count: gameStats.ties, + color: .green + ) + } + } + } + + // Session performance + SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") { + VStack(spacing: Design.Spacing.medium) { + HStack { + Text(String(localized: "Winning sessions")) + Spacer() + Text("\(stats.winningSessions)") + .foregroundStyle(.green) + .bold() + } + HStack { + Text(String(localized: "Losing sessions")) + Spacer() + Text("\(stats.losingSessions)") + .foregroundStyle(.red) + .bold() + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + HStack { + Text(String(localized: "Best session")) + Spacer() + Text(SessionFormatter.formatMoney(stats.bestSession)) + .foregroundStyle(.green) + .bold() + } + HStack { + Text(String(localized: "Worst session")) + Spacer() + Text(SessionFormatter.formatMoney(stats.worstSession)) + .foregroundStyle(.red) + .bold() + } + } + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + } + } + + // MARK: - Session History Content + + private var sessionHistoryContent: some View { + Group { + if state.sessionHistory.isEmpty && state.currentSession == nil { + emptyHistoryView + } else { + LazyVStack(spacing: Design.Spacing.medium) { + // Current session at top if exists (taps go to Current tab) + if let current = state.currentSession { + Button { + withAnimation { + selectedTab = .current + } + } label: { + SessionSummaryRow( + styleDisplayName: styleDisplayName(for: current.gameStyle), + duration: current.duration, + roundsPlayed: current.roundsPlayed, + netResult: current.netResult, + startTime: current.startTime, + isActive: true, + endReason: nil + ) + } + .buttonStyle(.plain) + } + + // Historical sessions - tap to view details, swipe to delete + ForEach(state.sessionHistory) { session in + Button { + selectedSession = session + } label: { + HStack { + SessionSummaryRow( + styleDisplayName: styleDisplayName(for: session.gameStyle), + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + startTime: session.startTime, + isActive: false, + endReason: session.endReason + ) + + Image(systemName: "chevron.right") + .font(.system(size: Design.BaseFontSize.small, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + } + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + withAnimation { + state.deleteSession(id: session.id) + } + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } + } + } + .padding(.horizontal) + } + } + } + + private var emptyHistoryView: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: Design.BaseFontSize.xxLarge * 2)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "No Session History")) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Completed sessions will appear here.")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .multilineTextAlignment(.center) + } + .padding(Design.Spacing.xxLarge) + } + + // MARK: - Session Stats Section + + @ViewBuilder + private func sessionStatsSection(session: BaccaratSession) -> some View { + // Game stats section + SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") { + VStack(spacing: Design.Spacing.large) { + // Rounds played + VStack(spacing: Design.Spacing.xSmall) { + Text(String(localized: "Rounds played")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + Text("\(session.roundsPlayed)") + .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + + // Win/Loss/Push HStack(spacing: Design.Spacing.medium) { - WinStatView( - title: String(localized: "Player"), - count: playerWins, - percentage: percentage(playerWins), + OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) + OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) + OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Game time and Baccarat-specific stats + StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration)) + + ForEach(session.gameStats.displayItems) { item in + GameStatRow(item: item) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Win distribution + HStack(spacing: Design.Spacing.large) { + WinStatCompact( + label: String(localized: "Player"), + count: session.gameStats.playerWins, color: .blue ) - - WinStatView( - title: String(localized: "Tie"), - count: tieCount, - percentage: percentage(tieCount), - color: .green - ) - - WinStatView( - title: String(localized: "Banker"), - count: bankerWins, - percentage: percentage(bankerWins), + WinStatCompact( + label: String(localized: "Banker"), + count: session.gameStats.bankerWins, color: .red ) - } - - // Win bar visualization - if totalRounds > 0 { - WinDistributionBar( - playerWins: playerWins, - tieCount: tieCount, - bankerWins: bankerWins + WinStatCompact( + label: String(localized: "Tie"), + count: session.gameStats.ties, + color: .green ) - .frame(height: Design.Spacing.large) - .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) } } } - } - - // MARK: - Side Bet Section - - private var sideBetSection: some View { - SheetSection(title: "SIDE BET FREQUENCY", icon: "sparkles") { - HStack(spacing: Design.Spacing.xLarge) { - PairStatView( - title: String(localized: "P Pair"), - count: playerPairs, - percentage: percentage(playerPairs), - color: .blue + + // Chips stats section + SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") { + VStack(spacing: Design.Spacing.medium) { + ChipStatRow( + icon: "chart.line.uptrend.xyaxis", + iconColor: session.totalWinnings >= 0 ? .green : .red, + label: String(localized: "Total gain"), + value: SessionFormatter.formatMoney(session.totalWinnings) ) - PairStatView( - title: String(localized: "B Pair"), - count: bankerPairs, - percentage: percentage(bankerPairs), - color: .red + ChipStatRow( + icon: "arrow.up.circle.fill", + iconColor: .green, + label: String(localized: "Best gain"), + value: SessionFormatter.formatMoney(session.biggestWin) + ) + + ChipStatRow( + icon: "arrow.down.circle.fill", + iconColor: .red, + label: String(localized: "Worst loss"), + value: SessionFormatter.formatMoney(session.biggestLoss) + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + ChipStatRow( + icon: "plusminus.circle.fill", + iconColor: .blue, + label: String(localized: "Total bet"), + value: "$\(session.totalBetAmount)" + ) + + if session.roundsPlayed > 0 { + ChipStatRow( + icon: "equal.circle.fill", + iconColor: .purple, + label: String(localized: "Average bet"), + value: "$\(session.averageBet)" + ) + } + + ChipStatRow( + icon: "star.circle.fill", + iconColor: .orange, + label: String(localized: "Biggest bet"), + value: "$\(session.biggestBet)" ) } - .frame(maxWidth: .infinity) } } - // MARK: - Big Road Section + // MARK: - Road Sections private var bigRoadSection: some View { - SheetSection(title: "BIG ROAD", icon: "chart.bar.xaxis") { - if results.isEmpty { - Text(String(localized: "No rounds played yet")) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.secondary)) - .frame(maxWidth: .infinity) - .padding(.vertical, Design.Spacing.xLarge) + SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") { + if state.roundHistory.isEmpty { + emptyRoadPlaceholder } else { - BigRoadView(results: results) - .frame(height: Design.Size.bigRoadHeight) + BigRoadView(results: state.roundHistory) + .frame(height: Size.bigRoadHeight) } } } + + private var roadMapSection: some View { + SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") { + if state.roundHistory.isEmpty { + emptyRoadPlaceholder + } else { + // Horizontal display matching what's shown during gameplay + ScrollView(.horizontal) { + HStack(spacing: Design.Spacing.xSmall) { + ForEach(state.roundHistory) { result in + RoadDot( + result: result.result, + dotSize: Size.roadDotSize, + hasPair: result.hasPair, + isNatural: result.isNatural + ) + } + } + .padding(.vertical, Design.Spacing.xSmall) + } + .scrollIndicators(.hidden) + } + } + } + + private var emptyRoadPlaceholder: some View { + Text(String(localized: "No rounds played yet")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, Design.Spacing.xLarge) + } + + // MARK: - Helpers + + private func styleDisplayName(for rawValue: String) -> String { + // Return the deck count display name + DeckCount(rawValue: Int(rawValue) ?? 8)?.displayName ?? rawValue + } +} + +// MARK: - Statistics Tab + +private enum StatisticsTab: CaseIterable { + case current + case global + case history + + var title: String { + switch self { + case .current: return String(localized: "Current") + case .global: return String(localized: "Global") + case .history: return String(localized: "History") + } + } + + var icon: String { + switch self { + case .current: return "play.circle.fill" + case .global: return "globe" + case .history: return "clock.arrow.circlepath" + } + } } // MARK: - Supporting Views -/// A box displaying a single statistic. -private struct StatBox: View { +private struct StatColumn: View { let value: String let label: String - let color: Color + var valueColor: Color = .white var body: some View { VStack(spacing: Design.Spacing.xSmall) { Text(value) - .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) - .foregroundStyle(color) - + .font(.system(size: Design.BaseFontSize.large, weight: .bold, design: .rounded)) + .foregroundStyle(valueColor) Text(label) .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(.white.opacity(Design.Opacity.secondary)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) } - .frame(minWidth: Design.Size.statBoxMinWidth) - .padding(Design.Spacing.medium) - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.black.opacity(Design.Opacity.overlay)) - ) + .frame(maxWidth: .infinity) } } -/// A win stat display with count and percentage. -private struct WinStatView: View { - let title: String +private struct OutcomeCircle: View { + let label: String + let count: Int + let color: Color + + var body: some View { + VStack(spacing: Design.Spacing.small) { + Text(label) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text("\(count)") + .font(.system(size: Design.BaseFontSize.large, weight: .bold)) + .foregroundStyle(.white) + + ZStack { + Circle() + .fill(Color.black.opacity(Design.Opacity.light)) + .frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize) + + Circle() + .stroke(color, lineWidth: Design.LineWidth.thick) + .frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize) + + Circle() + .fill(color.opacity(Design.Opacity.medium)) + .frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner) + } + } + .frame(maxWidth: .infinity) + } +} + +private struct WinStatCompact: View { + let label: String let count: Int - let percentage: String let color: Color var body: some View { VStack(spacing: Design.Spacing.xSmall) { Circle() .fill(color) - .frame(width: Design.Size.winIndicatorSize, height: Design.Size.winIndicatorSize) + .frame(width: Size.winIndicatorSize, height: Size.winIndicatorSize) Text("\(count)") - .font(.system(size: Design.BaseFontSize.title, weight: .bold)) + .font(.system(size: Design.BaseFontSize.large, weight: .bold)) .foregroundStyle(.white) - Text(percentage) - .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(.white.opacity(Design.Opacity.secondary)) - - Text(title) + Text(label) .font(.system(size: Design.BaseFontSize.small)) .foregroundStyle(color) } @@ -231,70 +601,321 @@ private struct WinStatView: View { } } -/// A pair stat display. -private struct PairStatView: View { - let title: String - let count: Int - let percentage: String - let color: Color +private struct StatRow: View { + let icon: String + let label: String + let value: String + var valueColor: Color = .white var body: some View { - VStack(spacing: Design.Spacing.xSmall) { - Text("\(count)") - .font(.system(size: Design.BaseFontSize.title, weight: .bold)) - .foregroundStyle(color) + HStack { + Image(systemName: icon) + .font(.system(size: Design.BaseFontSize.large)) + .foregroundStyle(Color.Sheet.accent) + .frame(width: Size.statIconWidth) - Text(percentage) - .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(.white.opacity(Design.Opacity.secondary)) + Text(label) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) - Text(title) - .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) + Spacer() + + Text(value) + .font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded)) + .foregroundStyle(valueColor) } } } -/// A horizontal bar showing win distribution. -private struct WinDistributionBar: View { - let playerWins: Int - let tieCount: Int - let bankerWins: Int - - private var total: Int { playerWins + tieCount + bankerWins } +private struct ChipStatRow: View { + let icon: String + let iconColor: Color + let label: String + let value: String var body: some View { - GeometryReader { geometry in - HStack(spacing: 0) { - if playerWins > 0 { - Rectangle() - .fill(Color.blue) - .frame(width: geometry.size.width * CGFloat(playerWins) / CGFloat(total)) + HStack { + ZStack { + Circle() + .fill(iconColor) + .frame(width: Size.chipIconSize, height: Size.chipIconSize) + + Image(systemName: icon) + .font(.system(size: Design.BaseFontSize.small, weight: .bold)) + .foregroundStyle(.white) + } + + Text(label) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Spacer() + + Text(value) + .font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.Sheet.accent) + } + } +} + +// MARK: - Session Detail View + +private struct SessionDetailView: View { + let session: BaccaratSession + let styleDisplayName: String + let roundHistory: [RoundResult] + let onDelete: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var showDeleteConfirmation = false + + var body: some View { + SheetContainerView( + title: styleDisplayName, + content: { + // Session header info + sessionHeader + + // Game stats section + SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") { + VStack(spacing: Design.Spacing.large) { + // Rounds played + VStack(spacing: Design.Spacing.xSmall) { + Text(String(localized: "Rounds played")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + Text("\(session.roundsPlayed)") + .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + + // Win/Loss/Push + HStack(spacing: Design.Spacing.medium) { + OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) + OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) + OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Game time + StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration)) + + // Baccarat-specific stats + ForEach(session.gameStats.displayItems) { item in + GameStatRow(item: item) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Win distribution + HStack(spacing: Design.Spacing.large) { + WinStatCompact( + label: String(localized: "Player"), + count: session.gameStats.playerWins, + color: .blue + ) + WinStatCompact( + label: String(localized: "Banker"), + count: session.gameStats.bankerWins, + color: .red + ) + WinStatCompact( + label: String(localized: "Tie"), + count: session.gameStats.ties, + color: .green + ) + } + } } - if tieCount > 0 { - Rectangle() - .fill(Color.green) - .frame(width: geometry.size.width * CGFloat(tieCount) / CGFloat(total)) + // Chips stats section + SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") { + VStack(spacing: Design.Spacing.medium) { + ChipStatRow( + icon: "chart.line.uptrend.xyaxis", + iconColor: session.totalWinnings >= 0 ? .green : .red, + label: String(localized: "Net result"), + value: SessionFormatter.formatMoney(session.totalWinnings) + ) + + ChipStatRow( + icon: "arrow.up.circle.fill", + iconColor: .green, + label: String(localized: "Best gain"), + value: SessionFormatter.formatMoney(session.biggestWin) + ) + + ChipStatRow( + icon: "arrow.down.circle.fill", + iconColor: .red, + label: String(localized: "Worst loss"), + value: SessionFormatter.formatMoney(session.biggestLoss) + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + ChipStatRow( + icon: "plusminus.circle.fill", + iconColor: .blue, + label: String(localized: "Total bet"), + value: "$\(session.totalBetAmount)" + ) + + if session.roundsPlayed > 0 { + ChipStatRow( + icon: "equal.circle.fill", + iconColor: .purple, + label: String(localized: "Average bet"), + value: "$\(session.averageBet)" + ) + } + + ChipStatRow( + icon: "star.circle.fill", + iconColor: .orange, + label: String(localized: "Biggest bet"), + value: "$\(session.biggestBet)" + ) + } } - if bankerWins > 0 { - Rectangle() - .fill(Color.red) - .frame(width: geometry.size.width * CGFloat(bankerWins) / CGFloat(total)) + // Balance section + SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") { + VStack(spacing: Design.Spacing.medium) { + HStack { + Text(String(localized: "Starting balance")) + Spacer() + Text("$\(session.startingBalance)") + .bold() + } + HStack { + Text(String(localized: "Ending balance")) + Spacer() + Text("$\(session.endingBalance)") + .foregroundStyle(session.netResult >= 0 ? .green : .red) + .bold() + } + } + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + + // Big Road section + if !roundHistory.isEmpty { + SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") { + BigRoadView(results: roundHistory) + .frame(height: Size.bigRoadHeight) + } + + // History road section + SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") { + ScrollView(.horizontal) { + HStack(spacing: Design.Spacing.xSmall) { + ForEach(roundHistory) { result in + RoadDot( + result: result.result, + dotSize: Size.roadDotSize, + hasPair: result.hasPair, + isNatural: result.isNatural + ) + } + } + .padding(.vertical, Design.Spacing.xSmall) + } + .scrollIndicators(.hidden) + } + } + + // Delete button + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + HStack { + Image(systemName: "trash") + Text(String(localized: "Delete Session")) + } + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.red.opacity(Design.Opacity.hint)) + ) + .foregroundStyle(.red) + } + .padding(.horizontal) + .padding(.top, Design.Spacing.large) + }, + onCancel: nil, + onDone: { dismiss() }, + doneButtonText: String(localized: "Done") + ) + .confirmationDialog( + String(localized: "Delete Session?"), + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "Delete"), role: .destructive) { + onDelete() + dismiss() + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "This will permanently remove this session from your history.")) + } + } + + private var sessionHeader: some View { + VStack(spacing: Design.Spacing.small) { + // Date and duration + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(SessionFormatter.formatSessionDate(session.startTime)) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + if let endReason = session.endReason { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: endReason == .brokeOut ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundStyle(endReason == .brokeOut ? .red : .green) + Text(endReason == .brokeOut ? String(localized: "Ran out of chips") : String(localized: "Ended manually")) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .font(.system(size: Design.BaseFontSize.small)) + } + } + + Spacer() + + // Net result badge + VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) { + Text(SessionFormatter.formatMoney(session.netResult)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) + .foregroundStyle(session.netResult >= 0 ? .green : .red) + + Text(SessionFormatter.formatPercent(session.winRate) + " " + String(localized: "win rate")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) } } } + .padding() + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.Sheet.sectionFill) + ) + .padding(.horizontal) } } -/// The Big Road scoreboard - a grid showing result patterns. -/// Results are arranged in columns, with each column representing a streak of same results. +// MARK: - Big Road View + private struct BigRoadView: View { let results: [RoundResult] private let maxRows = 6 - private let cellSize: CGFloat = Design.Size.bigRoadCellSize + private let cellSize: CGFloat = Size.bigRoadCellSize /// Convert results into columns for Big Road display. private var columns: [[RoundResult]] { @@ -303,11 +924,10 @@ private struct BigRoadView: View { var lastResult: GameResult? for result in results { - // Skip ties for column tracking (ties go in the current column) let currentResult = result.result if currentResult == .tie { - // Ties don't start new columns, they go with the current streak + // Ties don't start new columns if !currentCol.isEmpty { currentCol.append(result) } else if !cols.isEmpty { @@ -316,11 +936,9 @@ private struct BigRoadView: View { currentCol.append(result) } } else if lastResult == nil || currentResult == lastResult { - // Same as last or first result - continue column currentCol.append(result) lastResult = currentResult } else { - // Different result - start new column if !currentCol.isEmpty { cols.append(currentCol) } @@ -329,7 +947,6 @@ private struct BigRoadView: View { } } - // Add remaining column if !currentCol.isEmpty { cols.append(currentCol) } @@ -346,11 +963,10 @@ private struct BigRoadView: View { BigRoadCell(result: result) } - // If column has more than maxRows, show overflow count if column.count > maxRows { Text("+\(column.count - maxRows)") .font(.system(size: Design.BaseFontSize.xxSmall)) - .foregroundStyle(.white.opacity(Design.Opacity.secondary)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) } Spacer(minLength: 0) @@ -361,17 +977,16 @@ private struct BigRoadView: View { } .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.black.opacity(Design.Opacity.overlay)) + .fill(Color.black.opacity(Design.Opacity.light)) ) .scrollIndicators(.hidden) } } -/// A single cell in the Big Road display. private struct BigRoadCell: View { let result: RoundResult - private let cellSize: CGFloat = Design.Size.bigRoadCellSize + private let cellSize: CGFloat = Size.bigRoadCellSize private var color: Color { switch result.result { @@ -383,12 +998,10 @@ private struct BigRoadCell: View { var body: some View { ZStack { - // Main circle Circle() .stroke(color, lineWidth: Design.LineWidth.medium) .frame(width: cellSize, height: cellSize) - // Pair indicator (small dot at bottom) if result.hasPair { Circle() .fill(Color.yellow) @@ -396,7 +1009,6 @@ private struct BigRoadCell: View { .offset(y: cellSize * 0.3) } - // Natural indicator (small dot at top) if result.isNatural { Circle() .fill(Color.white) @@ -404,7 +1016,6 @@ private struct BigRoadCell: View { .offset(y: -cellSize * 0.3) } - // Tie diagonal line if it's a tie if result.result == .tie { Rectangle() .fill(color) @@ -415,27 +1026,21 @@ private struct BigRoadCell: View { } } -// MARK: - Design Constants Extensions +// MARK: - Local Size Constants -extension Design.Size { - static let bigRoadHeight: CGFloat = 200 +private enum Size { + static let outcomeCircleSize: CGFloat = 48 + static let outcomeCircleInner: CGFloat = 24 + static let statIconWidth: CGFloat = 32 + static let chipIconSize: CGFloat = 28 + static let bigRoadHeight: CGFloat = 180 static let bigRoadCellSize: CGFloat = 24 - static let statBoxMinWidth: CGFloat = 80 static let winIndicatorSize: CGFloat = 24 + static let roadDotSize: CGFloat = 28 } // MARK: - Preview #Preview { - StatisticsSheetView(results: [ - RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true), - RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), - RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), - RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8, bankerPair: true), - RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 6), - RoundResult(result: .tie, playerValue: 5, bankerValue: 5), - RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), - RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true) - ]) + StatisticsSheetView(state: GameState(settings: GameSettings())) } -