CasinoGames/Baccarat/Baccarat/Engine/GameState.swift

1115 lines
38 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// GameState.swift
// Baccarat
//
// Observable game state managing the flow of a baccarat game.
//
import Foundation
import SwiftUI
import CasinoKit
/// The current phase of a baccarat round.
enum GamePhase: Equatable {
case betting
case dealingInitial
case playerThirdCard
case bankerThirdCard
case showingResult
case roundComplete
}
/// Result of an individual bet after a round.
struct BetResult: Identifiable {
let id = UUID()
let type: BetType
let amount: Int
let payout: Int // Net winnings (positive) or loss (negative)
var isWin: Bool { payout > 0 }
var isLoss: Bool { payout < 0 }
var isPush: Bool { payout == 0 }
/// Display name for the bet type
var displayName: String {
switch type {
case .player: return "Player"
case .banker: return "Banker"
case .tie: return "Tie"
case .playerPair: return "P Pair"
case .bankerPair: return "B Pair"
case .dragonBonusPlayer: return "Dragon P"
case .dragonBonusBanker: return "Dragon B"
}
}
}
/// Main observable game state class managing all game logic and UI state.
/// Conforms to CasinoGameState for shared game behaviors.
@Observable
@MainActor
final class GameState: CasinoGameState {
// MARK: - CasinoGameState Conformance
typealias Stats = BaccaratStats
typealias GameSettingsType = GameSettings
/// 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
// MARK: - Onboarding
let onboarding: OnboardingState
// MARK: - Sound
private let sound = SoundManager.shared
// MARK: - Persistence
let persistence: CloudSyncManager<BaccaratGameData>
// MARK: - Game Engine
private(set) var engine: BaccaratEngine
// MARK: - Player State
var balance: Int = 1_000
var currentBets: [Bet] = []
// MARK: - Round State
var currentPhase: GamePhase = .betting
var lastResult: GameResult?
var lastWinnings: Int = 0
// MARK: - Bet Results
var playerHadPair: Bool = false
var bankerHadPair: Bool = false
var betResults: [BetResult] = []
// MARK: - Card Display State (for animations)
var visiblePlayerCards: [Card] = []
var visibleBankerCards: [Card] = []
var playerCardsFaceUp: [Bool] = []
var bankerCardsFaceUp: [Bool] = []
// MARK: - History
var roundHistory: [RoundResult] = []
// MARK: - Animation Flags
var isAnimating: Bool = false
var showResultBanner: Bool = false
// MARK: - Reveal State
/// Whether the game is waiting for a user to reveal a card (Tap or Squeeze style).
var isWaitingForReveal: Bool = false
/// The index of the card currently being revealed (0-3 for initial, 4+ for others).
var currentRevealIndex: Int = 0
/// The progress of a squeeze reveal (0.0 to 1.0).
var revealProgress: Double = 0.0
/// Continuation to resume dealing after a manual reveal.
private var revealContinuation: CheckedContinuation<Void, Never>?
// MARK: - Computed Properties
var totalBetAmount: Int {
currentBets.reduce(0) { $0 + $1.amount }
}
var canPlaceBet: Bool {
currentPhase == .betting && !isAnimating
}
var canDeal: Bool {
currentPhase == .betting && hasMainBet && mainBetMeetsMinimum && !isAnimating
}
var playerHandValue: Int {
// Only calculate value from visible face-up cards
guard visiblePlayerCards.count == playerCardsFaceUp.count else { return 0 }
guard playerCardsFaceUp.allSatisfy({ $0 }) else {
// If not all cards are face up, calculate value from only the face-up cards
let faceUpCards = zip(visiblePlayerCards, playerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}
return engine.playerHand.value
}
var bankerHandValue: Int {
// Only calculate value from visible face-up cards
guard visibleBankerCards.count == bankerCardsFaceUp.count else { return 0 }
guard bankerCardsFaceUp.allSatisfy({ $0 }) else {
// If not all cards are face up, calculate value from only the face-up cards
let faceUpCards = zip(visibleBankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}
return engine.bankerHand.value
}
// Recent results for the road display (last 20)
var recentResults: [RoundResult] {
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).
var currentStreakInfo: (type: GameResult?, count: Int) {
guard !roundHistory.isEmpty else { return (nil, 0) }
var streakType: GameResult?
var count = 0
// Count backwards from most recent, ignoring ties for streak purposes
for result in roundHistory.reversed() {
// Skip ties when counting streaks
if result.result == .tie {
continue
}
if streakType == nil {
streakType = result.result
count = 1
} else if result.result == streakType {
count += 1
} else {
break
}
}
return (streakType, count)
}
/// Distribution of results in the current session.
var resultDistribution: (player: Int, banker: Int, tie: Int) {
let player = roundHistory.filter { $0.result == .playerWins }.count
let banker = roundHistory.filter { $0.result == .bankerWins }.count
let tie = roundHistory.filter { $0.result == .tie }.count
return (player, banker, tie)
}
/// Whether the game is "choppy" (alternating frequently between Player and Banker).
var isChoppy: Bool {
guard roundHistory.count >= 6 else { return false }
// Check last 6 non-tie results for alternating pattern
let recentNonTie = roundHistory.suffix(10).filter { $0.result != .tie }.suffix(6)
guard recentNonTie.count >= 6 else { return false }
var alternations = 0
var previous: GameResult?
for result in recentNonTie {
if let prev = previous, prev != result.result {
alternations += 1
}
previous = result.result
}
// If 4+ alternations in 6 hands, it's choppy
return alternations >= 4
}
/// Hint information including text and style for the shared BettingHintView.
struct HintInfo {
let text: String
let secondaryText: String?
let isStreak: Bool
let isChoppy: Bool
let isBankerHot: Bool
let isPlayerHot: Bool
/// Determines the style for CasinoKit.BettingHintView.
var style: BettingHintStyle {
if isStreak { return .streak }
if isChoppy { return .pattern }
if isBankerHot { return .custom(.red, "chart.line.uptrend.xyaxis") }
if isPlayerHot { return .custom(.blue, "chart.line.uptrend.xyaxis") }
return .neutral
}
}
/// Current betting hint for beginners.
/// Returns nil if hints are disabled or no actionable hint is available.
var currentHint: String? {
currentHintInfo?.text
}
/// Full hint information including style.
var currentHintInfo: HintInfo? {
guard settings.showHints else { return nil }
guard currentPhase == .betting else { return nil }
// If no history, give the fundamental advice
if roundHistory.isEmpty {
return HintInfo(
text: String(localized: "Banker has the lowest house edge (1.06%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
let streak = currentStreakInfo
let dist = resultDistribution
let total = dist.player + dist.banker + dist.tie
// Calculate percentages if we have enough data
if total >= 5 {
let bankerPct = Double(dist.banker) / Double(total) * 100
let playerPct = Double(dist.player) / Double(total) * 100
let trendText = "P: \(Int(playerPct))% | B: \(Int(bankerPct))%"
// Strong streak (4+): suggest following or note it
if let streakType = streak.type, streak.count >= 4 {
let streakName = streakType == .bankerWins ?
String(localized: "Banker") : String(localized: "Player")
return HintInfo(
text: String(localized: "\(streakName) streak: \(streak.count) in a row"),
secondaryText: trendText,
isStreak: true,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
// Choppy game: note the pattern
if isChoppy {
return HintInfo(
text: String(localized: "Choppy shoe - results alternating"),
secondaryText: trendText,
isStreak: false,
isChoppy: true,
isBankerHot: false,
isPlayerHot: false
)
}
// Significant imbalance (15%+ difference)
if bankerPct > playerPct + 15 {
return HintInfo(
text: String(localized: "Banker running hot (\(Int(bankerPct))%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: true,
isPlayerHot: false
)
} else if playerPct > bankerPct + 15 {
return HintInfo(
text: String(localized: "Player running hot (\(Int(playerPct))%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: true
)
}
}
// Default hint: remind about odds
return HintInfo(
text: String(localized: "Banker bet has lowest house edge"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
/// Short trend summary for the top bar or compact display.
var trendSummary: String? {
guard settings.showHints else { return nil }
guard !roundHistory.isEmpty else { return nil }
let streak = currentStreakInfo
if let streakType = streak.type, streak.count >= 2 {
let letter = streakType == .bankerWins ? "B" : "P"
return "\(letter)×\(streak.count)"
}
return nil
}
/// Warning message for high house edge bets.
func warningForBet(_ type: BetType) -> String? {
guard settings.showHints else { return nil }
switch type {
case .tie:
return String(localized: "Tie has 14% house edge")
case .playerPair, .bankerPair:
return String(localized: "Pair bets have ~10% house edge")
case .dragonBonusPlayer, .dragonBonusBanker:
return String(localized: "Dragon Bonus: high risk, high reward")
default:
return nil
}
}
// MARK: - Animation Timing (based on settings)
private var dealDelay: Duration {
.milliseconds(Int(400 * settings.dealingSpeed))
}
private var flipDelay: Duration {
.milliseconds(Int(300 * settings.dealingSpeed))
}
private var shortDelay: Duration {
.milliseconds(Int(200 * settings.dealingSpeed))
}
private var resultDelay: Duration {
.milliseconds(Int(800 * settings.dealingSpeed))
}
// MARK: - Initialization
/// Convenience initializer that constructs default settings on the main actor.
convenience init() {
self.init(settings: GameSettings())
}
init(settings: GameSettings) {
self.settings = settings
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
self.balance = settings.startingBalance
self.onboarding = OnboardingState(gameIdentifier: "baccarat")
self.onboarding.registerHintKeys("bettingZone", "dealButton", "firstResult")
self.persistence = CloudSyncManager<BaccaratGameData>()
// Sync sound settings with SoundManager
syncSoundSettings()
// 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 > (currentSession?.roundsPlayed ?? 0) + sessionHistory.reduce(0, { $0 + $1.roundsPlayed }) else {
return
}
// Restore balance and sessions
self.balance = cloudData.balance
self.currentSession = cloudData.currentSession
self.sessionHistory = cloudData.sessionHistory
self.sessionRoundHistories = cloudData.sessionRoundHistories
// Restore round history for road map display
self.roundHistory = cloudData.currentSessionRoundHistory.compactMap { saved in
saved.toRoundResult()
}
}
// MARK: - Persistence
/// Loads saved game data from iCloud/local storage.
private func loadSavedGame() {
let data = persistence.load()
self.balance = data.balance
self.currentSession = data.currentSession
self.sessionHistory = data.sessionHistory
self.sessionRoundHistories = data.sessionRoundHistories
// Restore round history for road map display
self.roundHistory = data.currentSessionRoundHistory.compactMap { saved in
saved.toRoundResult()
}
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.
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
}
}
// 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.
var iCloudAvailable: Bool {
persistence.iCloudAvailable
}
/// Whether iCloud sync is enabled.
var iCloudEnabled: Bool {
get { persistence.iCloudEnabled }
set { persistence.iCloudEnabled = newValue }
}
/// Last sync date.
var lastSyncDate: Date? {
persistence.lastSyncDate
}
/// Forces a sync with iCloud.
func syncWithCloud() {
persistence.sync()
}
/// Syncs sound settings from GameSettings to SoundManager.
private func syncSoundSettings() {
sound.soundEnabled = settings.soundEnabled
sound.hapticsEnabled = settings.hapticsEnabled
sound.volume = settings.soundVolume
}
// MARK: - Computed Properties for Bets
/// Returns the current main bet (Player or Banker), if any.
var mainBet: Bet? {
currentBets.first(where: { $0.type == .player || $0.type == .banker })
}
/// Returns the current tie bet, if any.
var tieBet: Bet? {
currentBets.first(where: { $0.type == .tie })
}
/// Returns bets for a specific type.
func bet(for type: BetType) -> Bet? {
currentBets.first(where: { $0.type == type })
}
/// Whether the player has placed a main bet (required to deal).
var hasMainBet: Bool {
mainBet != nil
}
/// Whether the player has any side bets.
var hasSideBets: Bool {
currentBets.contains(where: { $0.type.isSideBet })
}
/// Minimum bet for the table.
var minBet: Int {
settings.minBet
}
/// Maximum bet for the table.
var maxBet: Int {
settings.maxBet
}
/// Returns the current bet amount for a specific bet type.
func betAmount(for type: BetType) -> Int {
currentBets.first(where: { $0.type == type })?.amount ?? 0
}
/// Whether the main bet meets the minimum requirement.
var mainBetMeetsMinimum: Bool {
guard let bet = mainBet else { return false }
return bet.amount >= minBet
}
/// Whether the main bet exists but is below the minimum required.
var isMainBetBelowMinimum: Bool {
guard let bet = mainBet else { return false }
return bet.amount < minBet
}
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
/// Used to determine card layout ordering (betted hand appears on bottom).
var bettedOnPlayer: Bool? {
guard let bet = mainBet else { return nil }
return bet.type == .player
}
/// Amount needed to reach the minimum bet.
var amountNeededForMinimum: Int {
guard let bet = mainBet else { return minBet }
return max(0, minBet - bet.amount)
}
/// Whether a bet type can accept more chips (hasn't hit max).
func canAddToBet(type: BetType, amount: Int) -> Bool {
let currentAmount = betAmount(for: type)
return currentAmount + amount <= maxBet
}
/// The minimum bet level across all bet types that can accept more chips.
/// Used by chip selector to determine if chips should be enabled.
/// Returns the smallest bet so chips stay enabled if ANY bet type can accept more.
var minBetForChipSelector: Int {
// All bet types are always available in Baccarat
let allBetTypes: [BetType] = [
.player, .banker, .tie,
.playerPair, .bankerPair,
.dragonBonusPlayer, .dragonBonusBanker
]
// Return the minimum bet amount across all bet types
// so chips stay enabled if any bet type can accept more
return allBetTypes.map { betAmount(for: $0) }.min() ?? 0
}
func revealCurrentCard() {
guard isWaitingForReveal else { return }
isWaitingForReveal = false
revealProgress = 1.0
let continuation = revealContinuation
revealContinuation = nil
continuation?.resume()
}
/// Updates the progress of a squeeze reveal.
func updateRevealProgress(_ progress: Double) {
revealProgress = progress
if progress >= 1.0 {
revealCurrentCard()
}
}
private func waitForReveal(index: Int) async {
guard settings.showAnimations else { return }
if settings.revealStyle == .tap || settings.revealStyle == .squeeze {
currentRevealIndex = index
revealProgress = 0.0
isWaitingForReveal = true
await withCheckedContinuation { continuation in
revealContinuation = continuation
}
} else {
// Auto Styles
try? await Task.sleep(for: flipDelay)
}
}
// MARK: - Betting Actions
/// Places a bet of the specified amount on the given bet type.
/// Player and Banker are mutually exclusive. Side bets can be added independently.
/// Enforces min/max table limits.
func placeBet(type: BetType, amount: Int) {
guard canPlaceBet, balance >= amount else { return }
// Check if adding this bet would exceed max
let currentAmount = betAmount(for: type)
guard currentAmount + amount <= maxBet else { return }
// Handle mutually exclusive Player/Banker bets
if type == .player || type == .banker {
// Remove any existing opposite main bet and refund it
if let existingMainBet = mainBet, existingMainBet.type != type {
balance += existingMainBet.amount
currentBets.removeAll(where: { $0.type == existingMainBet.type })
}
}
// Check if there's already a bet of this type
if let index = currentBets.firstIndex(where: { $0.type == type }) {
// Add to existing bet
let existingBet = currentBets[index]
currentBets[index] = Bet(type: type, amount: existingBet.amount + amount)
} else {
currentBets.append(Bet(type: type, amount: amount))
}
balance -= amount
// Play chip placement sound and haptic
sound.playChipPlace()
}
/// Clears all current bets and returns the amounts to balance.
func clearBets() {
guard canPlaceBet, !currentBets.isEmpty else { return }
balance += totalBetAmount
currentBets = []
// Play clear bets sound and haptic
sound.playClearBets()
}
/// Undoes the last bet placed.
func undoLastBet() {
guard canPlaceBet, let lastBet = currentBets.last else { return }
balance += lastBet.amount
currentBets.removeLast()
}
// MARK: - Game Flow
/// Starts a new round by dealing cards with animation.
func deal() async {
guard canDeal else { return }
isAnimating = true
engine.prepareNewRound()
// Clear visible cards and bet results
visiblePlayerCards = []
visibleBankerCards = []
playerCardsFaceUp = []
bankerCardsFaceUp = []
lastResult = nil
showResultBanner = false
playerHadPair = false
bankerHadPair = false
betResults = []
// Change to dealing phase - triggers layout animation (horizontal to vertical)
currentPhase = .dealingInitial
// Wait for layout animation to complete before dealing cards
if settings.showAnimations {
// Increased from 1s to 1.25s for better stability on all devices
try? await Task.sleep(for: .milliseconds(1250))
}
let initialCards = engine.dealInitialCards()
// Check if animations are enabled
if settings.showAnimations {
// Brief extra delay before the very first card deal starts
try? await Task.sleep(for: .milliseconds(250))
// Animate dealing: P1, B1, P2, B2
for (index, card) in initialCards.enumerated() {
try? await Task.sleep(for: dealDelay)
// Play card deal sound
sound.playCardDeal()
if index % 2 == 0 {
visiblePlayerCards.append(card)
playerCardsFaceUp.append(false)
} else {
visibleBankerCards.append(card)
bankerCardsFaceUp.append(false)
}
}
// Reveal according to selected style
switch settings.revealStyle {
case .auto, .tap, .squeeze:
// Sequential reveal: P1, B1, P2, B2
for i in 0..<playerCardsFaceUp.count {
// Flip player card
await waitForReveal(index: i * 2)
sound.playCardFlip()
playerCardsFaceUp[i] = true
// Flip banker card
await waitForReveal(index: i * 2 + 1)
sound.playCardFlip()
bankerCardsFaceUp[i] = true
}
}
// Pause to let user see initial totals
if settings.revealStyle == .auto {
try? await Task.sleep(for: resultDelay)
}
} else {
// No animations - show all cards immediately
for (index, card) in initialCards.enumerated() {
if index % 2 == 0 {
visiblePlayerCards.append(card)
playerCardsFaceUp.append(true)
} else {
visibleBankerCards.append(card)
bankerCardsFaceUp.append(true)
}
}
}
// Check for naturals
if engine.playerHand.isNatural || engine.bankerHand.isNatural {
// Pause before showing result for naturals
if settings.showAnimations {
try? await Task.sleep(for: resultDelay)
}
await showResult()
return
}
// Player third card
currentPhase = .playerThirdCard
if let playerThird = engine.drawPlayerThirdCard() {
if settings.showAnimations {
try? await Task.sleep(for: dealDelay)
sound.playCardDeal()
visiblePlayerCards.append(playerThird)
playerCardsFaceUp.append(false)
await waitForReveal(index: 4) // Player 3rd card
sound.playCardFlip()
playerCardsFaceUp[2] = true
if settings.revealStyle == .auto {
try? await Task.sleep(for: resultDelay)
}
} else {
visiblePlayerCards.append(playerThird)
playerCardsFaceUp.append(true)
}
}
// Banker third card
currentPhase = .bankerThirdCard
if let bankerThird = engine.drawBankerThirdCard() {
if settings.showAnimations {
try? await Task.sleep(for: dealDelay)
sound.playCardDeal()
visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(false)
await waitForReveal(index: 5) // Banker 3rd card
sound.playCardFlip()
bankerCardsFaceUp[2] = true
if settings.revealStyle == .auto {
try? await Task.sleep(for: resultDelay)
}
} else {
visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(true)
}
}
// Final pause after all cards are dealt before showing results
if settings.showAnimations {
try? await Task.sleep(for: resultDelay)
}
await showResult()
}
/// Shows the result and processes payouts.
private func showResult() async {
currentPhase = .showingResult
let result = engine.determineResult()
lastResult = result
// Record pair results for display
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
// 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
}
// Add winnings
if payout > 0 {
balance += payout
}
}
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 })
if let mainResult = mainBetResult {
if mainResult.isWin {
// Main bet won - play win sound
let maxBetAmount = currentBets.map(\.amount).max() ?? 0
let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
sound.playWin(isBigWin: isBigWin && totalWinnings > 0)
} else if mainResult.isPush {
// Main bet pushed (tie)
sound.playPush()
} else {
// Main bet lost
sound.playLose()
}
} else {
// No main bet (only side bets) - use total winnings
if totalWinnings > 0 {
sound.playWin(isBigWin: false)
} else if totalWinnings < 0 {
sound.playLose()
} else {
sound.playPush()
}
}
// Record result in history (for road map)
roundHistory.append(RoundResult(
result: result,
playerValue: playerHandValue,
bankerValue: bankerHandValue,
playerPair: playerHadPair,
bankerPair: bankerHadPair
))
// Save game data to iCloud
saveGameData()
// Show result banner - stays until user taps New Round
showResultBanner = true
currentPhase = .roundComplete
isAnimating = false
}
/// Prepares for a new round.
func newRound() {
guard currentPhase == .roundComplete else { return }
// Play new round sound
sound.playNewRound()
// Dismiss result banner
showResultBanner = false
currentBets = []
visiblePlayerCards = []
visibleBankerCards = []
playerCardsFaceUp = []
bankerCardsFaceUp = []
lastResult = nil
lastWinnings = 0
playerHadPair = false
bankerHadPair = false
betResults = []
currentPhase = .betting
}
/// Rebet the same amounts as last round.
func rebet() {
guard currentPhase == .roundComplete || (currentPhase == .betting && currentBets.isEmpty) else { return }
if currentPhase == .roundComplete {
newRound()
}
}
// MARK: - Session Cleanup Hooks
/// Clean up round history when deleting a session.
func onWillDeleteSession(id: UUID) {
sessionRoundHistories.removeValue(forKey: id.uuidString)
}
/// Clean up all round histories when deleting all sessions.
func onWillDeleteAllSessions() {
sessionRoundHistories.removeAll()
}
// 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)
newRoundInternal()
}
/// Internal new round reset (without sound).
private func newRoundInternal() {
currentBets = []
visiblePlayerCards = []
visibleBankerCards = []
playerCardsFaceUp = []
bankerCardsFaceUp = []
lastResult = nil
lastWinnings = 0
playerHadPair = false
bankerHadPair = false
betResults = []
currentPhase = .betting
}
/// Aggregated Baccarat-specific stats from all sessions.
var aggregatedBaccaratStats: BaccaratStats {
allSessions.aggregatedBaccaratStats()
}
// MARK: - Game Reset
/// Resets the entire game (keeps statistics).
/// Uses CasinoKit's performResetGame() which properly handles session ending.
func resetGame() {
performResetGame()
// Note: newRoundInternal() is called by resetForNewSession()
// Play new game sound
sound.playNewRound()
}
/// Completely clears all saved data and starts fresh.
func clearAllData() {
persistence.reset()
balance = settings.startingBalance
currentSession = nil
sessionHistory = []
sessionRoundHistories = [:]
roundHistory = []
startNewSession()
newRoundInternal()
// Play new game sound
sound.playNewRound()
}
/// Applies new settings (call after settings change).
func applySettings() {
resetGame()
}
}