CasinoGames/Baccarat/Engine/GameState.swift

651 lines
20 KiB
Swift

//
// 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: - Persistence
private var persistence: CloudSyncManager<BaccaratGameData>!
// 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
// Sync sound settings with SoundManager
syncSoundSettings()
// Initialize persistence with cloud data callback
self.persistence = CloudSyncManager<BaccaratGameData>()
persistence.onCloudDataReceived = { [weak self] cloudData in
self?.handleCloudDataReceived(cloudData)
}
// Load saved game data
loadSavedGame()
}
/// Handles data received from iCloud (e.g., after fresh install or from another device).
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
print("GameState: Received cloud data with \(cloudData.roundsPlayed) rounds")
// Only update if cloud has more progress than current state
guard cloudData.roundsPlayed > roundHistory.count else {
print("GameState: Local data is newer, ignoring cloud data")
return
}
// Restore balance
self.balance = cloudData.balance
// 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
)
}
print("GameState: Restored from cloud - \(cloudData.roundsPlayed) rounds, balance: \(cloudData.balance)")
}
// MARK: - Persistence
/// Loads saved game data from iCloud/local storage.
private func loadSavedGame() {
let savedData = persistence.data
// Only restore if there's saved progress
guard savedData.roundsPlayed > 0 else { return }
// 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
)
}
print("GameState: Restored \(savedData.roundsPlayed) rounds, balance: \(savedData.balance)")
}
/// Saves current game state to iCloud/local storage.
private func saveGame(netWinnings: Int = 0) {
var data = persistence.data
// Update balance
data.balance = balance
// Update statistics
data.totalWinnings += netWinnings
if netWinnings > data.biggestWin {
data.biggestWin = netWinnings
}
if netWinnings < 0 && abs(netWinnings) > data.biggestLoss {
data.biggestLoss = abs(netWinnings)
}
// Update round history from current session
data.roundHistory = roundHistory.enumerated().map { index, round in
// Try to get existing saved result for net winnings
if index < data.roundHistory.count {
return data.roundHistory[index]
}
// 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)
}
persistence.save(data)
}
/// 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 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 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 = []
// 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
sound.playCardDeal()
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
sound.playCardFlip()
// Flip all cards face up
for i in 0..<playerCardsFaceUp.count {
playerCardsFaceUp[i] = true
}
for i in 0..<bankerCardsFaceUp.count {
bankerCardsFaceUp[i] = true
}
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 {
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)
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
playerCardsFaceUp[2] = true
try? await Task.sleep(for: flipDelay)
} 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)
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
bankerCardsFaceUp[2] = true
try? await Task.sleep(for: dealDelay)
} else {
visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(true)
}
}
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
// Calculate and apply payouts, track individual results
var totalWinnings = 0
var results: [BetResult] = []
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))
// Return original bet if not a loss
if payout >= 0 {
balance += bet.amount
}
// Add winnings
if payout > 0 {
balance += payout
}
}
betResults = results
lastWinnings = totalWinnings
// 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
roundHistory.append(RoundResult(
result: result,
playerValue: playerHandValue,
bankerValue: bankerHandValue,
playerPair: playerHadPair,
bankerPair: bankerHadPair
))
// Save game state to iCloud/local
saveGame(netWinnings: totalWinnings)
// 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()
}
}
/// 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 = []
// Save the reset state (keeps lifetime stats, resets balance and session history)
saveGame()
// Play new game sound
sound.playNewRound()
}
/// Completely clears all saved data and starts fresh (including lifetime stats).
func clearAllData() {
persistence.reset()
resetGame()
}
/// Returns lifetime statistics from saved data.
var lifetimeStats: BaccaratGameData {
persistence.data
}
/// Applies new settings (call after settings change).
func applySettings() {
resetGame()
}
}