Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-17 21:40:43 -06:00
parent 13010a3131
commit e455a5bda8
11 changed files with 1040 additions and 42 deletions

View File

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

View File

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

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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" : {

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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.")
]
)
]

View File

@ -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"),

View File

@ -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