CasinoGames/Baccarat/Baccarat/Engine/GameState.swift

896 lines
30 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.
@Observable
@MainActor
final class GameState {
// MARK: - Settings
let settings: GameSettings
// MARK: - Onboarding
let onboarding: OnboardingState
// 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 {
// 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))
}
// 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(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
self.onboarding = OnboardingState(gameIdentifier: "baccarat")
// 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) {
// Only update if cloud has more progress than current state
guard cloudData.roundsPlayed > roundHistory.count else {
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
)
}
}
// 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
)
}
}
/// 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 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
}
// 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 {
try? await Task.sleep(for: .seconds(1))
}
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)
CasinoDesign.debugLog("🃏 Player 3rd card dealt face-down: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
playerCardsFaceUp[2] = true
CasinoDesign.debugLog("🃏 Player 3rd card flipped: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
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)
CasinoDesign.debugLog("🃏 Banker 3rd card dealt face-down: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
bankerCardsFaceUp[2] = true
CasinoDesign.debugLog("🃏 Banker 3rd card flipped: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
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()
}
}