// // BlackjackEngine.swift // Blackjack // // Core game logic for Blackjack. // import Foundation import CasinoKit /// Manages the Blackjack game rules and shoe. @Observable @MainActor final class BlackjackEngine { // MARK: - Properties /// The card shoe. private(set) var shoe: Deck /// Settings reference for rule variations. private let settings: GameSettings /// Running count for card counting (Hi-Lo system). private(set) var runningCount: Int = 0 /// Number of decks in the shoe (reads from current settings). var deckCount: Int { settings.deckCount.rawValue } /// Cards remaining in shoe. var cardsRemaining: Int { shoe.cardsRemaining } /// True count (running count / decks remaining). var trueCount: Double { let decksRemaining = max(1.0, Double(cardsRemaining) / 52.0) return Double(runningCount) / decksRemaining } /// Minimum cards needed to safely complete a hand. /// Need ~10 cards worst case (player splits once, both hit twice, dealer hits twice). private let minimumCardsForHand: Int = 10 /// The cut card position (cards to deal before reshuffling). /// Set during reshuffle with slight random variation for realism. private var cutCardPosition: Int = 0 /// Realistic default penetration based on deck count. /// These match typical casino practices. private var defaultPenetration: Double { switch deckCount { case 1: return 0.60 // 60% - common for single-deck (hand-held) case 2: return 0.70 // 70% - typical double-deck pitch game default: return 0.75 // 75% - standard for 4-8 deck shoe } } /// Whether the shoe needs reshuffling (hit cut card position). var needsReshuffle: Bool { let cardsDealt = (52 * deckCount) - cardsRemaining return cardsDealt >= cutCardPosition } /// Whether there are enough cards to start a new hand. var canDealNewHand: Bool { cardsRemaining >= minimumCardsForHand } /// Current penetration percentage (how much of the shoe has been dealt). var penetrationPercentage: Double { let totalCards = 52 * deckCount return Double(totalCards - cardsRemaining) / Double(totalCards) } // MARK: - Initialization init(settings: GameSettings) { self.settings = settings self.shoe = Deck(deckCount: settings.deckCount.rawValue) shoe.shuffle() // Set initial cut card position let totalCards = 52 * settings.deckCount.rawValue cutCardPosition = Int(Double(totalCards) * defaultPenetration) } // MARK: - Shoe Management /// Reshuffles the shoe with the current deck count from settings. func reshuffle() { shoe = Deck(deckCount: settings.deckCount.rawValue) shoe.shuffle() runningCount = 0 // Calculate cut card position with slight random variation (±5%) for realism let totalCards = 52 * deckCount let basePenetration = defaultPenetration let variation = Double.random(in: 0.95...1.05) let cardsToDeal = Int(Double(totalCards) * basePenetration * variation) // Clamp between 50% and 85% penetration let minCards = Int(Double(totalCards) * 0.50) let maxCards = Int(Double(totalCards) * 0.85) cutCardPosition = max(min(cardsToDeal, maxCards), minCards) } /// Deals a single card from the shoe and updates the running count. /// If the shoe is empty, automatically reshuffles and deals. func dealCard() -> Card? { // Emergency reshuffle if shoe is empty (should rarely happen) if cardsRemaining == 0 { reshuffle() } guard let card = shoe.draw() else { return nil } runningCount += card.hiLoValue return card } // MARK: - Card Counting /// Updates the running count when a card is revealed (e.g., dealer hole card). func updateCount(for card: Card) { runningCount += card.hiLoValue } // MARK: - Hand Evaluation /// Determines if dealer should hit based on rules. func dealerShouldHit(hand: BlackjackHand) -> Bool { let value = hand.value if value < 17 { return true } if value == 17 && hand.isSoft && settings.dealerHitsSoft17 { return true } return false } /// Determines the result of a player hand against dealer. func determineResult(playerHand: BlackjackHand, dealerHand: BlackjackHand) -> HandResult { let playerValue = playerHand.value let dealerValue = dealerHand.value // Player busted if playerHand.isBusted { return .bust } // Player has blackjack if playerHand.isBlackjack { if dealerHand.isBlackjack { return .push } return .blackjack } // Dealer busted if dealerHand.isBusted { return .win } // Dealer has blackjack (player doesn't) if dealerHand.isBlackjack { return .lose } // Compare values if playerValue > dealerValue { return .win } else if playerValue < dealerValue { return .lose } else { return .push } } /// Calculates payout for a hand result. func calculatePayout(bet: Int, result: HandResult, isDoubled: Bool) -> Int { let effectiveBet = isDoubled ? bet * 2 : bet switch result { case .blackjack: return Int(Double(bet) * settings.blackjackPayout) + bet case .win: return effectiveBet * 2 case .push: return effectiveBet case .lose, .bust: return 0 case .surrender: return bet / 2 case .insuranceWin: return bet * 3 // 2:1 + original bet case .insuranceLose: return 0 } } // MARK: - Action Availability /// Whether player can double down on this hand. func canDoubleDown(hand: BlackjackHand, balance: Int) -> Bool { guard hand.cards.count == 2 else { return false } guard !hand.isDoubledDown else { return false } guard balance >= hand.bet else { return false } // After split, check DAS rule if hand.isSplit && !settings.doubleAfterSplit { return false } return true } /// Whether player can split this hand. func canSplit(hand: BlackjackHand, balance: Int, currentSplitCount: Int) -> Bool { guard hand.canSplit else { return false } guard balance >= hand.bet else { return false } guard currentSplitCount < 3 else { return false } // Max 4 hands // Check resplit aces if hand.isSplit && hand.cards.first?.rank == .ace && !settings.resplitAces { return false } return true } /// Whether player can surrender. func canSurrender(hand: BlackjackHand) -> Bool { guard settings.lateSurrender else { return false } guard hand.cards.count == 2 else { return false } guard !hand.isSplit else { return false } return true } /// Whether insurance should be offered. func shouldOfferInsurance(dealerUpCard: Card) -> Bool { settings.insuranceAllowed && dealerUpCard.rank == .ace } // MARK: - Basic Strategy Hint /// Returns the basic strategy recommendation based on BJA chart. /// Accounts for game settings (surrender, dealer hits soft 17, etc.) func getHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String { let playerValue = playerHand.value let dealerValue = dealerUpCard.blackjackValue let isSoft = playerHand.isSoft let canDouble = playerHand.cards.count == 2 let surrenderAvailable = settings.lateSurrender let dealerHitsS17 = settings.dealerHitsSoft17 // SURRENDER (when available) - check first if surrenderAvailable && playerHand.cards.count == 2 { // 16 vs 9, 10, A - Surrender if playerValue == 16 && !isSoft && (dealerValue >= 9 || dealerValue == 1) { return String(localized: "Surrender") } // 15 vs 10 - Surrender if playerValue == 15 && !isSoft && dealerValue == 10 { return String(localized: "Surrender") } // 15 vs A - Surrender (if dealer hits soft 17) if playerValue == 15 && !isSoft && dealerValue == 1 && dealerHitsS17 { return String(localized: "Surrender") } } // PAIRS if playerHand.canSplit { let pairRank = playerHand.cards[0].rank switch pairRank { case .ace: return String(localized: "Split") case .eight: return String(localized: "Split") case .ten, .jack, .queen, .king: return String(localized: "Stand") case .five: // Never split 5s - treat as hard 10 return (canDouble && dealerValue <= 9) ? String(localized: "Double") : String(localized: "Hit") case .four: // Split 4s vs 5-6 (if DAS), otherwise hit return (settings.doubleAfterSplit && (dealerValue == 5 || dealerValue == 6)) ? String(localized: "Split") : String(localized: "Hit") case .two, .three: // Split 2s/3s vs 2-7 return dealerValue >= 2 && dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit") case .six: // Split 6s vs 2-6 return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Split") : String(localized: "Hit") case .seven: // Split 7s vs 2-7 return dealerValue >= 2 && dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit") case .nine: // Split 9s vs 2-6, 8-9. Stand vs 7, 10, A if dealerValue == 7 || dealerValue == 10 || dealerValue == 1 { return String(localized: "Stand") } return String(localized: "Split") } } // SOFT HANDS (Ace counted as 11) if isSoft { switch playerValue { case 20, 21: // A,9 or A,10 return String(localized: "Stand") case 19: // A,8 // Double vs 6 if dealer hits S17, otherwise stand if canDouble && dealerValue == 6 && dealerHitsS17 { return String(localized: "Double") } return String(localized: "Stand") case 18: // A,7 // Double vs 3-6, Stand vs 2/7/8, Hit vs 9/10/A if canDouble && dealerValue >= 3 && dealerValue <= 6 { return String(localized: "Double") } if dealerValue == 2 || dealerValue == 7 || dealerValue == 8 { return String(localized: "Stand") } return String(localized: "Hit") case 17: // A,6 // Double vs 3-6, otherwise hit if canDouble && dealerValue >= 3 && dealerValue <= 6 { return String(localized: "Double") } return String(localized: "Hit") case 16, 15: // A,5 or A,4 // Double vs 4-6, otherwise hit if canDouble && dealerValue >= 4 && dealerValue <= 6 { return String(localized: "Double") } return String(localized: "Hit") case 14, 13: // A,3 or A,2 // Double vs 5-6, otherwise hit if canDouble && dealerValue >= 5 && dealerValue <= 6 { return String(localized: "Double") } return String(localized: "Hit") default: return String(localized: "Hit") } } // HARD HANDS switch playerValue { case 17...21: return String(localized: "Stand") case 16: // Stand vs 2-6, Hit vs 7+ return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Stand") : String(localized: "Hit") case 15: // Stand vs 2-6, Hit vs 7+ return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Stand") : String(localized: "Hit") case 14: // Stand vs 2-6, Hit vs 7+ return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Stand") : String(localized: "Hit") case 13: // Stand vs 2-6, Hit vs 7+ return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Stand") : String(localized: "Hit") case 12: // Stand vs 4-6, Hit vs 2-3 and 7+ return dealerValue >= 4 && dealerValue <= 6 ? String(localized: "Stand") : String(localized: "Hit") case 11: // Always double (except vs A in some rules) if canDouble { return String(localized: "Double") } return String(localized: "Hit") case 10: // Double vs 2-9, Hit vs 10/A if canDouble && dealerValue >= 2 && dealerValue <= 9 { return String(localized: "Double") } return String(localized: "Hit") case 9: // Double vs 3-6, Hit otherwise if canDouble && dealerValue >= 3 && dealerValue <= 6 { return String(localized: "Double") } return String(localized: "Hit") default: // 8 or less return String(localized: "Hit") } } /// Returns the count-adjusted strategy recommendation with deviation explanation. /// Based on the "Illustrious 18" - the most valuable count-based deviations. func getCountAdjustedHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String { let basicHint = getHint(playerHand: playerHand, dealerUpCard: dealerUpCard) let tc = Int(trueCount.rounded()) let playerValue = playerHand.value let dealerValue = dealerUpCard.blackjackValue let isSoft = playerHand.isSoft // Check for count-based deviations from basic strategy // 16 vs 10: Stand at TC 0+ (basic says Hit) if playerValue == 16 && !isSoft && dealerValue == 10 { if tc >= 0 { return String(localized: "Stand (Count: 16v10 at TC≥0)") } } // 15 vs 10: Stand at TC +4+ (basic says Hit) if playerValue == 15 && !isSoft && dealerValue == 10 { if tc >= 4 { return String(localized: "Stand (Count: 15v10 at TC≥+4)") } } // 12 vs 2: Stand at TC +3+ (basic says Hit) if playerValue == 12 && !isSoft && dealerValue == 2 { if tc >= 3 { return String(localized: "Stand (Count: 12v2 at TC≥+3)") } } // 12 vs 3: Stand at TC +2+ (basic says Hit) if playerValue == 12 && !isSoft && dealerValue == 3 { if tc >= 2 { return String(localized: "Stand (Count: 12v3 at TC≥+2)") } } // 12 vs 4: Hit at TC < 0 (basic says Stand) if playerValue == 12 && !isSoft && dealerValue == 4 { if tc < 0 { return String(localized: "Hit (Count: 12v4 at TC<0)") } } // 13 vs 2: Hit at TC < -1 (basic says Stand) if playerValue == 13 && !isSoft && dealerValue == 2 { if tc < -1 { return String(localized: "Hit (Count: 13v2 at TC<-1)") } } // 16 vs 9: Stand at TC +5+ (basic says Hit) if playerValue == 16 && !isSoft && dealerValue == 9 { if tc >= 5 { return String(localized: "Stand (Count: 16v9 at TC≥+5)") } } // 10 vs 10: Double at TC +4+ (basic says Hit) if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 { if tc >= 4 { return String(localized: "Double (Count: 10v10 at TC≥+4)") } } // 10 vs A: Double at TC +4+ (basic says Hit) if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 { if tc >= 4 { return String(localized: "Double (Count: 10vA at TC≥+4)") } } // 9 vs 2: Double at TC +1+ (basic says Hit) if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 { if tc >= 1 { return String(localized: "Double (Count: 9v2 at TC≥+1)") } } // 9 vs 7: Double at TC +3+ (basic says Hit) if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 { if tc >= 3 { return String(localized: "Double (Count: 9v7 at TC≥+3)") } } // Pair of 10s vs 5: Split at TC +5+ (basic says Stand) if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 { if tc >= 5 { return String(localized: "Split (Count: 10,10v5 at TC≥+5)") } } // Pair of 10s vs 6: Split at TC +4+ (basic says Stand) if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 6 { if tc >= 4 { return String(localized: "Split (Count: 10,10v6 at TC≥+4)") } } // No deviation applies, return basic strategy return basicHint } }