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

This commit is contained in:
Matt Bruce 2025-12-29 13:48:03 -06:00
parent 10d3d02cb0
commit f1b834c47e
7 changed files with 1398 additions and 355 deletions

View File

@ -47,7 +47,29 @@ struct BetResult: Identifiable {
/// Main observable game state class managing all game logic and UI state. /// Main observable game state class managing all game logic and UI state.
@Observable @Observable
@MainActor @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 // MARK: - Settings
let settings: GameSettings let settings: GameSettings
@ -58,13 +80,13 @@ final class GameState {
private let sound = SoundManager.shared private let sound = SoundManager.shared
// MARK: - Persistence // MARK: - Persistence
private var persistence: CloudSyncManager<BaccaratGameData>! let persistence: CloudSyncManager<BaccaratGameData>
// MARK: - Game Engine // MARK: - Game Engine
private(set) var engine: BaccaratEngine private(set) var engine: BaccaratEngine
// MARK: - Player State // MARK: - Player State
var balance: Int = 10_000 var balance: Int = 1_000
var currentBets: [Bet] = [] var currentBets: [Bet] = []
// MARK: - Round State // MARK: - Round State
@ -135,6 +157,11 @@ final class GameState {
Array(roundHistory.suffix(20)) 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 // MARK: - Hint System
/// The current streak type (player wins, banker wins, or alternating/none). /// 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.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
self.balance = settings.startingBalance self.balance = settings.startingBalance
self.onboarding = OnboardingState(gameIdentifier: "baccarat") self.onboarding = OnboardingState(gameIdentifier: "baccarat")
self.persistence = CloudSyncManager<BaccaratGameData>()
// Sync sound settings with SoundManager // Sync sound settings with SoundManager
syncSoundSettings() syncSoundSettings()
// Initialize persistence with cloud data callback // Set up iCloud callback
self.persistence = CloudSyncManager<BaccaratGameData>()
persistence.onCloudDataReceived = { [weak self] cloudData in persistence.onCloudDataReceived = { [weak self] cloudData in
self?.handleCloudDataReceived(cloudData) self?.handleCloudDataReceived(cloudData)
} }
// Load saved game data // Load saved game data
loadSavedGame() loadSavedGame()
// Ensure we have an active session
ensureActiveSession()
} }
/// Handles data received from iCloud (e.g., after fresh install or from another device). /// Handles data received from iCloud (e.g., after fresh install or from another device).
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) { private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
// Only update if cloud has more progress than current state // 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 return
} }
// Restore balance // Restore balance and sessions
self.balance = cloudData.balance self.balance = cloudData.balance
self.currentSession = cloudData.currentSession
self.sessionHistory = cloudData.sessionHistory
self.sessionRoundHistories = cloudData.sessionRoundHistories
// Restore round history // Restore round history for road map display
self.roundHistory = cloudData.roundHistory.compactMap { saved in self.roundHistory = cloudData.currentSessionRoundHistory.compactMap { saved in
guard let result = GameResult(persistenceKey: saved.result) else { return nil } saved.toRoundResult()
return RoundResult(
result: result,
playerValue: saved.playerValue,
bankerValue: saved.bankerValue,
playerPair: saved.playerPair,
bankerPair: saved.bankerPair
)
} }
} }
@ -407,55 +432,59 @@ final class GameState {
/// Loads saved game data from iCloud/local storage. /// Loads saved game data from iCloud/local storage.
private func loadSavedGame() { 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 // Restore round history for road map display
guard savedData.roundsPlayed > 0 else { return } self.roundHistory = data.currentSessionRoundHistory.compactMap { saved in
saved.toRoundResult()
}
// Restore balance CasinoDesign.debugLog("📂 Loaded game data:")
self.balance = savedData.balance CasinoDesign.debugLog(" - balance: \(data.balance)")
CasinoDesign.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
// Restore round history (convert saved to RoundResult) CasinoDesign.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
self.roundHistory = savedData.roundHistory.compactMap { saved in CasinoDesign.debugLog(" - roundHistory count: \(roundHistory.count)")
guard let result = GameResult(persistenceKey: saved.result) else { return nil } CasinoDesign.debugLog(" - sessionRoundHistories count: \(sessionRoundHistories.count)")
return RoundResult( if let session = data.currentSession {
result: result, CasinoDesign.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s")
playerValue: saved.playerValue,
bankerValue: saved.bankerValue,
playerPair: saved.playerPair,
bankerPair: saved.bankerPair
)
} }
} }
/// Saves current game state to iCloud/local storage. /// Saves current game state to iCloud/local storage.
private func saveGame(netWinnings: Int = 0) { func saveGameData() {
var data = persistence.data // 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
// Update balance // Always keep the current session's round history in the archive
data.balance = balance // This ensures it's available when the session ends and becomes historical
if !roundHistory.isEmpty {
// Update statistics let savedHistory = roundHistory.map { SavedRoundResult(from: $0) }
data.totalWinnings += netWinnings sessionRoundHistories[session.id.uuidString] = savedHistory
if netWinnings > data.biggestWin {
data.biggestWin = netWinnings
} }
if netWinnings < 0 && abs(netWinnings) > data.biggestLoss {
data.biggestLoss = abs(netWinnings)
} }
// Update round history from current session // Convert round history for persistence
data.roundHistory = roundHistory.enumerated().map { index, round in let savedRoundHistory = roundHistory.map { SavedRoundResult(from: $0) }
// 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)
}
let data = BaccaratGameData(
lastModified: Date(),
balance: balance,
currentSession: currentSession,
sessionHistory: sessionHistory,
currentSessionRoundHistory: savedRoundHistory,
sessionRoundHistories: sessionRoundHistories
)
persistence.save(data) persistence.save(data)
CasinoDesign.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0), road history: \(roundHistory.count)")
} }
/// Whether iCloud sync is available. /// Whether iCloud sync is available.
@ -748,10 +777,17 @@ final class GameState {
playerHadPair = engine.playerHasPair playerHadPair = engine.playerHasPair
bankerHadPair = engine.bankerHasPair bankerHadPair = engine.bankerHasPair
// Check for naturals
let isNatural = engine.playerHand.isNatural || engine.bankerHand.isNatural
// Calculate and apply payouts, track individual results // Calculate and apply payouts, track individual results
var totalWinnings = 0 var totalWinnings = 0
var results: [BetResult] = [] var results: [BetResult] = []
// Track dragon bonus wins
var dragonPlayerWon = false
var dragonBankerWon = false
for bet in currentBets { for bet in currentBets {
let payout = engine.calculatePayout(bet: bet, result: result) let payout = engine.calculatePayout(bet: bet, result: result)
totalWinnings += payout totalWinnings += payout
@ -759,6 +795,14 @@ final class GameState {
// Track individual bet result // Track individual bet result
results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout)) 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 // Return original bet if not a loss
if payout >= 0 { if payout >= 0 {
balance += bet.amount balance += bet.amount
@ -773,6 +817,40 @@ final class GameState {
betResults = results betResults = results
lastWinnings = totalWinnings 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) // 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 // 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 }) 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( roundHistory.append(RoundResult(
result: result, result: result,
playerValue: playerHandValue, playerValue: playerHandValue,
@ -810,8 +888,8 @@ final class GameState {
bankerPair: bankerHadPair bankerPair: bankerHadPair
)) ))
// Save game state to iCloud/local // Save game data to iCloud
saveGame(netWinnings: totalWinnings) saveGameData()
// Show result banner - stays until user taps New Round // Show result banner - stays until user taps New Round
showResultBanner = true showResultBanner = true
@ -851,41 +929,98 @@ final class GameState {
} }
} }
/// Resets the game to initial state with current settings. // MARK: - Session History Management
func resetGame() {
/// 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) engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
balance = settings.startingBalance newRoundInternal()
}
/// Internal new round reset (without sound).
private func newRoundInternal() {
currentBets = [] currentBets = []
currentPhase = .betting
lastResult = nil
lastWinnings = 0
visiblePlayerCards = [] visiblePlayerCards = []
visibleBankerCards = [] visibleBankerCards = []
playerCardsFaceUp = [] playerCardsFaceUp = []
bankerCardsFaceUp = [] bankerCardsFaceUp = []
roundHistory = [] lastResult = nil
isAnimating = false lastWinnings = 0
showResultBanner = false
playerHadPair = false playerHadPair = false
bankerHadPair = false bankerHadPair = false
betResults = [] betResults = []
currentPhase = .betting
}
// Save the reset state (keeps lifetime stats, resets balance and session history) /// Aggregated Baccarat-specific stats from all sessions.
saveGame() 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 // Play new game sound
sound.playNewRound() sound.playNewRound()
} }
/// Completely clears all saved data and starts fresh (including lifetime stats). /// Completely clears all saved data and starts fresh.
func clearAllData() { func clearAllData() {
persistence.reset() persistence.reset()
resetGame() balance = settings.startingBalance
} currentSession = nil
sessionHistory = []
sessionRoundHistories = [:]
roundHistory = []
startNewSession()
newRoundInternal()
/// Returns lifetime statistics from saved data. // Play new game sound
var lifetimeStats: BaccaratGameData { sound.playNewRound()
persistence.data
} }
/// Applies new settings (call after settings change). /// Applies new settings (call after settings change).

