504 lines
18 KiB
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
|
|
}
|
|
}
|
|
|