Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
13010a3131
commit
e455a5bda8
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user