View File

@ -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<BaccaratStats> {
/// 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<BaccaratStats>

View File

@ -354,6 +354,9 @@
} }
} }
} }
},
"$%lld" : {
}, },
"2-9: Face value" : { "2-9: Face value" : {
"comment" : "Description of the card values for cards with values from 2 to 9.", "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" : { "Alternative: Use an online tool" : {
"comment" : "A section header that suggests using an online tool to generate app icon sizes.", "comment" : "A section header that suggests using an online tool to generate app icon sizes.",
"localizations" : { "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!" : { "Avoid the Tie bet — 14.4% house edge!" : {
"comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.", "comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.",
"localizations" : { "localizations" : {
@ -701,6 +712,7 @@
} }
}, },
"B Pair" : { "B Pair" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -747,6 +759,9 @@
} }
} }
} }
},
"BALANCE" : {
}, },
"Banker" : { "Banker" : {
"localizations" : { "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.", "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 "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" : { "Bet on Player, Banker, or Tie" : {
}, },
@ -1040,6 +1067,14 @@
}, },
"Betting tips and trend analysis" : { "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" : { "Blackjack" : {
"comment" : "The name of a blackjack game.", "comment" : "The name of a blackjack game.",
@ -1227,6 +1262,10 @@
}, },
"Change table limits and display options" : { "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" : { "Chips, cards, and result sounds" : {
"comment" : "Subtitle describing sound effects toggle.", "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" : { "Customize Settings" : {
@ -1439,6 +1485,16 @@
} }
} }
} }
},
"Delete" : {
"comment" : "A button to delete a session.",
"isCommentAutoGenerated" : true
},
"Delete Session" : {
},
"Delete Session?" : {
}, },
"DISPLAY" : { "DISPLAY" : {
"comment" : "Section header for display settings.", "comment" : "Section header for display settings.",
@ -1580,6 +1636,21 @@
"Dragon Bonus: high risk, high reward" : { "Dragon Bonus: high risk, high reward" : {
"comment" : "Warning text for dragon bonus bets, advising players to be cautious.", "comment" : "Warning text for dragon bonus bets, advising players to be cautious.",
"isCommentAutoGenerated" : true "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!)" : { "Example: 5♥ + 5♣ = Pair (wins!)" : {
"comment" : "Example of a pair bet winning.", "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" : { "Generate & Save Icons" : {
"comment" : "A button label that triggers the generation of app icons.", "comment" : "A button label that triggers the generation of app icons.",
"localizations" : { "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" : { "Green Circle (T): Tie between Player and Banker" : {
"comment" : "Explains the green circle icon in the history.", "comment" : "Explains the green circle icon in the history.",
"localizations" : { "localizations" : {
@ -1790,6 +1869,10 @@
} }
} }
}, },
"Hands" : {
"comment" : "Label for the number of hands played in a summary stat column.",
"isCommentAutoGenerated" : true
},
"handValueFormat" : { "handValueFormat" : {
"comment" : "Format for displaying hand value. The argument is the numeric value of the hand.", "comment" : "Format for displaying hand value. The argument is the numeric value of the hand.",
"localizations" : { "localizations" : {
@ -1836,6 +1919,10 @@
} }
} }
}, },
"History" : {
"comment" : "Title of the statistics tab that shows the user's session history.",
"isCommentAutoGenerated" : true
},
"HISTORY" : { "HISTORY" : {
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
"localizations" : { "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" : { "Main Bets" : {
"comment" : "Title of a rule page in the \"Rules\" help view, describing the main bets available in baccarat.", "comment" : "Title of a rule page in the \"Rules\" help view, describing the main bets available in baccarat.",
"localizations" : { "localizations" : {
@ -2381,6 +2476,13 @@
} }
} }
} }
},
"Net" : {
"comment" : "Label for the net winnings in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Net result" : {
}, },
"Never" : { "Never" : {
"localizations" : { "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" : { "No cards" : {
"comment" : "A description of the player's hand when they have no cards.", "comment" : "A description of the player's hand when they have no cards.",
"localizations" : { "localizations" : {
@ -2495,6 +2601,10 @@
} }
} }
}, },
"No Session History" : {
"comment" : "A description displayed when a user has no session history.",
"isCommentAutoGenerated" : true
},
"Objective" : { "Objective" : {
"comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.", "comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.",
"localizations" : { "localizations" : {
@ -2634,6 +2744,7 @@
} }
}, },
"P Pair" : { "P Pair" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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." : { "Pairs occur roughly once every 15 hands." : {
"comment" : "Explanation of how often pairs occur in a typical game of baccarat.", "comment" : "Explanation of how often pairs occur in a typical game of baccarat.",
"localizations" : { "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.", "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 "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" : { "Player with 0-5: Draws a third card" : {
"comment" : "Description of the action for the Player when their third card is 0-5.", "comment" : "Description of the action for the Player when their third card is 0-5.",
"localizations" : { "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" : { "Red Circle (B): Banker won the hand" : {
"comment" : "Explains the red circle icon in the history.", "comment" : "Explains the red circle icon in the history.",
"localizations" : { "localizations" : {
@ -3129,6 +3256,7 @@
} }
}, },
"Rounds" : { "Rounds" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Rounds Played" : {
"comment" : "A label displayed next to the number of rounds played in the game over screen.", "comment" : "A label displayed next to the number of rounds played in the game over screen.",
"extractionState" : "stale", "extractionState" : "stale",
@ -3176,6 +3308,14 @@
}, },
"Select a chip and tap a bet zone" : { "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." : { "Set a budget and stick to it." : {
"comment" : "Tip for players to set a budget and stick to it when playing baccarat.", "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" : { "Start with $1,000 and play risk-free" : {
},
"Starting balance" : {
}, },
"STARTING BALANCE" : { "STARTING BALANCE" : {
"comment" : "Section header for starting balance settings.", "comment" : "Section header for starting balance settings.",
@ -3724,6 +3871,9 @@
} }
} }
} }
},
"This will permanently remove this session from your history." : {
}, },
"Tie" : { "Tie" : {
"localizations" : { "localizations" : {
@ -3797,6 +3947,14 @@
"comment" : "Warning message for a tie bet, explaining the high house edge.", "comment" : "Warning message for a tie bet, explaining the high house edge.",
"isCommentAutoGenerated" : true "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" : { "TOTAL" : {
"comment" : "A label displayed next to the total winnings in the result banner.", "comment" : "A label displayed next to the total winnings in the result banner.",
"extractionState" : "stale", "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" : { "Total Winnings" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Winner" : {
"comment" : "A description of the player's hand, including its value and whether they won.", "comment" : "A description of the player's hand, including its value and whether they won.",
"localizations" : { "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" : { "Yellow Dot (bottom-left): A pair occurred in that hand" : {
"comment" : "Explains the yellow dot marker in the history.", "comment" : "Explains the yellow dot marker in the history.",
"localizations" : { "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!" : { "You've run out of chips!" : {
"comment" : "A message displayed when a player runs out of money in the game over screen.", "comment" : "A message displayed when a player runs out of money in the game over screen.",
"extractionState" : "stale", "extractionState" : "stale",

View File

@ -9,75 +9,54 @@ import Foundation
import CasinoKit import CasinoKit
/// Persisted data for Baccarat game. /// Persisted data for Baccarat game.
public struct BaccaratGameData: PersistableGameData { public struct BaccaratGameData: PersistableGameData, SessionPersistable {
// MARK: - PersistableGameData // MARK: - PersistableGameData
public static let gameIdentifier = "baccarat" public static let gameIdentifier = "baccarat"
public var roundsPlayed: Int { 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 { public static var empty: BaccaratGameData {
BaccaratGameData( BaccaratGameData(
balance: 10_000, lastModified: Date(),
roundHistory: [], balance: 1_000,
totalWinnings: 0, currentSession: nil,
biggestWin: 0, sessionHistory: [],
biggestLoss: 0, currentSessionRoundHistory: [],
lastModified: Date() sessionRoundHistories: [:]
) )
} }
// MARK: - Game Data // 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). /// Last time data was modified (required by PersistableGameData).
public var lastModified: Date public var lastModified: Date
// MARK: - Computed Stats /// Current chip balance.
public var balance: Int
/// Number of Player wins. /// The currently active session (nil if no session started).
public var playerWins: Int { public var currentSession: BaccaratSession?
roundHistory.filter { $0.result == "player" }.count
}
/// Number of Banker wins. /// History of completed sessions.
public var bankerWins: Int { public var sessionHistory: [BaccaratSession]
roundHistory.filter { $0.result == "banker" }.count
}
/// Number of Tie games. /// Round history for the current session (for road map display).
public var tieGames: Int { /// This is cleared when a new session starts.
roundHistory.filter { $0.result == "tie" }.count public var currentSessionRoundHistory: [SavedRoundResult]
}
/// Win rate percentage. /// Round histories for completed sessions (keyed by session ID string).
public var winRate: Double { /// Used to display road maps when viewing historical sessions.
guard roundsPlayed > 0 else { return 0 } public var sessionRoundHistories: [String: [SavedRoundResult]]
let wins = roundHistory.filter { $0.netWinnings > 0 }.count
return Double(wins) / Double(roundsPlayed) * 100
}
} }
/// Codable round result for persistence. /// Codable round result for persistence (for road map display).
public struct SavedRoundResult: Codable, Identifiable, Sendable { public struct SavedRoundResult: Codable, Identifiable, Sendable {
public let id: UUID public let id: UUID
public let result: String // "player", "banker", "tie" public let result: String // "player", "banker", "tie"
@ -87,7 +66,6 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
public let bankerPair: Bool public let bankerPair: Bool
public let isNatural: Bool public let isNatural: Bool
public let timestamp: Date public let timestamp: Date
public let netWinnings: Int
public init( public init(
id: UUID = UUID(), id: UUID = UUID(),
@ -97,8 +75,7 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
playerPair: Bool, playerPair: Bool,
bankerPair: Bool, bankerPair: Bool,
isNatural: Bool, isNatural: Bool,
timestamp: Date = Date(), timestamp: Date = Date()
netWinnings: Int
) { ) {
self.id = id self.id = id
self.result = result self.result = result
@ -108,15 +85,14 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
self.bankerPair = bankerPair self.bankerPair = bankerPair
self.isNatural = isNatural self.isNatural = isNatural
self.timestamp = timestamp self.timestamp = timestamp
self.netWinnings = netWinnings
} }
} }
// MARK: - Conversion from RoundResult // MARK: - Conversion from RoundResult
extension SavedRoundResult { extension SavedRoundResult {
/// Creates a SavedRoundResult from a RoundResult and net winnings. /// Creates a SavedRoundResult from a RoundResult.
init(from roundResult: RoundResult, netWinnings: Int) { init(from roundResult: RoundResult) {
self.id = roundResult.id self.id = roundResult.id
self.result = roundResult.result.persistenceKey self.result = roundResult.result.persistenceKey
self.playerValue = roundResult.playerValue self.playerValue = roundResult.playerValue
@ -125,7 +101,18 @@ extension SavedRoundResult {
self.bankerPair = roundResult.bankerPair self.bankerPair = roundResult.bankerPair
self.isNatural = roundResult.isNatural self.isNatural = roundResult.isNatural
self.timestamp = roundResult.timestamp 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 {
} }
} }
} }

View File

@ -131,7 +131,7 @@ struct GameTableView: View {
RulesHelpView() RulesHelpView()
} }
.sheet(isPresented: $showStats) { .sheet(isPresented: $showStats) {
StatisticsSheetView(results: state.roundHistory) StatisticsSheetView(state: state)
} }
.sheet(isPresented: $showWelcome) { .sheet(isPresented: $showWelcome) {
WelcomeSheet( WelcomeSheet(

View File

@ -228,7 +228,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("\(gameState.lifetimeStats.roundsPlayed)") Text("\(gameState.aggregatedStats.totalRoundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
} }
@ -240,7 +240,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
let winnings = gameState.lifetimeStats.totalWinnings let winnings = gameState.aggregatedStats.totalWinnings
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))") Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(winnings >= 0 ? .green : .red) .foregroundStyle(winnings >= 0 ? .green : .red)

File diff suppressed because it is too large Load Diff