896 lines
30 KiB
Swift
896 lines
30 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: - 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()
|
||
}
|
||
}
|