From e455a5bda87e691792c26014da852e7a2537b0a3 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 17 Dec 2025 21:40:43 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Blackjack/Engine/BlackjackEngine.swift | 335 ++++++++++++-- Blackjack/Engine/GameState.swift | 21 + Blackjack/Models/GameSettings.swift | 5 + Blackjack/Models/Hand.swift | 24 + Blackjack/Resources/Localizable.xcstrings | 410 +++++++++++++++++- Blackjack/Storage/BlackjackGameData.swift | 2 + Blackjack/Views/BlackjackTableView.swift | 142 ++++++ Blackjack/Views/GameTableView.swift | 109 +++++ Blackjack/Views/RulesHelpView.swift | 25 ++ Blackjack/Views/SettingsView.swift | 8 + .../CasinoKit/Theme/CasinoDesign.swift | 1 + 11 files changed, 1040 insertions(+), 42 deletions(-) diff --git a/Blackjack/Engine/BlackjackEngine.swift b/Blackjack/Engine/BlackjackEngine.swift index f078eb8..d6fd41c 100644 --- a/Blackjack/Engine/BlackjackEngine.swift +++ b/Blackjack/Engine/BlackjackEngine.swift @@ -20,6 +20,9 @@ final class BlackjackEngine { /// 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 @@ -30,10 +33,45 @@ final class BlackjackEngine { shoe.cardsRemaining } - /// Whether the shoe needs reshuffling (below 25% remaining). + /// 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 threshold = (52 * deckCount) / 4 - return cardsRemaining < threshold + 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 @@ -42,6 +80,10 @@ final class BlackjackEngine { 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 @@ -50,11 +92,38 @@ final class BlackjackEngine { 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. + /// Deals a single card from the shoe and updates the running count. + /// If the shoe is empty, automatically reshuffles and deals. func dealCard() -> Card? { - shoe.draw() + // 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 @@ -179,70 +248,256 @@ final class BlackjackEngine { // MARK: - Basic Strategy Hint - /// Returns the basic strategy recommendation. + /// 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 - // Pairs + // 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, .eight: + case .ace: + return String(localized: "Split") + case .eight: return String(localized: "Split") case .ten, .jack, .queen, .king: return String(localized: "Stand") case .five: - return canDouble ? String(localized: "Double") : String(localized: "Hit") + // Never split 5s - treat as hard 10 + return (canDouble && dealerValue <= 9) ? String(localized: "Double") : String(localized: "Hit") case .four: - return (dealerValue == 5 || dealerValue == 6) ? String(localized: "Split") : String(localized: "Hit") - case .two, .three, .seven: - return dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit") + // 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: - return dealerValue <= 6 ? String(localized: "Split") : String(localized: "Hit") + // 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: - return (dealerValue == 7 || dealerValue >= 10) ? String(localized: "Stand") : String(localized: "Split") + // 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 + // SOFT HANDS (Ace counted as 11) if isSoft { - if playerValue >= 19 { + switch playerValue { + case 20, 21: // A,9 or A,10 return String(localized: "Stand") - } - if playerValue == 18 { - if dealerValue >= 9 { - return String(localized: "Hit") + 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") } - // Soft 17 or less + } + + // 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 - // Hard hands - if playerValue >= 17 { - return String(localized: "Stand") - } - if playerValue >= 13 && dealerValue <= 6 { - return String(localized: "Stand") - } - if playerValue == 12 && dealerValue >= 4 && dealerValue <= 6 { - return String(localized: "Stand") - } - if playerValue == 11 && canDouble { - return String(localized: "Double") - } - if playerValue == 10 && dealerValue <= 9 && canDouble { - return String(localized: "Double") - } - if playerValue == 9 && dealerValue >= 3 && dealerValue <= 6 && canDouble { - return String(localized: "Double") + // 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)") + } } - return String(localized: "Hit") + // 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 } } diff --git a/Blackjack/Engine/GameState.swift b/Blackjack/Engine/GameState.swift index 4b3c7a1..4b39121 100644 --- a/Blackjack/Engine/GameState.swift +++ b/Blackjack/Engine/GameState.swift @@ -36,6 +36,9 @@ final class GameState { /// Insurance bet amount. var insuranceBet: Int = 0 + /// Whether a reshuffle notification should be shown. + var showReshuffleNotification: Bool = false + // MARK: - Hands /// Player's hands (can have multiple after splits). @@ -265,6 +268,17 @@ final class GameState { func deal() async { guard canDeal else { return } + // Ensure enough cards for a full hand - reshuffle if needed + if !engine.canDealNewHand { + engine.reshuffle() + showReshuffleNotification = true + + Task { + try? await Task.sleep(for: .seconds(2)) + showReshuffleNotification = false + } + } + currentPhase = .dealing playerHands = [BlackjackHand(bet: currentBet)] dealerHand = BlackjackHand() @@ -645,6 +659,13 @@ final class GameState { // Check if shoe needs reshuffling if engine.needsReshuffle { engine.reshuffle() + showReshuffleNotification = true + + // Auto-dismiss after a delay + Task { + try? await Task.sleep(for: .seconds(2)) + showReshuffleNotification = false + } } } diff --git a/Blackjack/Models/GameSettings.swift b/Blackjack/Models/GameSettings.swift index 05e4fda..e812825 100644 --- a/Blackjack/Models/GameSettings.swift +++ b/Blackjack/Models/GameSettings.swift @@ -143,6 +143,9 @@ final class GameSettings { /// Whether to show dealer hints (suggested action). var showHints: Bool = true { didSet { save() } } + /// Whether to show the running card count (Hi-Lo system). + var showCardCount: Bool = false { didSet { save() } } + // MARK: - Sound Settings /// Whether sound effects are enabled. @@ -234,6 +237,7 @@ final class GameSettings { self.showCardsRemaining = data.showCardsRemaining self.showHistory = data.showHistory self.showHints = data.showHints + self.showCardCount = data.showCardCount self.soundEnabled = data.soundEnabled self.hapticsEnabled = data.hapticsEnabled self.soundVolume = data.soundVolume @@ -258,6 +262,7 @@ final class GameSettings { showCardsRemaining: showCardsRemaining, showHistory: showHistory, showHints: showHints, + showCardCount: showCardCount, soundEnabled: soundEnabled, hapticsEnabled: hapticsEnabled, soundVolume: soundVolume diff --git a/Blackjack/Models/Hand.swift b/Blackjack/Models/Hand.swift index a461ae9..4a6d278 100644 --- a/Blackjack/Models/Hand.swift +++ b/Blackjack/Models/Hand.swift @@ -131,5 +131,29 @@ extension Card { case .ten, .jack, .queen, .king: return 10 } } + + /// The Hi-Lo card counting value. + /// Low cards (2-6): +1 (good for player when removed) + /// Neutral (7-9): 0 + /// High cards (10-A): -1 (bad for player when removed) + var hiLoValue: Int { + switch rank { + case .two, .three, .four, .five, .six: + return 1 // Low cards + case .seven, .eight, .nine: + return 0 // Neutral + case .ten, .jack, .queen, .king, .ace: + return -1 // High cards + } + } + + /// Display text for the Hi-Lo count value. + var hiLoDisplayText: String { + switch hiLoValue { + case 1: return "+1" + case -1: return "-1" + default: return "0" + } + } } diff --git a/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Resources/Localizable.xcstrings index a0c9a21..5cce153 100644 --- a/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Resources/Localizable.xcstrings @@ -1,6 +1,10 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + "comment" : "A label displaying the current true count for a card counting practice session. The argument is the true count, formatted to one decimal place.", + "isCommentAutoGenerated" : true + }, "%lld" : { "comment" : "A label displaying the amount wagered in the current hand. The argument is the amount wagered in the current hand.", "isCommentAutoGenerated" : true @@ -39,11 +43,41 @@ "comment" : "A step in the process of exporting app icons.", "isCommentAutoGenerated" : true }, + "+%@" : { + "comment" : "A label showing the true count for card counting. The number is formatted to one decimal place.", + "isCommentAutoGenerated" : true + }, + "+%lld" : { + "comment" : "A label that displays the running count of cards in a Hi-Lo card counting practice session. The text inside the label changes color based on whether the count is positive or negative.", + "isCommentAutoGenerated" : true + }, "1 Deck: Lowest house edge (~0.17%), rare to find." : { }, "2 Decks: Low house edge (~0.35%), common online." : { + }, + "2-6: +1 (low cards favor house)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "2-6: +1 (low cards favor house)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "2-6: +1 (cartas bajas favorecen la casa)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "2-6: +1 (les cartes basses favorisent la maison)" + } + } + } }, "2-10: Face value" : { "comment" : "Description of the card values for cards with values 2 through 10.", @@ -60,12 +94,56 @@ }, "6:5 Blackjack (avoid!): Increases house edge by ~1.4%." : { + }, + "7-9: 0 (neutral cards)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "7-9: 0 (neutral cards)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "7-9: 0 (cartas neutrales)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "7-9: 0 (cartes neutres)" + } + } + } }, "8 decks shuffled together." : { }, "8 Decks: Standard in Atlantic City (~0.55%)." : { + }, + "10-A: -1 (high cards favor player)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "10-A: -1 (high cards favor player)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "10-A: -1 (cartas altas favorecen al jugador)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "10-A: -1 (les cartes hautes favorisent le joueur)" + } + } + } }, "A 'soft' hand has an Ace counting as 11." : { "comment" : "Explanation of how an Ace can be counted as either 1 or 11 in a hand.", @@ -217,7 +295,26 @@ } }, "Basic Strategy" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basic Strategy" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estrategia Básica" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stratégie de Base" + } + } + } }, "Basic strategy suggestions" : { "localizations" : { @@ -267,6 +364,42 @@ } } }, + "Bet 2x minimum" : { + "comment" : "Betting recommendation based on a true count of 1.", + "isCommentAutoGenerated" : true + }, + "Bet 4x minimum" : { + "comment" : "Betting recommendation based on the true count of 4.", + "isCommentAutoGenerated" : true + }, + "Bet 6x minimum" : { + "comment" : "Betting recommendation based on a true count of 3.", + "isCommentAutoGenerated" : true + }, + "Bet 8x minimum" : { + "comment" : "Betting recommendation based on the true count of 4.", + "isCommentAutoGenerated" : true + }, + "Bet maximum!" : { + "comment" : "Betting recommendation to bet the maximum amount when the true count is 5 or higher.", + "isCommentAutoGenerated" : true + }, + "Bet minimum" : { + "comment" : "Betting recommendation to bet the minimum amount.", + "isCommentAutoGenerated" : true + }, + "Bet minimum (neutral)" : { + "comment" : "Betting hint when the true count is 0.", + "isCommentAutoGenerated" : true + }, + "Bet minimum or sit out" : { + "comment" : "Betting recommendation to bet the minimum amount or to sit out when the true count is -2 or lower.", + "isCommentAutoGenerated" : true + }, + "Betting Hint" : { + "comment" : "A label describing the view that shows betting recommendations.", + "isCommentAutoGenerated" : true + }, "BIGGEST SWINGS" : { "localizations" : { "en" : { @@ -454,6 +587,50 @@ } } }, + "Card Count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Count" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conteo de cartas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compte des cartes" + } + } + } + }, + "Card Counting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Counting" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conteo de Cartas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comptage des Cartes" + } + } + } + }, "Card dealing animations" : { "localizations" : { "en" : { @@ -571,6 +748,13 @@ "Costs half your original bet." : { "comment" : "Description of the cost of insurance in the rules help view.", "isCommentAutoGenerated" : true + }, + "Count reset to 0" : { + "comment" : "A description of what happens when the shoe is reshuffled in Blackjack.", + "isCommentAutoGenerated" : true + }, + "Count resets to 0 when the shoe is shuffled." : { + }, "Custom" : { "localizations" : { @@ -791,6 +975,9 @@ } } } + }, + "Decrease bets when the count is negative." : { + }, "DISPLAY" : { "localizations" : { @@ -858,6 +1045,22 @@ } } }, + "Double (Count: 9v2 at TC≥+1)" : { + "comment" : "Explanation of a count-based deviation from the basic strategy, recommending to double down.", + "isCommentAutoGenerated" : true + }, + "Double (Count: 9v7 at TC≥+3)" : { + "comment" : "Strategy recommendation to double down when the player has a value of 9, is soft, has two cards, and the dealer's upcard is 7, with a true count of 3 or higher.", + "isCommentAutoGenerated" : true + }, + "Double (Count: 10v10 at TC≥+4)" : { + "comment" : "Explanation of a recommended Blackjack move based on a count-adjusted strategy, specifically for a pair of 10s against a 10.", + "isCommentAutoGenerated" : true + }, + "Double (Count: 10vA at TC≥+4)" : { + "comment" : "Text displayed in a notification when a user should double their bet in a Blackjack game.", + "isCommentAutoGenerated" : true + }, "Double After Split" : { "localizations" : { "en" : { @@ -933,6 +1136,9 @@ "Double tap to add chips" : { "comment" : "A hint that appears when a user taps on the betting zone, instructing them to double-tap to add chips.", "isCommentAutoGenerated" : true + }, + "Enable 'Card Count' in Settings to practice." : { + }, "European" : { "localizations" : { @@ -955,6 +1161,9 @@ } } } + }, + "Fewer decks = easier to count accurately." : { + }, "Fewer decks favor the player slightly." : { @@ -1063,8 +1272,49 @@ } } }, + "Hi-Lo is the most popular counting system." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hi-Lo is the most popular counting system." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hi-Lo es el sistema de conteo más popular." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hi-Lo est le système de comptage le plus populaire." + } + } + } + }, "Higher house edge due to no hole card." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Higher house edge due to no hole card." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mayor ventaja de la casa debido a la ausencia de carta oculta." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avantage de la maison plus élevé en raison de l'absence de carte cachée." + } + } + } }, "Hint: %@" : { "localizations" : { @@ -1110,6 +1360,14 @@ } } }, + "Hit (Count: 12v4 at TC<0)" : { + "comment" : "Explanation of a Blackjack hand value and true count that leads to recommending to hit.", + "isCommentAutoGenerated" : true + }, + "Hit (Count: 13v2 at TC<-1)" : { + "comment" : "Explanation of a Blackjack hand value and the recommended action based on the true count.", + "isCommentAutoGenerated" : true + }, "Hit on soft 17 or less." : { }, @@ -1162,6 +1420,9 @@ "If you go over 21, you 'bust' and lose immediately." : { "comment" : "Description of the outcome when a player's hand value exceeds 21.", "isCommentAutoGenerated" : true + }, + "Increase bets when the count is positive." : { + }, "Insurance" : { "localizations" : { @@ -1664,6 +1925,9 @@ }, "Poker" : { + }, + "Positive count = more high cards remain = player advantage." : { + }, "PUSH" : { "localizations" : { @@ -1795,6 +2059,35 @@ } } } + }, + "Running" : { + "comment" : "The text \"Running\" displayed in the card count view.", + "isCommentAutoGenerated" : true + }, + "Running %lld, True %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running %lld, True %@" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual %lld, Verdadero %@" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Courant %lld, Vrai %@" + } + } + } + }, + "Running Count: Sum of all card values seen." : { + }, "SESSION SUMMARY" : { "localizations" : { @@ -1840,6 +2133,18 @@ } } }, + "Shoe reshuffled" : { + "comment" : "A description of the action of reshuffling a shoe.", + "isCommentAutoGenerated" : true + }, + "Shoe Reshuffled" : { + "comment" : "A title for the reshuffle notification.", + "isCommentAutoGenerated" : true + }, + "Shoe reshuffled, count reset to zero" : { + "comment" : "A description of the accessibility label for the reshuffle notification view when the card count is reset.", + "isCommentAutoGenerated" : true + }, "Show Animations" : { "localizations" : { "en" : { @@ -1884,6 +2189,28 @@ } } }, + "Show Hi-Lo running count & card values" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Hi-Lo running count & card values" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar conteo Hi-Lo y valores de cartas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le compte Hi-Lo et les valeurs des cartes" + } + } + } + }, "Show Hints" : { "localizations" : { "en" : { @@ -1980,6 +2307,14 @@ } } }, + "Split (Count: 10,10v5 at TC≥+5)" : { + "comment" : "Description of a strategy recommendation to split a pair of 10s when the true count is greater than or equal to 5.", + "isCommentAutoGenerated" : true + }, + "Split (Count: 10,10v6 at TC≥+4)" : { + "comment" : "Description of a count-based deviation from the basic strategy, specifically for a pair of 10s against a 6.", + "isCommentAutoGenerated" : true + }, "Split Hand" : { "localizations" : { "en" : { @@ -2031,6 +2366,26 @@ } } }, + "Stand (Count: 12v2 at TC≥+3)" : { + "comment" : "Explanation of a count-based deviation from the basic strategy, including the count and the recommended action.", + "isCommentAutoGenerated" : true + }, + "Stand (Count: 12v3 at TC≥+2)" : { + "comment" : "Explanation of a count-adjusted strategy recommendation to stand in a 12-3 situation with a true count of 2.", + "isCommentAutoGenerated" : true + }, + "Stand (Count: 15v10 at TC≥+4)" : { + "comment" : "Explanation of a count-based deviation from the basic strategy.", + "isCommentAutoGenerated" : true + }, + "Stand (Count: 16v9 at TC≥+5)" : { + "comment" : "Explanation of a Blackjack game hint based on a count-adjusted strategy recommendation.", + "isCommentAutoGenerated" : true + }, + "Stand (Count: 16v10 at TC≥0)" : { + "comment" : "Explanation of a count-based deviation from the basic strategy, indicating that the recommended action is to stand.", + "isCommentAutoGenerated" : true + }, "Stand on 17+ always." : { }, @@ -2216,10 +2571,61 @@ }, "Traditional European casino style." : { + }, + "True" : { + "comment" : "A label displayed above the true count of a card counting practice session.", + "isCommentAutoGenerated" : true + }, + "True count of +2 or higher favors the player." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "True count of +2 or higher favors the player." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuenta verdadera de +2 o más favorece al jugador." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un compte vrai de +2 ou plus favorise le joueur." + } + } + } + }, + "True Count: Running count ÷ decks remaining." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "True Count: Running count ÷ decks remaining." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuenta Verdadera: Cuenta actual ÷ mazos restantes." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compte Vrai: Compte courant ÷ paquets restants." + } + } + } }, "Upload the 1024px icon to appicon.co or makeappicon.com to generate all sizes automatically." : { "comment" : "A description of an alternative method for generating app icons.", "isCommentAutoGenerated" : true + }, + "Using the Count" : { + }, "Vegas Strip" : { "localizations" : { diff --git a/Blackjack/Storage/BlackjackGameData.swift b/Blackjack/Storage/BlackjackGameData.swift index 110a6ae..fdf7181 100644 --- a/Blackjack/Storage/BlackjackGameData.swift +++ b/Blackjack/Storage/BlackjackGameData.swift @@ -71,6 +71,7 @@ struct BlackjackSettingsData: PersistableGameData { showCardsRemaining: true, showHistory: true, showHints: true, + showCardCount: false, soundEnabled: true, hapticsEnabled: true, soundVolume: 1.0 @@ -93,6 +94,7 @@ struct BlackjackSettingsData: PersistableGameData { var showCardsRemaining: Bool var showHistory: Bool var showHints: Bool + var showCardCount: Bool var soundEnabled: Bool var hapticsEnabled: Bool var soundVolume: Float diff --git a/Blackjack/Views/BlackjackTableView.swift b/Blackjack/Views/BlackjackTableView.swift index 64a5c41..c6d32b3 100644 --- a/Blackjack/Views/BlackjackTableView.swift +++ b/Blackjack/Views/BlackjackTableView.swift @@ -12,6 +12,9 @@ struct BlackjackTableView: View { @Bindable var state: GameState let onPlaceBet: () -> Void + /// Whether to show Hi-Lo card count values on cards. + var showCardCount: Bool { state.settings.showCardCount } + // MARK: - Scaled Metrics @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium @@ -29,6 +32,7 @@ struct BlackjackTableView: View { DealerHandView( hand: state.dealerHand, showHoleCard: shouldShowDealerHoleCard, + showCardCount: showCardCount, cardWidth: cardWidth, cardSpacing: cardSpacing ) @@ -51,6 +55,7 @@ struct BlackjackTableView: View { hands: state.playerHands, activeHandIndex: state.activeHandIndex, isPlayerTurn: isPlayerTurn, + showCardCount: showCardCount, cardWidth: cardWidth, cardSpacing: cardSpacing ) @@ -64,6 +69,12 @@ struct BlackjackTableView: View { onTap: onPlaceBet ) .transition(.scale.combined(with: .opacity)) + + // Betting hint based on count (only when card counting enabled) + if showCardCount, let bettingHint = bettingHint { + BettingHintView(hint: bettingHint, trueCount: state.engine.trueCount) + .transition(.opacity) + } } // Hint (when enabled and player turn) @@ -98,8 +109,40 @@ struct BlackjackTableView: View { private var currentHint: String? { guard let hand = state.activeHand, let upCard = state.dealerUpCard else { return nil } + + // Use count-adjusted hints when card counting is enabled + if showCardCount { + return state.engine.getCountAdjustedHint(playerHand: hand, dealerUpCard: upCard) + } return state.engine.getHint(playerHand: hand, dealerUpCard: upCard) } + + /// Betting recommendation based on the true count. + private var bettingHint: String? { + let tc = Int(state.engine.trueCount.rounded()) + + // Betting spread recommendations based on true count + 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: - Dealer Hand View @@ -107,6 +150,7 @@ struct BlackjackTableView: View { struct DealerHandView: View { let hand: BlackjackHand let showHoleCard: Bool + let showCardCount: Bool let cardWidth: CGFloat let cardSpacing: CGFloat @@ -144,6 +188,11 @@ struct DealerHandView: View { isFaceUp: isFaceUp, cardWidth: cardWidth ) + .overlay(alignment: .bottomLeading) { + if showCardCount && isFaceUp { + HiLoCountBadge(card: hand.cards[index]) + } + } .zIndex(Double(index)) } @@ -204,6 +253,7 @@ struct PlayerHandsView: View { let hands: [BlackjackHand] let activeHandIndex: Int let isPlayerTurn: Bool + let showCardCount: Bool let cardWidth: CGFloat let cardSpacing: CGFloat @@ -239,6 +289,7 @@ struct PlayerHandsView: View { PlayerHandView( hand: hands[index], isActive: index == activeHandIndex && isPlayerTurn, + showCardCount: showCardCount, // Hand numbers: rightmost is Hand 1, leftmost is Hand 2, etc. handNumber: hands.count > 1 ? hands.count - index : nil, cardWidth: adaptiveCardWidth, @@ -253,6 +304,7 @@ struct PlayerHandsView: View { struct PlayerHandView: View { let hand: BlackjackHand let isActive: Bool + let showCardCount: Bool let handNumber: Int? let cardWidth: CGFloat let cardSpacing: CGFloat @@ -274,6 +326,11 @@ struct PlayerHandView: View { isFaceUp: true, cardWidth: cardWidth ) + .overlay(alignment: .bottomLeading) { + if showCardCount { + HiLoCountBadge(card: hand.cards[index]) + } + } .zIndex(Double(index)) } } @@ -478,6 +535,59 @@ struct InsuranceZoneView: View { } } +// MARK: - Betting Hint View + +/// Shows betting recommendations based on the current count. +struct BettingHintView: View { + let hint: String + let trueCount: Double + + private var hintColor: Color { + let tc = Int(trueCount.rounded()) + if tc >= 2 { + return .green // Player advantage - bet more + } else if tc <= -1 { + return .red // House advantage - bet less + } else { + return .yellow // Neutral + } + } + + private var icon: String { + let tc = Int(trueCount.rounded()) + if tc >= 2 { + return "arrow.up.circle.fill" // Increase bet + } else if tc <= -1 { + return "arrow.down.circle.fill" // Decrease bet + } else { + return "equal.circle.fill" // Neutral + } + } + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: icon) + .foregroundStyle(hintColor) + Text(hint) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + Capsule() + .fill(Color.black.opacity(Design.Opacity.light)) + .overlay( + Capsule() + .strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) + ) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Betting Hint")) + .accessibilityValue(hint) + } +} + // MARK: - Hint View struct HintView: View { @@ -500,6 +610,38 @@ struct HintView: View { } } +// MARK: - Hi-Lo Count Badge + +/// A small badge showing the Hi-Lo counting value of a card. +struct HiLoCountBadge: View { + let card: Card + + var body: some View { + Text(card.hiLoDisplayText) + .font(.system(size: Design.BaseFontSize.xxSmall, weight: .bold, design: .rounded)) + .foregroundStyle(badgeTextColor) + .padding(.horizontal, Design.Spacing.xSmall) + .padding(.vertical, Design.Spacing.xxxSmall) + .background( + Capsule() + .fill(badgeBackgroundColor) + ) + .offset(x: -Design.Spacing.xSmall, y: Design.Spacing.xSmall) + } + + private var badgeBackgroundColor: Color { + switch card.hiLoValue { + case 1: return .green // Low cards = positive for player + case -1: return .red // High cards = negative for player + default: return .gray // Neutral + } + } + + private var badgeTextColor: Color { + .white + } +} + // MARK: - Card Accessibility Extension extension Card { diff --git a/Blackjack/Views/GameTableView.swift b/Blackjack/Views/GameTableView.swift index a516e9f..77009a3 100644 --- a/Blackjack/Views/GameTableView.swift +++ b/Blackjack/Views/GameTableView.swift @@ -86,6 +86,22 @@ struct GameTableView: View { ) .frame(maxWidth: maxContentWidth) + // Card count display (when enabled) + if settings.showCardCount { + CardCountView( + runningCount: state.engine.runningCount, + trueCount: state.engine.trueCount + ) + .frame(maxWidth: maxContentWidth) + } + + // Reshuffle notification + if state.showReshuffleNotification { + ReshuffleNotificationView(showCardCount: settings.showCardCount) + .frame(maxWidth: maxContentWidth) + .transition(.move(edge: .top).combined(with: .opacity)) + } + // Table layout BlackjackTableView( state: state, @@ -337,6 +353,99 @@ struct ActionButton: View { } } +// MARK: - Card Count View + +/// Displays the Hi-Lo running count for card counting practice. +struct CardCountView: View { + let runningCount: Int + let trueCount: Double + + var body: some View { + HStack(spacing: Design.Spacing.large) { + // Running count + VStack(spacing: Design.Spacing.xxSmall) { + Text("Running") + .font(.system(size: Design.BaseFontSize.xSmall, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)") + .font(.system(size: Design.BaseFontSize.large, weight: .bold, design: .monospaced)) + .foregroundStyle(countColor(for: runningCount)) + } + + Divider() + .frame(height: Design.Spacing.xLarge) + .background(Color.white.opacity(Design.Opacity.hint)) + + // True count + VStack(spacing: Design.Spacing.xxSmall) { + Text("True") + .font(.system(size: Design.BaseFontSize.xSmall, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))") + .font(.system(size: Design.BaseFontSize.large, weight: .bold, design: .monospaced)) + .foregroundStyle(countColor(for: Int(trueCount.rounded()))) + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.small) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.subtle)) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Card Count")) + .accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))")) + } + + private func countColor(for count: Int) -> Color { + if count > 0 { + return .green // Positive count favors player + } else if count < 0 { + return .red // Negative count favors house + } else { + return .white // Neutral + } + } +} + +// MARK: - Reshuffle Notification View + +/// Shows a notification when the shoe is reshuffled. +struct ReshuffleNotificationView: View { + let showCardCount: Bool + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "shuffle") + .foregroundStyle(.white) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text("Shoe Reshuffled") + .font(.system(size: Design.BaseFontSize.medium, weight: .bold)) + .foregroundStyle(.white) + + if showCardCount { + Text("Count reset to 0") + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill(Color.blue.opacity(Design.Opacity.heavy)) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(showCardCount + ? String(localized: "Shoe reshuffled, count reset to zero") + : String(localized: "Shoe reshuffled")) + } +} + // MARK: - Preview #Preview { diff --git a/Blackjack/Views/RulesHelpView.swift b/Blackjack/Views/RulesHelpView.swift index 5ebfc79..66e81e8 100644 --- a/Blackjack/Views/RulesHelpView.swift +++ b/Blackjack/Views/RulesHelpView.swift @@ -160,6 +160,31 @@ struct RulesHelpView: View { String(localized: "Hit on soft 17 or less."), String(localized: "Surrender 16 vs dealer 9, 10, Ace.") ] + ), + RulePage( + title: String(localized: "Card Counting"), + icon: "number.circle.fill", + content: [ + String(localized: "Hi-Lo is the most popular counting system."), + String(localized: "2-6: +1 (low cards favor house)"), + String(localized: "7-9: 0 (neutral cards)"), + String(localized: "10-A: -1 (high cards favor player)"), + String(localized: "Running Count: Sum of all card values seen."), + String(localized: "True Count: Running count ÷ decks remaining."), + String(localized: "Positive count = more high cards remain = player advantage.") + ] + ), + RulePage( + title: String(localized: "Using the Count"), + icon: "chart.line.uptrend.xyaxis", + content: [ + String(localized: "True count of +2 or higher favors the player."), + String(localized: "Increase bets when the count is positive."), + String(localized: "Decrease bets when the count is negative."), + String(localized: "Fewer decks = easier to count accurately."), + String(localized: "Count resets to 0 when the shoe is shuffled."), + String(localized: "Enable 'Card Count' in Settings to practice.") + ] ) ] diff --git a/Blackjack/Views/SettingsView.swift b/Blackjack/Views/SettingsView.swift index e241ba7..c600736 100644 --- a/Blackjack/Views/SettingsView.swift +++ b/Blackjack/Views/SettingsView.swift @@ -93,6 +93,14 @@ struct SettingsView: View { Divider().background(Color.white.opacity(Design.Opacity.hint)) + SettingsToggle( + title: String(localized: "Card Count"), + subtitle: String(localized: "Show Hi-Lo running count & card values"), + isOn: $settings.showCardCount + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + SettingsToggle( title: String(localized: "Cards Remaining"), subtitle: String(localized: "Show cards left in shoe"), diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index 11e8894..b059e52 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -13,6 +13,7 @@ public enum CasinoDesign { // MARK: - Spacing public enum Spacing { + public static let xxxSmall: CGFloat = 1 public static let xxSmall: CGFloat = 2 public static let xSmall: CGFloat = 4 public static let small: CGFloat = 8