CasinoGames/Blackjack/Blackjack/Engine/GameState.swift

1091 lines
35 KiB
Swift

//
// GameState.swift
// Blackjack
//
// Manages the game state machine for Blackjack.
//
import SwiftUI
import CasinoKit
/// Current phase of the game.
enum GamePhase: Equatable {
case betting
case dealing
case insurance
case playerTurn(handIndex: Int)
case dealerTurn
case roundComplete
}
/// Main game state manager.
@Observable
@MainActor
final class GameState {
// MARK: - Core State
/// Current player balance.
private(set) var balance: Int
/// Current game phase.
private(set) var currentPhase: GamePhase = .betting
/// The current bet amount (before deal).
var currentBet: Int = 0
/// Insurance bet amount.
var insuranceBet: Int = 0
// MARK: - Side Bets
/// Perfect Pairs side bet amount.
var perfectPairsBet: Int = 0
/// 21+3 side bet amount.
var twentyOnePlusThreeBet: Int = 0
/// Result of Perfect Pairs bet (set after deal).
var perfectPairsResult: PerfectPairsResult?
/// Result of 21+3 bet (set after deal).
var twentyOnePlusThreeResult: TwentyOnePlusThreeResult?
/// Whether to show side bet toast notifications.
var showSideBetToasts: Bool = false
/// Whether to show the gameplay hint toast.
var showHintToast: Bool = false
/// Tracks the current hint display session to prevent race conditions.
/// When a new hint arrives or is shown, increment this so old dismiss tasks become stale.
var hintDisplayID: UUID = UUID()
/// Whether a reshuffle notification should be shown.
var showReshuffleNotification: Bool = false
// MARK: - Hands
/// Player's hands (can have multiple after splits).
private(set) var playerHands: [BlackjackHand] = []
/// Dealer's hand.
private(set) var dealerHand: BlackjackHand = BlackjackHand()
/// Index of the hand currently being played.
private(set) var activeHandIndex: Int = 0
/// Whether an action is currently being processed (prevents double-tap issues).
private(set) var isProcessingAction: Bool = false
/// Time of the last player action (prevents rapid double-taps).
private var lastActionTime: Date = .distantPast
/// Minimum interval between player actions in seconds.
private let actionDebounceInterval: TimeInterval = 0.2
/// The active player hand.
var activeHand: BlackjackHand? {
guard activeHandIndex < playerHands.count else { return nil }
return playerHands[activeHandIndex]
}
/// Dealer's face-up card.
var dealerUpCard: Card? {
dealerHand.cards.first
}
// MARK: - UI State
/// Whether to show the result banner.
var showResultBanner: Bool = false
/// The result of the last round.
private(set) var lastRoundResult: RoundResult?
/// Round history for statistics.
private(set) var roundHistory: [RoundResult] = []
// MARK: - Statistics (persisted)
private(set) var totalWinnings: Int = 0
private(set) var biggestWin: Int = 0
private(set) var biggestLoss: Int = 0
private(set) var blackjackCount: Int = 0
private(set) var bustCount: Int = 0
// MARK: - Persistence
/// iCloud sync manager for game data.
let persistence: CloudSyncManager<BlackjackGameData>
// MARK: - Engine & Settings
/// The game engine.
let engine: BlackjackEngine
/// Game settings.
let settings: GameSettings
/// Onboarding state for first-time users.
let onboarding: OnboardingState
/// Sound manager.
private let sound = SoundManager.shared
// MARK: - Computed Properties
/// Total bet across all hands.
var totalBet: Int {
playerHands.reduce(0) { $0 + $1.bet * ($1.isDoubledDown ? 2 : 1) } + insuranceBet
}
/// Whether player can place a bet.
/// True if in betting phase, have balance, and haven't hit max bet.
/// Whether in betting phase (matches Baccarat's canPlaceBet).
var canPlaceBet: Bool {
currentPhase == .betting
}
/// Whether the main bet can accept more chips of the given amount.
/// Matches Baccarat's canAddToBet pattern.
func canAddToMainBet(amount: Int) -> Bool {
currentBet + amount <= settings.maxBet
}
/// Whether a specific side bet can accept more chips of the given amount.
/// Matches Baccarat's canAddToBet pattern.
func canAddToSideBet(type: SideBetType, amount: Int) -> Bool {
guard settings.sideBetsEnabled else { return false }
let currentAmount: Int
switch type {
case .perfectPairs:
currentAmount = perfectPairsBet
case .twentyOnePlusThree:
currentAmount = twentyOnePlusThreeBet
}
return currentAmount + amount <= settings.maxBet
}
/// Whether a bet type is at max.
func isAtMax(main: Bool = false, sideType: SideBetType? = nil) -> Bool {
if main {
return currentBet >= settings.maxBet
}
if let type = sideType {
switch type {
case .perfectPairs:
return perfectPairsBet >= settings.maxBet
case .twentyOnePlusThree:
return twentyOnePlusThreeBet >= settings.maxBet
}
}
return false
}
/// The minimum bet level across all active bet types.
/// 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 {
if settings.sideBetsEnabled {
// Return the minimum of all bet types so chips stay enabled if any can be increased
return min(currentBet, perfectPairsBet, twentyOnePlusThreeBet)
} else {
return currentBet
}
}
/// Whether the current hand can hit.
var canHit: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
return activeHand?.canHit ?? false
}
/// Whether the current hand can stand.
var canStand: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
return !(activeHand?.isBusted ?? true)
}
/// Whether the current hand can double.
var canDouble: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
return engine.canDoubleDown(hand: hand, balance: balance)
}
/// Whether the current hand can split.
var canSplit: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
let splitCount = playerHands.count - 1
return engine.canSplit(hand: hand, balance: balance, currentSplitCount: splitCount)
}
/// Whether the player can surrender.
var canSurrender: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
return engine.canSurrender(hand: hand)
}
/// Whether the game is over (can't afford to meet minimum bet).
/// True when in betting phase and total available chips (balance + current bet) is less than minimum bet.
var isGameOver: Bool {
currentPhase == .betting && (balance + currentBet) < settings.minBet
}
/// Total rounds played.
var roundsPlayed: Int {
roundHistory.count
}
/// Whether it's currently the player's turn.
var isPlayerTurn: Bool {
if case .playerTurn = currentPhase { return true }
return false
}
/// Whether the dealer's hole card should be revealed.
var shouldShowDealerHoleCard: Bool {
switch currentPhase {
case .dealerTurn, .roundComplete:
return true
default:
return false
}
}
// MARK: - Hints
/// Current gameplay hint based on basic strategy.
var currentHint: String? {
guard settings.showHints else { return nil }
guard isPlayerTurn else { return nil }
guard let hand = activeHand,
let upCard = dealerUpCard else { return nil }
// Use count-adjusted hints when card counting is enabled
if settings.showCardCount {
return engine.getCountAdjustedHint(playerHand: hand, dealerUpCard: upCard)
}
return engine.getHint(playerHand: hand, dealerUpCard: upCard)
}
/// Whether the current bet is below the minimum required.
var isBetBelowMinimum: Bool {
currentBet > 0 && currentBet < settings.minBet
}
/// Amount needed to reach minimum bet.
var amountNeededForMinimum: Int {
max(0, settings.minBet - currentBet)
}
/// Whether the current bet has reached the maximum.
var isBetAtMaximum: Bool {
currentBet >= settings.maxBet
}
/// Betting recommendation based on the true count.
var bettingHint: String? {
guard settings.showCardCount else { return nil }
guard currentPhase == .betting else { return nil }
let tc = Int(engine.trueCount.rounded())
switch tc {
case ...(-2):
return String(localized: "Bet minimum or sit out")
case -1:
return String(localized: "Bet minimum")
case 0:
return String(localized: "Bet minimum (neutral)")
case 1:
return String(localized: "Bet 2x minimum")
case 2:
return String(localized: "Bet 4x minimum")
case 3:
return String(localized: "Bet 6x minimum")
case 4:
return String(localized: "Bet 8x minimum")
case 5...:
return String(localized: "Bet maximum!")
default:
return nil
}
}
// MARK: - Initialization
init(settings: GameSettings) {
self.settings = settings
self.balance = settings.startingBalance
self.engine = BlackjackEngine(settings: settings)
self.onboarding = OnboardingState(gameIdentifier: "blackjack")
self.persistence = CloudSyncManager<BlackjackGameData>()
syncSoundSettings()
loadSavedGame()
}
/// Syncs sound settings with SoundManager.
private func syncSoundSettings() {
sound.soundEnabled = settings.soundEnabled
sound.hapticsEnabled = settings.hapticsEnabled
sound.volume = settings.soundVolume
}
/// Called when deck count setting changes - reshuffles with new deck count.
func applyDeckCountChange() {
engine.reshuffle()
}
// MARK: - Persistence
/// Loads saved game data from iCloud or local storage.
private func loadSavedGame() {
let data = persistence.load()
self.balance = data.balance
self.totalWinnings = data.totalWinnings
self.biggestWin = data.biggestWin
self.biggestLoss = data.biggestLoss
self.blackjackCount = data.blackjackCount
self.bustCount = data.bustCount
// Set up callback for when iCloud data arrives later
persistence.onCloudDataReceived = { [weak self] newData in
guard let self else { return }
self.balance = newData.balance
self.totalWinnings = newData.totalWinnings
self.biggestWin = newData.biggestWin
self.biggestLoss = newData.biggestLoss
self.blackjackCount = newData.blackjackCount
self.bustCount = newData.bustCount
}
}
/// Saves current game data to iCloud and local storage.
private func saveGameData() {
let savedRounds: [SavedRoundResult] = roundHistory.map { result in
SavedRoundResult(
date: Date(),
mainResult: result.mainHandResult.saveName,
hadSplit: result.hadSplit,
totalWinnings: result.totalWinnings
)
}
let data = BlackjackGameData(
lastModified: Date(),
balance: balance,
roundHistory: savedRounds,
totalWinnings: totalWinnings,
biggestWin: biggestWin,
biggestLoss: biggestLoss,
blackjackCount: blackjackCount,
bustCount: bustCount
)
persistence.save(data)
}
/// Clears all saved data.
func clearAllData() {
persistence.reset()
balance = settings.startingBalance
totalWinnings = 0
biggestWin = 0
biggestLoss = 0
blackjackCount = 0
bustCount = 0
roundHistory = []
newRound()
}
// MARK: - Betting
/// Places a main bet.
/// Places a main bet. Matches Baccarat's placeBet pattern.
func placeBet(amount: Int) {
guard canPlaceBet else { return }
guard balance >= amount else { return }
guard canAddToMainBet(amount: amount) else { return }
currentBet += amount
balance -= amount
sound.play(.chipPlace)
}
/// Places a side bet (Perfect Pairs or 21+3).
/// Matches Baccarat's placeBet pattern for side bets.
func placeSideBet(type: SideBetType, amount: Int) {
guard canPlaceBet else { return }
guard balance >= amount else { return }
guard canAddToSideBet(type: type, amount: amount) else { return }
switch type {
case .perfectPairs:
perfectPairsBet += amount
case .twentyOnePlusThree:
twentyOnePlusThreeBet += amount
}
balance -= amount
sound.play(.chipPlace)
}
/// Clears all bets (main and side bets).
func clearBet() {
balance += currentBet + perfectPairsBet + twentyOnePlusThreeBet
currentBet = 0
perfectPairsBet = 0
twentyOnePlusThreeBet = 0
sound.play(.chipPlace)
}
/// Total amount bet (main + side bets).
var totalBetAmount: Int {
currentBet + perfectPairsBet + twentyOnePlusThreeBet
}
// MARK: - Dealing
/// Whether the player can deal (betting phase with valid bet).
var canDeal: Bool {
currentPhase == .betting && currentBet >= settings.minBet
}
/// Deals the initial cards.
func deal() async {
guard canDeal else { return }
// Ensure enough cards for a full hand - reshuffle if needed
if !engine.canDealNewHand {
engine.reshuffle()
// Show notification with animation
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = true
}
// Auto-dismiss after 2 seconds
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = false
}
}
}
currentPhase = .dealing
playerHands = [BlackjackHand(bet: currentBet)]
dealerHand = BlackjackHand()
activeHandIndex = 0
insuranceBet = 0
perfectPairsResult = nil
twentyOnePlusThreeResult = nil
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
// European no-hole-card: deal 3 cards (player, dealer, player)
// American style: deal 4 cards (player, dealer, player, dealer)
let cardCount = settings.noHoleCard ? 3 : 4
for i in 0..<cardCount {
if let card = engine.dealCard() {
if i % 2 == 0 {
playerHands[0].cards.append(card)
} else {
dealerHand.cards.append(card)
}
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
}
// Evaluate side bets after initial cards are dealt
evaluateSideBets()
// Check for insurance offer (only in American style with hole card)
// Skip if user has opted out of insurance prompts
if !settings.noHoleCard,
!settings.neverAskInsurance,
let upCard = dealerUpCard,
engine.shouldOfferInsurance(dealerUpCard: upCard) {
currentPhase = .insurance
return
}
// Check for immediate blackjacks (only in American style - European checks after player acts)
if !settings.noHoleCard {
await checkForBlackjacks()
} else {
// European: just go to player turn (blackjacks checked after player acts)
if playerHands[0].isBlackjack {
// Player blackjack - will be handled after dealer gets second card
currentPhase = .playerTurn(handIndex: 0)
} else {
currentPhase = .playerTurn(handIndex: 0)
}
}
}
/// Checks for blackjacks and handles accordingly.
private func checkForBlackjacks() async {
let playerBJ = playerHands[0].isBlackjack
let dealerBJ = dealerHand.isBlackjack
if playerBJ || dealerBJ {
// Reveal dealer card
sound.play(.cardFlip)
if playerBJ && dealerBJ {
// Push
playerHands[0].result = .push
await completeRound()
} else if playerBJ {
// Player wins
playerHands[0].result = .blackjack
await completeRound()
} else {
// Dealer wins
playerHands[0].result = .lose
await completeRound()
}
} else {
currentPhase = .playerTurn(handIndex: 0)
}
}
// MARK: - Insurance
/// Takes insurance bet.
func takeInsurance() async {
let insuranceAmount = currentBet / 2
guard balance >= insuranceAmount else {
declineInsurance()
return
}
insuranceBet = insuranceAmount
balance -= insuranceAmount
sound.play(.chipPlace)
// Check dealer blackjack
if dealerHand.isBlackjack {
sound.play(.cardFlip)
// Insurance wins
let payout = insuranceBet * 3
balance += payout
playerHands[0].result = .lose
await completeRound()
} else {
// Insurance loses, continue game
insuranceBet = 0 // Lost the insurance bet
await checkForBlackjacks()
}
}
/// Declines insurance.
func declineInsurance() {
Task {
await checkForBlackjacks()
}
}
/// Declines insurance and sets the "never ask again" preference.
func neverAskInsurance() {
settings.neverAskInsurance = true
declineInsurance()
}
// MARK: - Player Actions
/// Player hits (takes another card).
func hit() async {
guard canHit else { return }
// Debounce: ignore rapid taps
let now = Date()
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
lastActionTime = now
isProcessingAction = true
defer { isProcessingAction = false }
guard let card = engine.dealCard() else { return }
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
// Check for bust or 21
if playerHands[activeHandIndex].isBusted {
playerHands[activeHandIndex].result = .bust
await moveToNextHand()
} else if playerHands[activeHandIndex].value == 21 {
playerHands[activeHandIndex].isStanding = true
await moveToNextHand()
}
}
/// Player stands.
func stand() async {
guard canStand else { return }
// Debounce: ignore rapid taps
let now = Date()
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
lastActionTime = now
isProcessingAction = true
defer { isProcessingAction = false }
playerHands[activeHandIndex].isStanding = true
await moveToNextHand()
}
/// Player doubles down.
func doubleDown() async {
guard canDouble else { return }
// Debounce: ignore rapid taps
let now = Date()
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
lastActionTime = now
isProcessingAction = true
defer { isProcessingAction = false }
let additionalBet = playerHands[activeHandIndex].bet
balance -= additionalBet
playerHands[activeHandIndex].isDoubledDown = true
sound.play(.chipPlace)
// Deal one card and stand
if let card = engine.dealCard() {
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
}
if playerHands[activeHandIndex].isBusted {
playerHands[activeHandIndex].result = .bust
} else {
playerHands[activeHandIndex].isStanding = true
}
await moveToNextHand()
}
/// Player splits the hand.
func split() async {
guard canSplit else { return }
// Debounce: ignore rapid taps
let now = Date()
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
lastActionTime = now
isProcessingAction = true
defer { isProcessingAction = false }
let originalHand = playerHands[activeHandIndex]
let splitCard = originalHand.cards[1]
// Create two new hands
var hand1 = BlackjackHand(cards: [originalHand.cards[0]], bet: originalHand.bet)
hand1.isSplit = true
var hand2 = BlackjackHand(cards: [splitCard], bet: originalHand.bet)
hand2.isSplit = true
// Deduct bet for second hand
balance -= originalHand.bet
sound.play(.chipPlace)
// Deal one card to each hand
if let card1 = engine.dealCard() {
hand1.cards.append(card1)
sound.play(.cardDeal)
}
if let card2 = engine.dealCard() {
hand2.cards.append(card2)
sound.play(.cardDeal)
}
// Replace original with split hands
playerHands.remove(at: activeHandIndex)
playerHands.insert(hand1, at: activeHandIndex)
playerHands.insert(hand2, at: activeHandIndex + 1)
// If split aces, typically only one card each and stand
if originalHand.cards[0].rank == .ace && !settings.resplitAces {
playerHands[activeHandIndex].isStanding = true
playerHands[activeHandIndex + 1].isStanding = true
await moveToNextHand()
} else {
currentPhase = .playerTurn(handIndex: activeHandIndex)
}
}
/// Player surrenders.
func surrender() async {
guard canSurrender else { return }
// Debounce: ignore rapid taps
let now = Date()
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
lastActionTime = now
isProcessingAction = true
defer { isProcessingAction = false }
playerHands[activeHandIndex].result = .surrender
await completeRound()
}
// MARK: - Hand Progression
/// Moves to the next hand or dealer turn.
private func moveToNextHand() async {
// Check if there are more hands to play
let nextIndex = activeHandIndex + 1
if nextIndex < playerHands.count {
if !playerHands[nextIndex].isStanding && !playerHands[nextIndex].isBusted {
activeHandIndex = nextIndex
currentPhase = .playerTurn(handIndex: nextIndex)
return
}
}
// Check if all hands are busted
let allBusted = playerHands.allSatisfy { $0.isBusted }
if allBusted {
await completeRound()
return
}
// Dealer's turn
await dealerTurn()
}
// MARK: - Dealer Turn
/// Plays out the dealer's hand.
private func dealerTurn() async {
currentPhase = .dealerTurn
let delay = settings.showAnimations ? 0.5 * settings.dealingSpeed : 0
// European no-hole-card: deal the second card now
if settings.noHoleCard && dealerHand.cards.count == 1 {
if let card = engine.dealCard() {
dealerHand.cards.append(card)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
// Check for dealer blackjack in European mode
// Player loses everything (no early check in European)
if dealerHand.isBlackjack {
// Mark player hands as lost if they don't have blackjack
for i in 0..<playerHands.count {
if playerHands[i].result == nil {
if playerHands[i].isBlackjack {
playerHands[i].result = .push
} else {
playerHands[i].result = .lose
}
}
}
await completeRound()
return
}
} else {
// American style: reveal hole card
sound.play(.cardFlip)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
// Dealer draws
while engine.dealerShouldHit(hand: dealerHand) {
if let card = engine.dealCard() {
dealerHand.cards.append(card)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
}
await completeRound()
}
// MARK: - Side Bets
/// Evaluates side bets based on the initial deal.
private func evaluateSideBets() {
guard settings.sideBetsEnabled else { return }
let playerCards = playerHands[0].cards
guard playerCards.count >= 2 else { return }
// Evaluate Perfect Pairs
if perfectPairsBet > 0 {
perfectPairsResult = SideBetEvaluator.evaluatePerfectPairs(
card1: playerCards[0],
card2: playerCards[1]
)
}
// Evaluate 21+3 (requires dealer upcard)
if twentyOnePlusThreeBet > 0, let dealerUpCard = dealerUpCard {
twentyOnePlusThreeResult = SideBetEvaluator.evaluateTwentyOnePlusThree(
playerCard1: playerCards[0],
playerCard2: playerCards[1],
dealerUpcard: dealerUpCard
)
}
// Show toast notifications if any side bets were placed
if perfectPairsBet > 0 || twentyOnePlusThreeBet > 0 {
showSideBetToasts = true
// Play sound for side bet result
let ppWon = perfectPairsResult?.isWin ?? false
let topWon = twentyOnePlusThreeResult?.isWin ?? false
if ppWon || topWon {
sound.play(.win)
}
// Auto-hide toasts after delay
Task {
try? await Task.sleep(for: Design.Toast.duration)
showSideBetToasts = false
}
}
}
/// Calculates winnings from side bets.
var sideBetWinnings: Int {
var winnings = 0
if let ppResult = perfectPairsResult, ppResult.isWin {
winnings += perfectPairsBet * ppResult.payout
}
if let topResult = twentyOnePlusThreeResult, topResult.isWin {
winnings += twentyOnePlusThreeBet * topResult.payout
}
return winnings
}
// MARK: - Round Completion
/// Completes the round and calculates payouts.
private func completeRound() async {
currentPhase = .roundComplete
var roundWinnings = 0
var wasBlackjack = false
var hadBust = false
var perHandWinnings: [Int] = []
// Evaluate each hand
for i in 0..<playerHands.count {
if playerHands[i].result == nil {
playerHands[i].result = engine.determineResult(
playerHand: playerHands[i],
dealerHand: dealerHand
)
}
if let result = playerHands[i].result {
let payout = engine.calculatePayout(
bet: playerHands[i].bet,
result: result,
isDoubled: playerHands[i].isDoubledDown
)
let totalBet = playerHands[i].bet * (playerHands[i].isDoubledDown ? 2 : 1)
let handWinnings = payout - totalBet
balance += payout
roundWinnings += handWinnings
perHandWinnings.append(handWinnings)
if result == .blackjack {
wasBlackjack = true
}
if result == .bust {
hadBust = true
}
} else {
perHandWinnings.append(0)
}
}
// Calculate insurance result
var insResult: HandResult? = nil
var insWinnings = 0
if insuranceBet > 0 {
if dealerHand.isBlackjack {
insResult = .insuranceWin
insWinnings = insuranceBet * 2 // Insurance pays 2:1
balance += insuranceBet * 3 // Return bet + 2x winnings
roundWinnings += insWinnings
} else {
insResult = .insuranceLose
insWinnings = -insuranceBet // Lost insurance bet
roundWinnings += insWinnings
}
}
// Calculate side bet results
let sideBetsTotal = perfectPairsBet + twentyOnePlusThreeBet
let sideBetsWon = sideBetWinnings
if sideBetsWon > 0 {
balance += sideBetsWon + sideBetsTotal // Return bet + winnings
roundWinnings += sideBetsWon
} else if sideBetsTotal > 0 {
// Side bets lost - account for the loss in round winnings
roundWinnings -= sideBetsTotal
}
// Update statistics
totalWinnings += roundWinnings
if roundWinnings > biggestWin {
biggestWin = roundWinnings
}
if roundWinnings < biggestLoss {
biggestLoss = roundWinnings
}
if wasBlackjack {
blackjackCount += 1
}
if hadBust {
bustCount += 1
}
// Create round result with all hand results, per-hand winnings, and side bets
let allHandResults = playerHands.map { $0.result ?? .lose }
// Calculate individual side bet winnings
let ppWinnings: Int
if let ppResult = perfectPairsResult, perfectPairsBet > 0 {
ppWinnings = ppResult.isWin ? perfectPairsBet * ppResult.payout : -perfectPairsBet
} else {
ppWinnings = 0
}
let topWinnings: Int
if let topResult = twentyOnePlusThreeResult, twentyOnePlusThreeBet > 0 {
topWinnings = topResult.isWin ? twentyOnePlusThreeBet * topResult.payout : -twentyOnePlusThreeBet
} else {
topWinnings = 0
}
lastRoundResult = RoundResult(
handResults: allHandResults,
handWinnings: perHandWinnings,
insuranceResult: insResult,
insuranceWinnings: insWinnings,
perfectPairsResult: perfectPairsBet > 0 ? perfectPairsResult : nil,
perfectPairsWinnings: ppWinnings,
twentyOnePlusThreeResult: twentyOnePlusThreeBet > 0 ? twentyOnePlusThreeResult : nil,
twentyOnePlusThreeWinnings: topWinnings,
totalWinnings: roundWinnings,
wasBlackjack: wasBlackjack
)
roundHistory.append(lastRoundResult!)
// Save game data to iCloud
saveGameData()
// Play appropriate sound
if roundWinnings > 0 {
sound.play(.win)
} else if roundWinnings < 0 {
sound.play(.lose)
} else {
sound.play(.push)
}
// Reset bet for next round
currentBet = 0
showResultBanner = true
// Check if shoe needs reshuffling
if engine.needsReshuffle {
engine.reshuffle()
// Show notification after delay so it appears after result banner is dismissed
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = true
}
// Auto-dismiss after showing for 2 seconds
try? await Task.sleep(for: .seconds(2))
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = false
}
}
}
}
// MARK: - New Round
/// Starts a new round.
func newRound() {
// Reset all hand state
playerHands = []
dealerHand = BlackjackHand()
activeHandIndex = 0
// Reset bets
currentBet = 0
insuranceBet = 0
perfectPairsBet = 0
twentyOnePlusThreeBet = 0
// Reset side bet results
perfectPairsResult = nil
twentyOnePlusThreeResult = nil
showSideBetToasts = false
// Reset UI state
showResultBanner = false
lastRoundResult = nil
currentPhase = .betting
sound.play(.newRound)
}
// MARK: - Game Reset
/// Resets the entire game (keeps statistics).
func resetGame() {
balance = settings.startingBalance
roundHistory = []
engine.reshuffle()
newRound()
saveGameData()
}
}