From 8082556d7ea6663bc38e5188d133cef04988fe17 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 28 Dec 2025 16:54:32 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat/Baccarat/Engine/GameState.swift | 200 ++++++++++++++++++ Baccarat/Baccarat/Models/GameSettings.swift | 15 ++ .../Baccarat/Resources/Localizable.xcstrings | 106 ++++++++++ Baccarat/Baccarat/Theme/DesignConstants.swift | 4 +- .../Baccarat/Views/Game/GameTableView.swift | 24 +++ .../Baccarat/Views/Sheets/SettingsView.swift | 10 + Baccarat/Baccarat/Views/Table/HintViews.swift | 195 +++++++++++++++++ .../Blackjack/Resources/Localizable.xcstrings | 1 + .../Blackjack/Theme/DesignConstants.swift | 2 +- .../Views/Table/BlackjackTableView.swift | 2 +- .../Blackjack/Views/Table/HintViews.swift | 60 ++---- CasinoKit/Sources/CasinoKit/Exports.swift | 4 + .../CasinoKit/Resources/Localizable.xcstrings | 4 + .../CasinoKit/Theme/CasinoDesign.swift | 17 ++ .../CasinoKit/Views/BettingHintView.swift | 171 +++++++++++++++ 15 files changed, 764 insertions(+), 51 deletions(-) create mode 100644 Baccarat/Baccarat/Views/Table/HintViews.swift create mode 100644 CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift diff --git a/Baccarat/Baccarat/Engine/GameState.swift b/Baccarat/Baccarat/Engine/GameState.swift index 6012a26..a168b6a 100644 --- a/Baccarat/Baccarat/Engine/GameState.swift +++ b/Baccarat/Baccarat/Engine/GameState.swift @@ -114,6 +114,206 @@ final class GameState { Array(roundHistory.suffix(20)) } + // MARK: - Hint System + + /// The current streak type (player wins, banker wins, or alternating/none). + var currentStreakInfo: (type: GameResult?, count: Int) { + guard !roundHistory.isEmpty else { return (nil, 0) } + + var streakType: GameResult? + var count = 0 + + // Count backwards from most recent, ignoring ties for streak purposes + for result in roundHistory.reversed() { + // Skip ties when counting streaks + if result.result == .tie { + continue + } + + if streakType == nil { + streakType = result.result + count = 1 + } else if result.result == streakType { + count += 1 + } else { + break + } + } + + return (streakType, count) + } + + /// Distribution of results in the current session. + var resultDistribution: (player: Int, banker: Int, tie: Int) { + let player = roundHistory.filter { $0.result == .playerWins }.count + let banker = roundHistory.filter { $0.result == .bankerWins }.count + let tie = roundHistory.filter { $0.result == .tie }.count + return (player, banker, tie) + } + + /// Whether the game is "choppy" (alternating frequently between Player and Banker). + var isChoppy: Bool { + guard roundHistory.count >= 6 else { return false } + + // Check last 6 non-tie results for alternating pattern + let recentNonTie = roundHistory.suffix(10).filter { $0.result != .tie }.suffix(6) + guard recentNonTie.count >= 6 else { return false } + + var alternations = 0 + var previous: GameResult? + + for result in recentNonTie { + if let prev = previous, prev != result.result { + alternations += 1 + } + previous = result.result + } + + // If 4+ alternations in 6 hands, it's choppy + return alternations >= 4 + } + + /// Hint information including text and style for the shared BettingHintView. + struct HintInfo { + let text: String + let secondaryText: String? + let isStreak: Bool + let isChoppy: Bool + let isBankerHot: Bool + let isPlayerHot: Bool + + /// Determines the style for CasinoKit.BettingHintView. + var style: BettingHintStyle { + if isStreak { return .streak } + if isChoppy { return .pattern } + if isBankerHot { return .custom(.red, "chart.line.uptrend.xyaxis") } + if isPlayerHot { return .custom(.blue, "chart.line.uptrend.xyaxis") } + return .neutral + } + } + + /// Current betting hint for beginners. + /// Returns nil if hints are disabled or no actionable hint is available. + var currentHint: String? { + currentHintInfo?.text + } + + /// Full hint information including style. + var currentHintInfo: HintInfo? { + guard settings.showHints else { return nil } + guard currentPhase == .betting else { return nil } + + // If no history, give the fundamental advice + if roundHistory.isEmpty { + return HintInfo( + text: String(localized: "Banker has the lowest house edge (1.06%)"), + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: false, + isPlayerHot: false + ) + } + + let streak = currentStreakInfo + let dist = resultDistribution + let total = dist.player + dist.banker + dist.tie + + // Calculate percentages if we have enough data + if total >= 5 { + let bankerPct = Double(dist.banker) / Double(total) * 100 + let playerPct = Double(dist.player) / Double(total) * 100 + let trendText = "P: \(Int(playerPct))% | B: \(Int(bankerPct))%" + + // Strong streak (4+): suggest following or note it + if let streakType = streak.type, streak.count >= 4 { + let streakName = streakType == .bankerWins ? + String(localized: "Banker") : String(localized: "Player") + return HintInfo( + text: String(localized: "\(streakName) streak: \(streak.count) in a row"), + secondaryText: trendText, + isStreak: true, + isChoppy: false, + isBankerHot: false, + isPlayerHot: false + ) + } + + // Choppy game: note the pattern + if isChoppy { + return HintInfo( + text: String(localized: "Choppy shoe - results alternating"), + secondaryText: trendText, + isStreak: false, + isChoppy: true, + isBankerHot: false, + isPlayerHot: false + ) + } + + // Significant imbalance (15%+ difference) + if bankerPct > playerPct + 15 { + return HintInfo( + text: String(localized: "Banker running hot (\(Int(bankerPct))%)"), + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: true, + isPlayerHot: false + ) + } else if playerPct > bankerPct + 15 { + return HintInfo( + text: String(localized: "Player running hot (\(Int(playerPct))%)"), + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: false, + isPlayerHot: true + ) + } + } + + // Default hint: remind about odds + return HintInfo( + text: String(localized: "Banker bet has lowest house edge"), + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: false, + isPlayerHot: false + ) + } + + /// Short trend summary for the top bar or compact display. + var trendSummary: String? { + guard settings.showHints else { return nil } + guard !roundHistory.isEmpty else { return nil } + + let streak = currentStreakInfo + if let streakType = streak.type, streak.count >= 2 { + let letter = streakType == .bankerWins ? "B" : "P" + return "\(letter)×\(streak.count)" + } + + return nil + } + + /// Warning message for high house edge bets. + func warningForBet(_ type: BetType) -> String? { + guard settings.showHints else { return nil } + + switch type { + case .tie: + return String(localized: "Tie has 14% house edge") + case .playerPair, .bankerPair: + return String(localized: "Pair bets have ~10% house edge") + case .dragonBonusPlayer, .dragonBonusBanker: + return String(localized: "Dragon Bonus: high risk, high reward") + default: + return nil + } + } + // MARK: - Animation Timing (based on settings) private var dealDelay: Duration { diff --git a/Baccarat/Baccarat/Models/GameSettings.swift b/Baccarat/Baccarat/Models/GameSettings.swift index 1a593d7..cc7ca58 100644 --- a/Baccarat/Baccarat/Models/GameSettings.swift +++ b/Baccarat/Baccarat/Models/GameSettings.swift @@ -133,6 +133,9 @@ final class GameSettings { /// Whether to show the history road map. var showHistory: Bool = true + /// Whether to show betting hints and recommendations. + var showHints: Bool = true + // MARK: - Sound Settings /// Whether sound effects are enabled. @@ -154,6 +157,7 @@ final class GameSettings { static let dealingSpeed = "settings.dealingSpeed" static let showCardsRemaining = "settings.showCardsRemaining" static let showHistory = "settings.showHistory" + static let showHints = "settings.showHints" static let soundEnabled = "settings.soundEnabled" static let hapticsEnabled = "settings.hapticsEnabled" static let soundVolume = "settings.soundVolume" @@ -237,6 +241,10 @@ final class GameSettings { self.showHistory = defaults.bool(forKey: Keys.showHistory) } + if defaults.object(forKey: Keys.showHints) != nil { + self.showHints = defaults.bool(forKey: Keys.showHints) + } + if defaults.object(forKey: Keys.soundEnabled) != nil { self.soundEnabled = defaults.bool(forKey: Keys.soundEnabled) } @@ -284,6 +292,10 @@ final class GameSettings { self.showHistory = iCloudStore.bool(forKey: Keys.showHistory) } + if iCloudStore.object(forKey: Keys.showHints) != nil { + self.showHints = iCloudStore.bool(forKey: Keys.showHints) + } + if iCloudStore.object(forKey: Keys.soundEnabled) != nil { self.soundEnabled = iCloudStore.bool(forKey: Keys.soundEnabled) } @@ -308,6 +320,7 @@ final class GameSettings { defaults.set(dealingSpeed, forKey: Keys.dealingSpeed) defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining) defaults.set(showHistory, forKey: Keys.showHistory) + defaults.set(showHints, forKey: Keys.showHints) defaults.set(soundEnabled, forKey: Keys.soundEnabled) defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled) defaults.set(soundVolume, forKey: Keys.soundVolume) @@ -321,6 +334,7 @@ final class GameSettings { iCloudStore.set(dealingSpeed, forKey: Keys.dealingSpeed) iCloudStore.set(showCardsRemaining, forKey: Keys.showCardsRemaining) iCloudStore.set(showHistory, forKey: Keys.showHistory) + iCloudStore.set(showHints, forKey: Keys.showHints) iCloudStore.set(soundEnabled, forKey: Keys.soundEnabled) iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled) iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume) @@ -337,6 +351,7 @@ final class GameSettings { dealingSpeed = 1.0 showCardsRemaining = true showHistory = true + showHints = true soundEnabled = true hapticsEnabled = true soundVolume = 1.0 diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 1887a14..19e0202 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -23,6 +23,42 @@ } } }, + "%@ streak of %lld" : { + "comment" : "A label describing the type of streak and its count. The first argument is the type of streak (\"Banker\" or \"Player\"). The second argument is the count of the streak.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ streak of %2$lld" + } + } + } + }, + "%@ streak: %lld in a row" : { + "comment" : "Text displayed as a hint in the game UI, providing information about a streak of wins for either the Player or Banker. The argument is the name of the player/banker involved in the streak, and the second argument is the count of consecutive wins.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ streak: %2$lld in a row" + } + } + } + }, + "%@×%lld" : { + "comment" : "A text view displaying the streak count and type (e.g., \"3×Banker\"). The first argument is the streak count. The second argument is the type of streak (\"Banker\" or \"Player\").", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@×%2$lld" + } + } + } + }, "%lld" : { "comment" : "The number of rounds a player has played in the game.", "localizations" : { @@ -685,6 +721,9 @@ } } } + }, + "B: %lld%%" : { + }, "Baccarat has one of the lowest house edges in the casino." : { "comment" : "Description of the house edge of baccarat.", @@ -892,6 +931,10 @@ } } }, + "Banker bet has lowest house edge" : { + "comment" : "Default hint in the game, reminding players about the house edge of the banker bet.", + "isCommentAutoGenerated" : true + }, "Banker bet has the lowest house edge (1.06%)." : { "comment" : "Description of the house edge for the Banker bet in the Rules Help view.", "localizations" : { @@ -961,6 +1004,14 @@ } } }, + "Banker has the lowest house edge (1.06%)" : { + "comment" : "Hint text for beginners about the house edge of the banker bet in a baccarat round.", + "isCommentAutoGenerated" : true + }, + "Banker running hot (%lld%%)" : { + "comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.", + "isCommentAutoGenerated" : true + }, "Bet on which hand will win: Player, Banker, or Tie." : { "comment" : "Text describing the objective of the baccarat game.", "localizations" : { @@ -983,6 +1034,9 @@ } } } + }, + "Betting tips and trend analysis" : { + }, "Blackjack" : { "comment" : "The name of a blackjack game.", @@ -1168,6 +1222,10 @@ } } }, + "Choppy shoe - results alternating" : { + "comment" : "Hint text indicating that the current shoe is \"choppy\", with results alternating between Player and Banker.", + "isCommentAutoGenerated" : true + }, "Clear" : { "comment" : "The label of a button that clears all current bets in the game.", "localizations" : { @@ -1305,6 +1363,7 @@ }, "Dealing..." : { "comment" : "A placeholder text shown while a game is being dealt.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1486,6 +1545,10 @@ } } }, + "Dragon Bonus: high risk, high reward" : { + "comment" : "Warning text for dragon bonus bets, advising players to be cautious.", + "isCommentAutoGenerated" : true + }, "Example: 5♥ + 5♣ = Pair (wins!)" : { "comment" : "Example of a pair bet winning.", "localizations" : { @@ -2514,6 +2577,14 @@ } } }, + "P: %lld%%" : { + "comment" : "Labels indicating the percentage of Player, Banker, and Tie outcomes in a game's result distribution.", + "isCommentAutoGenerated" : true + }, + "Pair bets have ~10% house edge" : { + "comment" : "Warning message for high house edge pair bets.", + "isCommentAutoGenerated" : true + }, "Pair bets have ~10% house edge." : { "comment" : "Description of the house edge of a pair bet in the Rules Help view.", "localizations" : { @@ -2675,6 +2746,18 @@ } } }, + "Player %lld%%, Banker %lld%%, Tie %lld%%" : { + "comment" : "A label describing the percentage of times the player, banker, or tie result occurred in the last spin of the game.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Player %1$lld%%, Banker %2$lld%%, Tie %3$lld%%" + } + } + } + }, "Player Bet: Pays 1:1 (even money)" : { "comment" : "Description of the payout for a Player Bet in the Rules Help view.", "localizations" : { @@ -2721,6 +2804,10 @@ } } }, + "Player running hot (%lld%%)" : { + "comment" : "A hint to place a bet on the Player, given a significant house edge in favor of the Player. The percentage is included as a context hint.", + "isCommentAutoGenerated" : true + }, "Player with 0-5: Draws a third card" : { "comment" : "Description of the action for the Player when their third card is 0-5.", "localizations" : { @@ -2904,6 +2991,10 @@ } } }, + "Result distribution" : { + "comment" : "A label describing the view that shows the distribution of betting results.", + "isCommentAutoGenerated" : true + }, "Roulette" : { "comment" : "The name of a roulette game.", "localizations" : { @@ -3041,6 +3132,9 @@ } } } + }, + "Show Hints" : { + }, "Show History" : { "comment" : "Toggle label for showing game history.", @@ -3247,6 +3341,10 @@ } } }, + "Streak" : { + "comment" : "An accessibility label for the streak badge.", + "isCommentAutoGenerated" : true + }, "Sync Now" : { "localizations" : { "en" : { @@ -3291,6 +3389,10 @@ } } }, + "T: %lld%%" : { + "comment" : "A label indicating that there are ties in the distribution.", + "isCommentAutoGenerated" : true + }, "TABLE LIMITS" : { "comment" : "Section header for table limits settings.", "localizations" : { @@ -3543,6 +3645,10 @@ } } }, + "Tie has 14% house edge" : { + "comment" : "Warning message for a tie bet, explaining the high house edge.", + "isCommentAutoGenerated" : true + }, "TOTAL" : { "comment" : "A label displayed next to the total winnings in the result banner.", "extractionState" : "stale", diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index 4ec3f7f..75ea3a6 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -14,7 +14,7 @@ import CasinoKit enum Design { /// Set to true to show layout debug borders on views - static let showDebugBorders = false + static let showDebugBorders = true // MARK: - Shared Constants (from CasinoKit) @@ -28,6 +28,8 @@ enum Design { typealias MinScaleFactor = CasinoDesign.MinScaleFactor typealias BaseFontSize = CasinoDesign.BaseFontSize typealias IconSize = CasinoDesign.IconSize + typealias Toast = CasinoDesign.Toast + typealias HintSize = CasinoDesign.HintSize // MARK: - Baccarat-Specific Sizes (use CasinoDesign.Size for shared values) diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift index 65a4d52..08d40eb 100644 --- a/Baccarat/Baccarat/Views/Game/GameTableView.swift +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -212,6 +212,18 @@ struct GameTableView: View { .padding(.horizontal, Design.Spacing.medium) .debugBorder(showDebugBorders, color: .blue, label: "BetTable") + // Betting hint (static, below table, above chips) + if let hintInfo = state.currentHintInfo { + BettingHintView( + hint: hintInfo.text, + secondaryInfo: hintInfo.secondaryText, + style: hintInfo.style + ) + .transition(.opacity) + .padding(.vertical, Design.Spacing.small) + .debugBorder(showDebugBorders, color: .purple, label: "Hint") + } + Spacer(minLength: Design.Spacing.xSmall) // Chip selector - full width so all chips are tappable @@ -306,6 +318,18 @@ struct GameTableView: View { .padding(.horizontal, Design.Spacing.medium) .debugBorder(showDebugBorders, color: .blue, label: "BetTable") + // Betting hint (static, below table, above chips) + if let hintInfo = state.currentHintInfo { + BettingHintView( + hint: hintInfo.text, + secondaryInfo: hintInfo.secondaryText, + style: hintInfo.style + ) + .transition(.opacity) + .padding(.vertical, Design.Spacing.small) + .debugBorder(showDebugBorders, color: .purple, label: "Hint") + } + Spacer(minLength: mediumSpacerHeight) .debugBorder(showDebugBorders, color: .yellow, label: "Spacer4") diff --git a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift index 5d775b3..22fbe6b 100644 --- a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift +++ b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift @@ -92,6 +92,16 @@ struct SettingsView: View { isOn: $settings.showHistory, accentColor: accent ) + + Divider() + .background(Color.white.opacity(Design.Opacity.subtle)) + + SettingsToggle( + title: String(localized: "Show Hints"), + subtitle: String(localized: "Betting tips and trend analysis"), + isOn: $settings.showHints, + accentColor: accent + ) } // 5. Sound & Haptics diff --git a/Baccarat/Baccarat/Views/Table/HintViews.swift b/Baccarat/Baccarat/Views/Table/HintViews.swift new file mode 100644 index 0000000..0414758 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/HintViews.swift @@ -0,0 +1,195 @@ +// +// HintViews.swift +// Baccarat +// +// Baccarat-specific hint views and supplementary components. +// The main BettingHintView is provided by CasinoKit. +// + +import SwiftUI +import CasinoKit + +// MARK: - Odds Info View + +/// Shows the house edge information for educational purposes. +struct OddsInfoView: View { + @ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.xSmall + + var body: some View { + HStack(spacing: Design.Spacing.medium) { + oddsItem(label: "Banker", edge: "1.06%", color: .red) + oddsItem(label: "Player", edge: "1.24%", color: .blue) + oddsItem(label: "Tie", edge: "14.4%", color: .green) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xSmall) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + } + + private func oddsItem(label: String, edge: String, color: Color) -> some View { + VStack(spacing: Design.Spacing.xxSmall) { + Text(label) + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(color) + Text(edge) + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + } +} + +// MARK: - Distribution Bar + +/// A visual bar showing the distribution of Player/Banker/Tie results. +struct DistributionBarView: View { + let player: Int + let banker: Int + let tie: Int + + @ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.xSmall + + private var total: Int { player + banker + tie } + + private var playerPct: Double { + total > 0 ? Double(player) / Double(total) : 0.33 + } + + private var bankerPct: Double { + total > 0 ? Double(banker) / Double(total) : 0.33 + } + + private var tiePct: Double { + total > 0 ? Double(tie) / Double(total) : 0.34 + } + + var body: some View { + VStack(spacing: Design.Spacing.xSmall) { + // Bar + GeometryReader { geo in + HStack(spacing: 0) { + Rectangle() + .fill(Color.blue) + .frame(width: geo.size.width * playerPct) + + Rectangle() + .fill(Color.green) + .frame(width: geo.size.width * tiePct) + + Rectangle() + .fill(Color.red) + .frame(width: geo.size.width * bankerPct) + } + .clipShape(Capsule()) + } + .frame(height: Design.Spacing.small) + + // Labels + HStack { + Text("P: \(Int(playerPct * 100))%") + .foregroundStyle(.blue) + + Spacer() + + if tie > 0 { + Text("T: \(Int(tiePct * 100))%") + .foregroundStyle(.green) + + Spacer() + } + + Text("B: \(Int(bankerPct * 100))%") + .foregroundStyle(.red) + } + .font(.system(size: fontSize, weight: .medium, design: .rounded)) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Result distribution")) + .accessibilityValue("Player \(Int(playerPct * 100))%, Banker \(Int(bankerPct * 100))%, Tie \(Int(tiePct * 100))%") + } +} + +// MARK: - Streak Badge + +/// A badge showing the current streak. +struct StreakBadgeView: View { + let type: GameResult + let count: Int + + @ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.body + + private var color: Color { + switch type { + case .bankerWins: return .red + case .playerWins: return .blue + case .tie: return .green + } + } + + private var letter: String { + switch type { + case .bankerWins: return "B" + case .playerWins: return "P" + case .tie: return "T" + } + } + + var body: some View { + HStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "flame.fill") + .font(.system(size: fontSize)) + .foregroundStyle(.orange) + + Text("\(letter)×\(count)") + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(color) + } + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xxSmall) + .background( + Capsule() + .fill(Color.black.opacity(Design.Opacity.heavy)) + .overlay( + Capsule() + .strokeBorder(color.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) + ) + ) + .accessibilityLabel(String(localized: "Streak")) + .accessibilityValue("\(type == .bankerWins ? "Banker" : "Player") streak of \(count)") + } +} + +// MARK: - Previews + +#Preview("Distribution Bar") { + ZStack { + Color.Table.felt.ignoresSafeArea() + DistributionBarView(player: 12, banker: 15, tie: 3) + .frame(maxWidth: 300) + } +} + +#Preview("Streak Badge") { + ZStack { + Color.Table.felt.ignoresSafeArea() + HStack(spacing: Design.Spacing.medium) { + StreakBadgeView(type: .bankerWins, count: 4) + StreakBadgeView(type: .playerWins, count: 3) + } + } +} + +#Preview("Odds Info") { + ZStack { + Color.Table.felt.ignoresSafeArea() + OddsInfoView() + } +} diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index ce690ca..359a6e7 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -1456,6 +1456,7 @@ }, "Betting Hint" : { "comment" : "A label describing the view that shows betting recommendations.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "en" : { diff --git a/Blackjack/Blackjack/Theme/DesignConstants.swift b/Blackjack/Blackjack/Theme/DesignConstants.swift index c87e9ed..f655476 100644 --- a/Blackjack/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Blackjack/Theme/DesignConstants.swift @@ -17,7 +17,7 @@ enum Design { // MARK: - Debug /// Set to true to show layout debug borders on views - static let showDebugBorders = false + static let showDebugBorders = true /// Set to true to show debug log statements static let showDebugLogs = false diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index 96d5f04..618a5d4 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -215,7 +215,7 @@ struct BlackjackTableView: View { // Betting hint based on count (only when card counting enabled) if let hint = state.bettingHint { - BettingHintView(hint: hint, trueCount: state.engine.trueCount) + BlackjackBettingHintView(hint: hint, trueCount: state.engine.trueCount) .transition(.opacity) .padding(.vertical, 10) .debugBorder(showDebugBorders, color: .purple, label: "BetHint") diff --git a/Blackjack/Blackjack/Views/Table/HintViews.swift b/Blackjack/Blackjack/Views/Table/HintViews.swift index 6dfc8be..e8f8d55 100644 --- a/Blackjack/Blackjack/Views/Table/HintViews.swift +++ b/Blackjack/Blackjack/Views/Table/HintViews.swift @@ -48,64 +48,28 @@ struct HintView: View { } } -// MARK: - Betting Hint View +// MARK: - Blackjack Betting Hint View /// Shows betting recommendations based on the current card count. -struct BettingHintView: View { +/// Maps true count to hint style and uses CasinoKit's shared BettingHintView. +struct BlackjackBettingHintView: View { let hint: String let trueCount: Double - @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.hintIconSize - @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.Size.hintFontSize - @ScaledMetric(relativeTo: .body) private var paddingH: CGFloat = Design.Size.hintPaddingH - @ScaledMetric(relativeTo: .body) private var paddingV: CGFloat = Design.Size.hintPaddingV - - private var hintColor: Color { + /// Determines the hint style based on true count. + private var style: BettingHintStyle { let tc = Int(trueCount.rounded()) if tc >= 2 { - return .green // Player advantage - bet more + return .positive // Player advantage - bet more } else if tc <= -1 { - return .red // House advantage - bet less + return .negative // 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 + return .neutral // Neutral } } var body: some View { - HStack(spacing: Design.Spacing.small) { - Image(systemName: icon) - .font(.system(size: iconSize)) - .foregroundStyle(hintColor) - Text(hint) - .font(.system(size: fontSize, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - .lineLimit(1) - .minimumScaleFactor(Design.MinScaleFactor.comfortable) - } - .padding(.horizontal, paddingH) - .padding(.vertical, paddingV) - .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) + BettingHintView(hint: hint, style: style) } } @@ -128,21 +92,21 @@ struct BettingHintView: View { #Preview("Betting Hint - Positive Count") { ZStack { Color.Table.felt.ignoresSafeArea() - BettingHintView(hint: "Bet 4x minimum", trueCount: 2.5) + BlackjackBettingHintView(hint: "Bet 4x minimum", trueCount: 2.5) } } #Preview("Betting Hint - Negative Count") { ZStack { Color.Table.felt.ignoresSafeArea() - BettingHintView(hint: "Bet minimum", trueCount: -1.5) + BlackjackBettingHintView(hint: "Bet minimum", trueCount: -1.5) } } #Preview("Betting Hint - Neutral") { ZStack { Color.Table.felt.ignoresSafeArea() - BettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0) + BlackjackBettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0) } } diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 1960a40..b2823ea 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -41,6 +41,10 @@ // - ValueBadge // - ChipBadge +// MARK: - Hints +// - BettingHintView (shared betting recommendation view) +// - BettingHintStyle (positive, negative, neutral, streak, pattern, custom) + // MARK: - Buttons // - ActionButton, ActionButtonStyle // - BettingActionsView (Clear/Deal button pair for betting phase) diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index f8d3290..39f4e74 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -474,6 +474,10 @@ } } }, + "Betting Hint" : { + "comment" : "The accessibility label for the betting hint view.", + "isCommentAutoGenerated" : true + }, "Card face down" : { "localizations" : { "en" : { diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index e8c3cf3..04284f2 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -229,6 +229,23 @@ public enum CasinoDesign { public enum ChipStack { public static let maxChipsToShow: Int = 5 } + + // MARK: - Toast Configuration + + public enum Toast { + /// Duration toasts stay visible before auto-dismiss. + public static let duration: Duration = .seconds(3) + } + + // MARK: - Hint Sizes + + public enum HintSize { + public static let iconSize: CGFloat = 16 + public static let fontSize: CGFloat = 13 + public static let paddingH: CGFloat = 12 + public static let paddingV: CGFloat = 8 + public static let minWidth: CGFloat = 180 + } } // MARK: - CasinoKit Colors diff --git a/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift b/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift new file mode 100644 index 0000000..255d34b --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift @@ -0,0 +1,171 @@ +// +// BettingHintView.swift +// CasinoKit +// +// A shared betting hint view for displaying recommendations across casino games. +// + +import SwiftUI + +/// Style of the betting hint (affects color and icon). +public enum BettingHintStyle { + /// Positive/favorable hint (green) + case positive + /// Negative/warning hint (red) + case negative + /// Neutral/informational hint (yellow) + case neutral + /// Streak-based hint (orange/flame) + case streak + /// Choppy/alternating pattern (blue) + case pattern + /// Custom color + case custom(Color, String) + + var color: Color { + switch self { + case .positive: return .green + case .negative: return .red + case .neutral: return .yellow + case .streak: return .orange + case .pattern: return .blue + case .custom(let color, _): return color + } + } + + var icon: String { + switch self { + case .positive: return "arrow.up.circle.fill" + case .negative: return "arrow.down.circle.fill" + case .neutral: return "lightbulb.fill" + case .streak: return "flame.fill" + case .pattern: return "arrow.left.arrow.right" + case .custom(_, let icon): return icon + } + } +} + +/// A shared betting hint view for casino games. +/// Shows betting recommendations with consistent styling. +public struct BettingHintView: View { + /// The main hint text. + public let hint: String + + /// Optional secondary info (e.g., trend percentage). + public let secondaryInfo: String? + + /// The style of the hint (determines color and icon). + public let style: BettingHintStyle + + // MARK: - Scaled Metrics + + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.HintSize.iconSize + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.HintSize.fontSize + @ScaledMetric(relativeTo: .body) private var paddingH: CGFloat = CasinoDesign.HintSize.paddingH + @ScaledMetric(relativeTo: .body) private var paddingV: CGFloat = CasinoDesign.HintSize.paddingV + + /// Creates a betting hint view. + /// - Parameters: + /// - hint: The main hint text. + /// - secondaryInfo: Optional secondary info text. + /// - style: The visual style of the hint. + public init(hint: String, secondaryInfo: String? = nil, style: BettingHintStyle = .neutral) { + self.hint = hint + self.secondaryInfo = secondaryInfo + self.style = style + } + + public var body: some View { + HStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: style.icon) + .font(.system(size: iconSize)) + .foregroundStyle(style.color) + + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) { + Text(hint) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(CasinoDesign.MinScaleFactor.comfortable) + + if let secondary = secondaryInfo { + Text(secondary) + .font(.system(size: fontSize - 2, weight: .regular)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + .lineLimit(1) + } + } + } + .padding(.horizontal, paddingH) + .padding(.vertical, paddingV) + .frame(minWidth: CasinoDesign.HintSize.minWidth, alignment: .leading) + .background( + Capsule() + .fill(Color.black.opacity(CasinoDesign.Opacity.heavy)) + .overlay( + Capsule() + .strokeBorder(style.color.opacity(CasinoDesign.Opacity.medium), lineWidth: CasinoDesign.LineWidth.thin) + ) + ) + .shadow(color: .black.opacity(CasinoDesign.Opacity.medium), radius: CasinoDesign.Shadow.radiusMedium) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Betting Hint", bundle: .module)) + .accessibilityValue(hint) + } +} + +// MARK: - Previews + +#Preview("Neutral Hint") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingHintView( + hint: "Banker has the lowest house edge (1.06%)", + style: .neutral + ) + } +} + +#Preview("Positive Hint") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingHintView( + hint: "Bet 4x minimum", + secondaryInfo: "True count: +3", + style: .positive + ) + } +} + +#Preview("Negative Hint") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingHintView( + hint: "Bet minimum", + secondaryInfo: "True count: -2", + style: .negative + ) + } +} + +#Preview("Streak Hint") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingHintView( + hint: "Banker streak: 5 in a row", + secondaryInfo: "B: 58% | P: 38%", + style: .streak + ) + } +} + +#Preview("Pattern Hint") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingHintView( + hint: "Choppy shoe - results alternating", + style: .pattern + ) + } +} +