// // 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. @Observable @MainActor final class GameState { // MARK: - Settings let settings: GameSettings // MARK: - Sound private let sound = SoundManager.shared // MARK: - Game Engine private(set) var engine: BaccaratEngine // MARK: - Player State var balance: Int = 10_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: - 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 { engine.playerHand.value } var bankerHandValue: Int { engine.bankerHand.value } // Recent results for the road display (last 20) var recentResults: [RoundResult] { Array(roundHistory.suffix(20)) } // 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(500 * 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 } // 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 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 } // 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 if settings.soundEnabled { sound.play(.chipPlace) } if settings.hapticsEnabled { sound.hapticLight() } } /// 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 if settings.soundEnabled { sound.play(.clearBets) } if settings.hapticsEnabled { sound.hapticMedium() } } /// 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 = [] // Deal initial cards currentPhase = .dealingInitial let initialCards = engine.dealInitialCards() // Check if animations are enabled if settings.showAnimations { // Animate dealing: P1, B1, P2, B2 for (index, card) in initialCards.enumerated() { try? await Task.sleep(for: dealDelay) // Play card deal sound if settings.soundEnabled { sound.play(.cardDeal) } if index % 2 == 0 { visiblePlayerCards.append(card) playerCardsFaceUp.append(false) } else { visibleBankerCards.append(card) bankerCardsFaceUp.append(false) } } // Brief pause then flip cards try? await Task.sleep(for: flipDelay) // Play card flip sound if settings.soundEnabled { sound.play(.cardFlip) } // Flip all cards face up for i in 0..= 0 { balance += bet.amount } // Add winnings if payout > 0 { balance += payout } } betResults = results lastWinnings = totalWinnings // Play result sound if totalWinnings > 0 { // Determine if it's a big win (>= 5x any bet amount or >= 500) let maxBetAmount = currentBets.map(\.amount).max() ?? 0 let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500 if settings.soundEnabled { sound.play(isBigWin ? .bigWin : .win) } if settings.hapticsEnabled { sound.hapticSuccess() } } else if totalWinnings < 0 { if settings.soundEnabled { sound.play(.lose) } if settings.hapticsEnabled { sound.hapticError() } } else { // Push (tie with main bet push) if settings.soundEnabled { sound.play(.push) } if settings.hapticsEnabled { sound.hapticMedium() } } // Record result in history roundHistory.append(RoundResult( result: result, playerValue: playerHandValue, bankerValue: bankerHandValue, playerPair: playerHadPair, bankerPair: bankerHadPair )) // 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 if settings.soundEnabled { sound.play(.newRound) } // 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() } } /// Resets the game to initial state with current settings. func resetGame() { engine = BaccaratEngine(deckCount: settings.deckCount.rawValue) balance = settings.startingBalance currentBets = [] currentPhase = .betting lastResult = nil lastWinnings = 0 visiblePlayerCards = [] visibleBankerCards = [] playerCardsFaceUp = [] bankerCardsFaceUp = [] roundHistory = [] isAnimating = false showResultBanner = false playerHadPair = false bankerHadPair = false betResults = [] // Play new game sound if settings.soundEnabled { sound.play(.newRound) } if settings.hapticsEnabled { sound.hapticMedium() } } /// Applies new settings (call after settings change). func applySettings() { resetGame() } }