CasinoGames/Blackjack/Engine/BlackjackEngine.swift

504 lines
18 KiB
Swift

//
// 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
}
}