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.
@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<BaccaratGameData>!
let persistence: CloudSyncManager<BaccaratGameData>
// 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<BaccaratGameData>()
// Sync sound settings with SoundManager
syncSoundSettings()
// Initialize persistence with cloud data callback
self.persistence = CloudSyncManager<BaccaratGameData>()
// 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
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
// Update balance
data.balance = balance
// Update statistics
data.totalWinnings += netWinnings
if netWinnings > data.biggestWin {
data.biggestWin = netWinnings
// 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
}
if netWinnings < 0 && abs(netWinnings) > data.biggestLoss {
data.biggestLoss = abs(netWinnings)
}
// Update round history from current session
data.roundHistory = roundHistory.enumerated().map { index, round in
// Try to get existing saved result for net winnings
if index < data.roundHistory.count {
return data.roundHistory[index]
}
// New round - calculate net winnings from betResults if available
let netForRound = betResults.reduce(0) { $0 + $1.payout }
return SavedRoundResult(from: round, netWinnings: index == roundHistory.count - 1 ? netWinnings : netForRound)
}
// 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 = []
currentPhase = .betting
}
// Save the reset state (keeps lifetime stats, resets balance and session history)
saveGame()
/// 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()
}
balance = settings.startingBalance
currentSession = nil
sessionHistory = []
sessionRoundHistories = [:]
roundHistory = []
startNewSession()
newRoundInternal()
/// Returns lifetime statistics from saved data.
var lifetimeStats: BaccaratGameData {
persistence.data
// Play new game sound
sound.playNewRound()
}
/// 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" : {
"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",

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff