CasinoGames/Blackjack/Engine/GameState.swift

623 lines
18 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: - 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
/// 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
/// 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.
var canBet: Bool {
currentPhase == .betting && currentBet + settings.minBet <= balance
}
/// Whether the current hand can hit.
var canHit: Bool {
guard case .playerTurn = currentPhase else { return false }
return activeHand?.canHit ?? false
}
/// Whether the current hand can stand.
var canStand: Bool {
guard case .playerTurn = currentPhase else { return false }
return !(activeHand?.isBusted ?? true)
}
/// Whether the current hand can double.
var canDouble: Bool {
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 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 case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
return engine.canSurrender(hand: hand)
}
/// Whether the game is over (out of money).
var isGameOver: Bool {
balance < settings.minBet && currentPhase == .betting
}
/// Total rounds played.
var roundsPlayed: Int {
roundHistory.count
}
// MARK: - Initialization
init(settings: GameSettings) {
self.settings = settings
self.balance = settings.startingBalance
self.engine = BlackjackEngine(settings: settings)
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
}
// 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.splitHandResult != nil,
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 bet.
func placeBet(amount: Int) {
guard canBet else { return }
guard currentBet + amount <= settings.maxBet else { return }
guard balance >= amount else { return }
currentBet += amount
balance -= amount
sound.play(.chipPlace)
}
/// Clears the current bet.
func clearBet() {
balance += currentBet
currentBet = 0
sound.play(.chipPlace)
}
// MARK: - Dealing
/// Deals the initial cards.
func deal() async {
guard currentBet >= settings.minBet else { return }
currentPhase = .dealing
playerHands = [BlackjackHand(bet: currentBet)]
dealerHand = BlackjackHand()
activeHandIndex = 0
insuranceBet = 0
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
// Deal cards: player, dealer, player, dealer
for i in 0..<4 {
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))
}
}
}
// Check for insurance offer
if let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) {
currentPhase = .insurance
return
}
// Check for immediate blackjacks
await checkForBlackjacks()
}
/// 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()
}
}
// MARK: - Player Actions
/// Player hits (takes another card).
func hit() async {
guard canHit else { return }
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 }
playerHands[activeHandIndex].isStanding = true
await moveToNextHand()
}
/// Player doubles down.
func doubleDown() async {
guard canDouble else { return }
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 }
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 }
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
// Reveal hole card
sound.play(.cardFlip)
let delay = settings.showAnimations ? 0.5 * settings.dealingSpeed : 0
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: - Round Completion
/// Completes the round and calculates payouts.
private func completeRound() async {
currentPhase = .roundComplete
var roundWinnings = 0
var wasBlackjack = false
var hadBust = false
// 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
)
balance += payout
roundWinnings += payout - playerHands[i].bet * (playerHands[i].isDoubledDown ? 2 : 1)
if result == .blackjack {
wasBlackjack = true
}
if result == .bust {
hadBust = true
}
}
}
// 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
lastRoundResult = RoundResult(
mainHandResult: playerHands[0].result ?? .lose,
splitHandResult: playerHands.count > 1 ? playerHands[1].result : nil,
insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil,
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()
}
}
// MARK: - New Round
/// Starts a new round.
func newRound() {
playerHands = []
dealerHand = BlackjackHand()
activeHandIndex = 0
insuranceBet = 0
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()
}
}