diff --git a/Baccarat/Baccarat/Engine/RoadMapBuilder.swift b/Baccarat/Baccarat/Engine/RoadMapBuilder.swift new file mode 100644 index 0000000..b918f9e --- /dev/null +++ b/Baccarat/Baccarat/Engine/RoadMapBuilder.swift @@ -0,0 +1,282 @@ +// +// RoadMapBuilder.swift +// Baccarat +// +// Builds road map data structures from round results. +// Implements Big Road layout and derived road algorithms (Big Eye Boy, Small Road, Cockroach Pig). +// + +import Foundation + +// MARK: - Road Map Builder + +/// Builds road map data from round results. +/// Converts raw round history into structured Big Road and derived road entries. +enum RoadMapBuilder { + + /// Maximum number of rows before dragon tail wrapping. + static let maxRows = 6 + + // MARK: - Big Road + + /// Builds Big Road entries from round results. + /// - Parameter results: The round history to convert. + /// - Returns: An array of BigRoadEntry positioned in the grid. + static func buildBigRoad(from results: [RoundResult]) -> [BigRoadEntry] { + guard !results.isEmpty else { return [] } + + var entries: [BigRoadEntry] = [] + var currentColumn = 0 + var currentRow = 0 + var lastNonTieResult: GameResult? + var pendingTies = 0 + + for result in results { + if result.result == .tie { + // Ties don't create new entries - they attach to the previous result + pendingTies += 1 + + // If we have entries, update the last one with the tie count + if !entries.isEmpty { + let lastIndex = entries.count - 1 + let lastEntry = entries[lastIndex] + entries[lastIndex] = BigRoadEntry( + id: lastEntry.id, + result: lastEntry.result, + column: lastEntry.column, + row: lastEntry.row, + tieCount: lastEntry.tieCount + 1, + isNatural: lastEntry.isNatural, + hasPair: lastEntry.hasPair, + roundResult: lastEntry.roundResult + ) + } + continue + } + + // Determine if we need a new column + if let lastResult = lastNonTieResult { + if result.result != lastResult { + // Winner changed - start new column + currentColumn += 1 + currentRow = 0 + } else { + // Same winner - continue down the column + currentRow += 1 + + // Handle dragon tail (wrap to next column if exceeding max rows) + if currentRow >= maxRows { + currentColumn += 1 + currentRow = maxRows - 1 + } + } + } + + let entry = BigRoadEntry( + result: result.result, + column: currentColumn, + row: currentRow, + tieCount: 0, + isNatural: result.isNatural, + hasPair: result.hasPair, + roundResult: result + ) + + entries.append(entry) + lastNonTieResult = result.result + pendingTies = 0 + } + + return entries + } + + /// Builds Big Road columns from entries for easier pattern analysis. + /// - Parameter entries: The Big Road entries. + /// - Returns: An array of BigRoadColumn containing grouped entries. + static func buildBigRoadColumns(from entries: [BigRoadEntry]) -> [BigRoadColumn] { + guard !entries.isEmpty else { return [] } + + var columns: [BigRoadColumn] = [] + var currentColumnIndex = -1 + + for entry in entries { + if entry.column > currentColumnIndex { + // Start new column(s) - handle gaps from dragon tail + while columns.count <= entry.column { + columns.append(BigRoadColumn(index: columns.count)) + } + currentColumnIndex = entry.column + } + + if entry.column < columns.count { + columns[entry.column].entries.append(entry) + } + } + + return columns + } + + // MARK: - Derived Roads + + /// Builds a derived road (Big Eye Boy, Small Road, or Cockroach Pig) from Big Road columns. + /// - Parameters: + /// - columns: The Big Road columns to analyze. + /// - roadType: The type of derived road to build. + /// - Returns: An array of DerivedRoadEntry for the specified road type. + static func buildDerivedRoad( + from columns: [BigRoadColumn], + roadType: RoadType + ) -> [DerivedRoadEntry] { + guard roadType.isDerived else { return [] } + + let offset = roadType.columnOffset + let startingColumn = roadType.startingColumn + + guard columns.count >= startingColumn else { return [] } + + var entries: [DerivedRoadEntry] = [] + var derivedColumn = 0 + var derivedRow = 0 + + // Process each column starting from the required starting column + for columnIndex in (startingColumn - 1)..= maxRows { + derivedColumn += 1 + derivedRow = maxRows - 1 + } + } + + let entry = DerivedRoadEntry( + isRed: isRed, + column: derivedColumn, + row: derivedRow + ) + entries.append(entry) + } + } + + return entries + } + + // MARK: - Private Helpers + + /// Compares column depths for the first entry in a new Big Road column. + /// Used by derived roads to determine if patterns are repeating. + /// - Parameters: + /// - columns: All Big Road columns. + /// - currentColumn: The current column index being analyzed. + /// - offset: The column offset (1 for Big Eye Boy, 2 for Small Road, 3 for Cockroach Pig). + /// - Returns: `true` (red) if depths match, `false` (blue) if they differ. + private static func compareColumnDepths( + columns: [BigRoadColumn], + currentColumn: Int, + offset: Int + ) -> Bool { + let previousColumn = currentColumn - 1 + let compareColumn = currentColumn - 1 - offset + + guard previousColumn >= 0, compareColumn >= 0 else { + return false // Not enough history - default to blue + } + + guard previousColumn < columns.count, compareColumn < columns.count else { + return false + } + + let previousDepth = columns[previousColumn].depth + let compareDepth = columns[compareColumn].depth + + return previousDepth == compareDepth + } + + /// Compares cells for continuation entries in a Big Road column. + /// Looks at the cell one column left and the cell above that. + /// - Parameters: + /// - columns: All Big Road columns. + /// - currentColumn: The current column index. + /// - currentRow: The current row index. + /// - offset: The column offset for the derived road type. + /// - Returns: `true` (red) if cells match or both empty, `false` (blue) if they differ. + private static func compareCells( + columns: [BigRoadColumn], + currentColumn: Int, + currentRow: Int, + offset: Int + ) -> Bool { + let lookbackColumn = currentColumn - offset + let lookbackRow = currentRow - 1 + + guard lookbackColumn >= 0, lookbackRow >= 0 else { + return false // Not enough history - default to blue + } + + guard lookbackColumn < columns.count else { + return false + } + + let lookbackColumnData = columns[lookbackColumn] + + // Check if cell at (lookbackColumn, currentRow-1) exists + let cellExists = lookbackRow < lookbackColumnData.depth + + // Check if cell at (lookbackColumn, currentRow) exists + let cellBelowExists = currentRow < lookbackColumnData.depth + + // If both exist or both don't exist, return red (pattern) + // If one exists and one doesn't, return blue (break) + return cellExists == cellBelowExists + } + + // MARK: - Convenience + + /// Builds all road maps from round results. + /// - Parameter results: The round history. + /// - Returns: A tuple containing Big Road entries and all derived road entries. + static func buildAllRoads(from results: [RoundResult]) -> ( + bigRoad: [BigRoadEntry], + bigEyeBoy: [DerivedRoadEntry], + smallRoad: [DerivedRoadEntry], + cockroachPig: [DerivedRoadEntry] + ) { + let bigRoad = buildBigRoad(from: results) + let columns = buildBigRoadColumns(from: bigRoad) + + return ( + bigRoad: bigRoad, + bigEyeBoy: buildDerivedRoad(from: columns, roadType: .bigEyeBoy), + smallRoad: buildDerivedRoad(from: columns, roadType: .smallRoad), + cockroachPig: buildDerivedRoad(from: columns, roadType: .cockroachPig) + ) + } +} diff --git a/Baccarat/Baccarat/Models/GameSettings.swift b/Baccarat/Baccarat/Models/GameSettings.swift index f75cb15..0e0626f 100644 --- a/Baccarat/Baccarat/Models/GameSettings.swift +++ b/Baccarat/Baccarat/Models/GameSettings.swift @@ -121,6 +121,14 @@ final class GameSettings: GameSettingsProtocol { /// Whether to show betting hints and recommendations. var showHints: Bool = true + // MARK: - Road Map Settings + + /// The preferred road type to display. + var preferredRoadType: RoadType = .big + + /// Whether to show streak alerts. + var showStreakAlerts: Bool = true + // MARK: - Sound Settings /// Whether sound effects are enabled. @@ -147,6 +155,8 @@ final class GameSettings: GameSettingsProtocol { static let hapticsEnabled = "settings.hapticsEnabled" static let soundVolume = "settings.soundVolume" static let revealStyle = "settings.revealStyle" + static let preferredRoadType = "settings.preferredRoadType" + static let showStreakAlerts = "settings.showStreakAlerts" } // MARK: - iCloud @@ -267,6 +277,15 @@ final class GameSettings: GameSettingsProtocol { let style = RevealStyle(rawValue: rawStyle) { self.revealStyle = style } + + if let rawRoadType = defaults.string(forKey: Keys.preferredRoadType), + let roadType = RoadType(rawValue: rawRoadType) { + self.preferredRoadType = roadType + } + + if defaults.object(forKey: Keys.showStreakAlerts) != nil { + self.showStreakAlerts = defaults.bool(forKey: Keys.showStreakAlerts) + } } /// Loads settings from iCloud. @@ -323,6 +342,15 @@ final class GameSettings: GameSettingsProtocol { let style = RevealStyle(rawValue: rawStyle) { self.revealStyle = style } + + if let rawRoadType = store.string(forKey: Keys.preferredRoadType), + let roadType = RoadType(rawValue: rawRoadType) { + self.preferredRoadType = roadType + } + + if store.object(forKey: Keys.showStreakAlerts) != nil { + self.showStreakAlerts = store.bool(forKey: Keys.showStreakAlerts) + } } /// Saves settings to UserDefaults and iCloud. @@ -341,6 +369,8 @@ final class GameSettings: GameSettingsProtocol { defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled) defaults.set(soundVolume, forKey: Keys.soundVolume) defaults.set(revealStyle.rawValue, forKey: Keys.revealStyle) + defaults.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType) + defaults.set(showStreakAlerts, forKey: Keys.showStreakAlerts) // Also save to iCloud if iCloudAvailable, let store = iCloudStore { @@ -356,6 +386,8 @@ final class GameSettings: GameSettingsProtocol { store.set(hapticsEnabled, forKey: Keys.hapticsEnabled) store.set(Double(soundVolume), forKey: Keys.soundVolume) store.set(revealStyle.rawValue, forKey: Keys.revealStyle) + store.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType) + store.set(showStreakAlerts, forKey: Keys.showStreakAlerts) store.synchronize() } } @@ -392,6 +424,8 @@ final class GameSettings: GameSettingsProtocol { hapticsEnabled = true soundVolume = 1.0 revealStyle = .auto + preferredRoadType = .big + showStreakAlerts = true save() } } diff --git a/Baccarat/Baccarat/Models/RoadMapModels.swift b/Baccarat/Baccarat/Models/RoadMapModels.swift new file mode 100644 index 0000000..62fc73e --- /dev/null +++ b/Baccarat/Baccarat/Models/RoadMapModels.swift @@ -0,0 +1,230 @@ +// +// RoadMapModels.swift +// Baccarat +// +// Models for Baccarat road map displays including Big Road and derived roads. +// + +import Foundation +import SwiftUI + +// MARK: - Road Types + +/// The available road map display types. +enum RoadType: String, CaseIterable, Identifiable, Codable { + case bead = "bead" + case big = "big" + case bigEyeBoy = "bigEyeBoy" + case smallRoad = "smallRoad" + case cockroachPig = "cockroachPig" + + var id: String { rawValue } + + /// Localized display name for the road type. + var displayName: String { + switch self { + case .bead: return String(localized: "Bead Road") + case .big: return String(localized: "Big Road") + case .bigEyeBoy: return String(localized: "Big Eye Boy") + case .smallRoad: return String(localized: "Small Road") + case .cockroachPig: return String(localized: "Cockroach Pig") + } + } + + /// Short description for tooltips. + var shortDescription: String { + switch self { + case .bead: + return String(localized: "Simple grid showing all results in order.") + case .big: + return String(localized: "Shows streaks. New column when winner changes.") + case .bigEyeBoy: + return String(localized: "Compares current streak to previous. Red = repeating pattern.") + case .smallRoad: + return String(localized: "Like Big Eye Boy, but looks 2 columns back.") + case .cockroachPig: + return String(localized: "Finest pattern detection. Looks 3 columns back.") + } + } + + /// Icon for the road type. + var icon: String { + switch self { + case .bead: return "circle.grid.3x3" + case .big: return "chart.bar.xaxis" + case .bigEyeBoy: return "eye.fill" + case .smallRoad: return "road.lanes" + case .cockroachPig: return "ant.fill" + } + } + + /// Whether this is a derived road (Big Eye Boy, Small Road, Cockroach Pig). + var isDerived: Bool { + switch self { + case .bead, .big: return false + case .bigEyeBoy, .smallRoad, .cockroachPig: return true + } + } + + /// The column offset used for derived road calculations. + /// Big Eye Boy compares to 1 column back, Small Road to 2, Cockroach Pig to 3. + var columnOffset: Int { + switch self { + case .bead, .big: return 0 + case .bigEyeBoy: return 1 + case .smallRoad: return 2 + case .cockroachPig: return 3 + } + } + + /// The starting column requirement for derived roads. + /// Big Eye Boy starts after column 2, Small Road after column 3, Cockroach Pig after column 4. + var startingColumn: Int { + switch self { + case .bead, .big: return 1 + case .bigEyeBoy: return 2 + case .smallRoad: return 3 + case .cockroachPig: return 4 + } + } +} + +// MARK: - Big Road Entry + +/// An entry in the Big Road grid. +/// Represents a single result positioned in the column-based streak layout. +struct BigRoadEntry: Identifiable, Equatable { + let id: UUID + + /// The game result (Player wins or Banker wins). + /// Ties are tracked separately via `tieCount`. + let result: GameResult + + /// The column position (0-indexed). + let column: Int + + /// The row position (0-indexed, 0 is top). + let row: Int + + /// Number of ties that occurred after this result. + let tieCount: Int + + /// Whether this result was a natural (8 or 9). + let isNatural: Bool + + /// Whether a pair occurred in this hand. + let hasPair: Bool + + /// The original round result for additional data access. + let roundResult: RoundResult? + + init( + id: UUID = UUID(), + result: GameResult, + column: Int, + row: Int, + tieCount: Int = 0, + isNatural: Bool = false, + hasPair: Bool = false, + roundResult: RoundResult? = nil + ) { + self.id = id + self.result = result + self.column = column + self.row = row + self.tieCount = tieCount + self.isNatural = isNatural + self.hasPair = hasPair + self.roundResult = roundResult + } + + // MARK: - Equatable (custom implementation excluding roundResult) + + static func == (lhs: BigRoadEntry, rhs: BigRoadEntry) -> Bool { + lhs.id == rhs.id && + lhs.result == rhs.result && + lhs.column == rhs.column && + lhs.row == rhs.row && + lhs.tieCount == rhs.tieCount && + lhs.isNatural == rhs.isNatural && + lhs.hasPair == rhs.hasPair + } + + /// The color for this entry based on the result. + var color: Color { + result.color + } + + /// Label for display (P or B). + var label: String { + switch result { + case .playerWins: return "P" + case .bankerWins: return "B" + case .tie: return "T" + } + } +} + +// MARK: - Derived Road Entry + +/// An entry in a derived road (Big Eye Boy, Small Road, or Cockroach Pig). +/// Uses hollow circles: red indicates pattern repeating, blue indicates pattern breaking. +struct DerivedRoadEntry: Identifiable, Equatable { + let id: UUID + + /// Whether this entry is red (pattern repeating) or blue (pattern breaking). + let isRed: Bool + + /// The column position (0-indexed). + let column: Int + + /// The row position (0-indexed, 0 is top). + let row: Int + + init( + id: UUID = UUID(), + isRed: Bool, + column: Int, + row: Int + ) { + self.id = id + self.isRed = isRed + self.column = column + self.row = row + } + + /// The color for this entry. + var color: Color { + isRed ? .red : .blue + } +} + +// MARK: - Big Road Column + +/// A column in the Big Road, containing multiple entries. +/// Used internally by the road map builder for layout calculations. +struct BigRoadColumn: Identifiable, Equatable { + let id: UUID + + /// The column index (0-indexed). + let index: Int + + /// The entries in this column (top to bottom). + var entries: [BigRoadEntry] + + /// The result type for this column (all entries have the same result). + var result: GameResult? { + entries.first?.result + } + + /// The depth (number of entries) in this column. + var depth: Int { + entries.count + } + + init(id: UUID = UUID(), index: Int, entries: [BigRoadEntry] = []) { + self.id = id + self.index = index + self.entries = entries + } +} diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 68062db..737b7c1 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -69,6 +69,18 @@ } } }, + "%@, %@" : { + "comment" : "A button that selects a specific road type. The label shows the name of the road type and whether it is currently selected.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@" + } + } + } + }, "%@×%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\").", "localizations" : { @@ -353,6 +365,7 @@ }, "+%lld" : { "comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -511,6 +524,13 @@ } } }, + "A new column starts when the winner changes." : { + + }, + "About This Road" : { + "comment" : "The title of the popover that appears when a user taps a road type in the game.", + "isCommentAutoGenerated" : true + }, "Ace: 1 point" : { "comment" : "Card value description for an Ace.", "localizations" : { @@ -652,6 +672,10 @@ } } }, + "Analyzes patterns from the Big Road." : { + "comment" : "Tooltip text for the \"Big Eye Boy\" road type in the road map interface.", + "isCommentAutoGenerated" : true + }, "Animate dealing and flipping" : { "comment" : "Subtitle for card animations toggle.", "localizations" : { @@ -1140,6 +1164,10 @@ } } }, + "Banker win" : { + "comment" : "Accessibility label for a Bead Road cell showing a banker win.", + "isCommentAutoGenerated" : true + }, "Banker Wins" : { "comment" : "Label for the number of banker win rounds in the statistics display.", "localizations" : { @@ -1163,6 +1191,26 @@ } } }, + "Bead Road" : { + "comment" : "Localized display name for the Bead Road road type.", + "isCommentAutoGenerated" : true + }, + "Bead Road: %lld results. Player %lld, Banker %lld, Ties %lld" : { + "comment" : "Accessibility label for the Bead Road view, showing the number of results and the count of each result type.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bead Road: %1$lld results. Player %2$lld, Banker %3$lld, Ties %4$lld" + } + } + } + }, + "Bead Road: No results yet" : { + "comment" : "Accessibility label for the Bead Road view when there are no results yet.", + "isCommentAutoGenerated" : true + }, "Bet on Player, Banker, or Tie" : { "comment" : "Welcome screen feature title for betting options.", "localizations" : { @@ -1232,8 +1280,20 @@ } } }, + "Big Eye Boy" : { + "comment" : "Name of a derived road type that compares the current streak to the previous one.", + "isCommentAutoGenerated" : true + }, + "Big Eye Boy, Small Road, and Cockroach Pig analyze the Big Road." : { + + }, + "Big Road" : { + "comment" : "Localized display name for the \"Big Road\" road type.", + "isCommentAutoGenerated" : true + }, "BIG ROAD" : { "comment" : "Title for the section in the statistics sheet that shows the user's performance on the Big Road.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1255,6 +1315,22 @@ } } }, + "Big Road: %lld results. Player %lld, Banker %lld, Ties %lld" : { + "comment" : "Accessibility label for the Big Road view, showing the number of results and the count of each result type.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Big Road: %1$lld results. Player %2$lld, Banker %3$lld, Ties %4$lld" + } + } + } + }, + "Big Road: No results yet" : { + "comment" : "Accessibility label for the Big Road view when there are no results yet.", + "isCommentAutoGenerated" : true + }, "Blackjack" : { "comment" : "The name of a blackjack game.", "extractionState" : "stale", @@ -1279,6 +1355,10 @@ } } }, + "Blue = Player win, Red = Banker win, Green = Tie." : { + "comment" : "Label for the color coding of road map results.", + "isCommentAutoGenerated" : true + }, "Blue Circle (P): Player won the hand" : { "comment" : "Explains the blue circle icon in the history.", "localizations" : { @@ -1302,6 +1382,13 @@ } } }, + "Blue circle = 'The shoe is NOT doing what it did before'" : { + + }, + "Blue, pattern breaking" : { + "comment" : "An accessibility label for a hollow circle with blue color.", + "isCommentAutoGenerated" : true + }, "BONUS" : { "comment" : "The text displayed in the center of the bonus zone.", "localizations" : { @@ -1443,6 +1530,9 @@ "Cards flip automatically" : { "comment" : "Help text for the \"Auto Reveal\" reveal style.", "isCommentAutoGenerated" : true + }, + "Casinos display these boards — experienced players study them closely." : { + }, "Change table limits and display options" : { "comment" : "Welcome screen feature description for customizing settings.", @@ -1625,6 +1715,29 @@ } } }, + "Cockroach Pig" : { + "comment" : "Name of the road type that shows the finest pattern detection, looking 3 columns back.", + "isCommentAutoGenerated" : true + }, + "Color Meaning" : { + "comment" : "A heading displayed in the color legend of a derived road type popover.", + "isCommentAutoGenerated" : true + }, + "Compares current streak to 2 columns back." : { + "comment" : "Tooltip text for the \"Small Road\" road type in the Road Map selector.", + "isCommentAutoGenerated" : true + }, + "Compares current streak to 3 columns back." : { + + }, + "Compares current streak to previous. Red = repeating pattern." : { + "comment" : "Tooltip description for the Big Eye Boy road type.", + "isCommentAutoGenerated" : true + }, + "Compares current streak to the previous column." : { + "comment" : "Description of a feature in the Road Info Popover related to identifying if the shoe is predictable or random.", + "isCommentAutoGenerated" : true + }, "Customize Settings" : { "comment" : "Welcome screen feature title for settings customization.", "localizations" : { @@ -1738,6 +1851,12 @@ } } } + }, + "Derived Road" : { + + }, + "Derived Roads" : { + }, "DISPLAY" : { "comment" : "Section header for display settings.", @@ -1968,6 +2087,10 @@ } } }, + "Finest pattern detection. Looks 3 columns back." : { + "comment" : "Short description for a road type that compares the current streak to one from three columns ago.", + "isCommentAutoGenerated" : true + }, "Game history" : { "comment" : "The accessibility label for the road map view, describing it as a display of game results.", "localizations" : { @@ -2224,6 +2347,10 @@ } } }, + "Helps identify if the shoe is predictable or random." : { + "comment" : "Explanation of the Big Eye Boy road map feature.", + "isCommentAutoGenerated" : true + }, "HISTORY" : { "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", "localizations" : { @@ -2432,6 +2559,10 @@ } } }, + "Identifies medium-term pattern trends." : { + "comment" : "Description of a road map when the selected road type is the Small Road.", + "isCommentAutoGenerated" : true + }, "If either hand totals 8 or 9 with two cards, it's a 'Natural'." : { "comment" : "Description of the 'Natural' hand in baccarat, explaining when it occurs and its significance.", "localizations" : { @@ -2547,6 +2678,10 @@ } } }, + "Information about %@" : { + "comment" : "The accessibility label for the info button, describing what it does.", + "isCommentAutoGenerated" : true + }, "Last Synced" : { "localizations" : { "en" : { @@ -2636,6 +2771,13 @@ } } } + }, + "Like Big Eye Boy, but looks 2 columns back." : { + "comment" : "Description of a derived road type that looks 2 columns back.", + "isCommentAutoGenerated" : true + }, + "Long columns = hot streak. Short columns = choppy shoe." : { + }, "Lost" : { "comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.", @@ -2659,6 +2801,9 @@ } } } + }, + "Lots of red = predictable shoe. Lots of blue = random shoe." : { + }, "Main Bets" : { "comment" : "Title of a rule page in the \"Rules\" help view, describing the main bets available in baccarat.", @@ -2933,6 +3078,10 @@ } } }, + "Not enough data" : { + "comment" : "A message displayed when there is not enough data to show a derived road.", + "isCommentAutoGenerated" : true + }, "Objective" : { "comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.", "localizations" : { @@ -3236,6 +3385,14 @@ } } }, + "Pattern breaking" : { + "comment" : "Text describing the color meaning for a derived road where the pattern is breaking.", + "isCommentAutoGenerated" : true + }, + "Pattern repeating" : { + "comment" : "Text describing the color meaning for a pattern-repeating bead on a derived road.", + "isCommentAutoGenerated" : true + }, "Player" : { "localizations" : { "en" : { @@ -3373,6 +3530,10 @@ } } }, + "Player win" : { + "comment" : "Label for a result where the player wins.", + "isCommentAutoGenerated" : true + }, "Player Wins" : { "comment" : "Label for the \"Player Wins\" stat in the statistics UI.", "localizations" : { @@ -3556,6 +3717,15 @@ } } } + }, + "Red circle = 'The shoe is doing what it did before'" : { + + }, + "Red, pattern repeating" : { + + }, + "Red/Blue = Banker/Player in basic roads." : { + }, "Reset Game" : { "comment" : "A button label that resets the game balance and reshuffles the deck.", @@ -3672,11 +3842,25 @@ } } } + }, + "Results fill top to bottom, then left to right." : { + "comment" : "Description of how the Bead Road displays results.", + "isCommentAutoGenerated" : true + }, + "Results stack vertically when the same side wins." : { + }, "REVEAL STYLE" : { "comment" : "The title of the section in the settings view related to the reveal style.", "isCommentAutoGenerated" : true }, + "Road Maps" : { + + }, + "ROAD MAPS" : { + "comment" : "Title of the \"Road Maps\" section in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "Road maps show game history and trends" : { "comment" : "Welcome screen feature description for pattern tracking.", "localizations" : { @@ -3699,6 +3883,9 @@ } } } + }, + "Road maps track patterns in the shoe to help spot trends." : { + }, "Roulette" : { "comment" : "The name of a roulette game.", @@ -4115,6 +4302,17 @@ } } } + }, + "Shows all results in a simple grid." : { + "comment" : "Explanation point for the Bead Road popover.", + "isCommentAutoGenerated" : true + }, + "Shows streaks. New column when winner changes." : { + "comment" : "Short description for tooltips on the \"Big Road\" road type.", + "isCommentAutoGenerated" : true + }, + "Shows the most subtle pattern variations." : { + }, "Side bet on Player or Banker winning by a margin." : { "comment" : "Title for a side bet where the player bets on which hand wins by a margin (e.g., Banker by 9 points).", @@ -4183,6 +4381,17 @@ } } } + }, + "Similar to Big Eye Boy, but looks further back." : { + "comment" : "Description of the Small Road road type.", + "isCommentAutoGenerated" : true + }, + "Simple grid showing all results in order." : { + "comment" : "Tooltip description for the Bead Road.", + "isCommentAutoGenerated" : true + }, + "Small Road" : { + }, "SOUND & HAPTICS" : { "comment" : "Section header for sound and haptic settings.", @@ -4260,6 +4469,17 @@ } } } + }, + "Starts after the first entry in column 2 of the Big Road." : { + "comment" : "Description of the starting point of the Big Eye Boy road map.", + "isCommentAutoGenerated" : true + }, + "Starts after the first entry in column 3 of the Big Road." : { + "comment" : "Description of the starting point for the Small Road road map.", + "isCommentAutoGenerated" : true + }, + "Starts after the first entry in column 4 of the Big Road." : { + }, "Statistics" : { "localizations" : { @@ -4474,6 +4694,12 @@ "Tap Reveal" : { "comment" : "Name of the icon representing the \"Tap Reveal\" card reveal style.", "isCommentAutoGenerated" : true + }, + "Tap the road selector to switch between different road types." : { + + }, + "The finest pattern detection in Baccarat." : { + }, "The hand closest to 9 wins" : { "comment" : "Welcome screen feature description for betting explanation.", @@ -4544,6 +4770,13 @@ } } }, + "The most important road map in Baccarat." : { + + }, + "The simplest road map to understand." : { + "comment" : "Explanation point for the Bead Road popover, describing its simplicity.", + "isCommentAutoGenerated" : true + }, "There's no skill involved — just enjoy the game!" : { "comment" : "Tip for players on how to play baccarat without needing any skill.", "localizations" : { @@ -4567,6 +4800,13 @@ } } }, + "These are for advanced players — beginners can ignore them!" : { + + }, + "These are for advanced players. Beginners can ignore them!" : { + "comment" : "A tip displayed in the \"About This Road\" popover, explaining that players can ignore certain information if they are not interested.", + "isCommentAutoGenerated" : true + }, "These show how the same pattern works for other games" : { "comment" : "A description below the section of the view that previews icons for other games.", "extractionState" : "stale", @@ -4590,6 +4830,12 @@ } } } + }, + "They compare current patterns to past patterns." : { + + }, + "They don't predict the future, but show what HAS happened." : { + }, "Third Card - Banker" : { "comment" : "Content for a rule page in the \"Rules\" help view, detailing the action of the Banker based on the Player's third card.", @@ -4795,6 +5041,9 @@ } } } + }, + "Ties are shown as a green line across the last circle." : { + }, "Time" : { "comment" : "Label for the duration of a session in the statistics sheet.", diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index 055ca5c..5d21c6b 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -59,6 +59,40 @@ enum Design { static let bettingButtonsContainerHeight: CGFloat = 70 } + // MARK: - Road Map Display + + enum RoadMap { + /// Cell size for road map dots. + static let cellSize: CGFloat = 16 + + /// Larger cell size for Big Road display. + static let bigRoadCellSize: CGFloat = 24 + + /// Sidebar cell size (landscape iPad). + static let sidebarCellSize: CGFloat = 32 + + /// Spacing between cells. + static let cellSpacing: CGFloat = 2 + + /// Maximum rows before dragon tail wrapping. + static let maxRows: Int = 6 + + /// Stroke width for hollow circles in derived roads. + static let hollowStrokeWidth: CGFloat = 2 + + /// Height of the road map container. + static let containerHeight: CGFloat = 180 + + /// Width of the landscape sidebar. + static let sidebarWidth: CGFloat = 240 + + /// Size of tie indicator line. + static let tieIndicatorSize: CGFloat = 8 + + /// Size of pair/natural markers. + static let markerSize: CGFloat = 6 + } + // MARK: - Card Deal Animation enum DealAnimation { diff --git a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift index 5b0a68d..06cbce3 100644 --- a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift +++ b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift @@ -128,6 +128,40 @@ struct RulesHelpView: View { String(localized: "Use History to spot patterns and trends in the shoe.") ] ), + RulePage( + title: String(localized: "Road Maps"), + icon: "chart.bar.xaxis", + content: [ + String(localized: "Road maps track patterns in the shoe to help spot trends."), + String(localized: "Casinos display these boards — experienced players study them closely."), + String(localized: "They don't predict the future, but show what HAS happened."), + String(localized: "Red/Blue = Banker/Player in basic roads."), + String(localized: "Tap the road selector to switch between different road types.") + ] + ), + RulePage( + title: String(localized: "Big Road"), + icon: "circle.grid.3x3", + content: [ + String(localized: "The most important road map in Baccarat."), + String(localized: "Results stack vertically when the same side wins."), + String(localized: "A new column starts when the winner changes."), + String(localized: "Ties are shown as a green line across the last circle."), + String(localized: "Long columns = hot streak. Short columns = choppy shoe.") + ] + ), + RulePage( + title: String(localized: "Derived Roads"), + icon: "waveform.path", + content: [ + String(localized: "Big Eye Boy, Small Road, and Cockroach Pig analyze the Big Road."), + String(localized: "They compare current patterns to past patterns."), + String(localized: "Red circle = 'The shoe is doing what it did before'"), + String(localized: "Blue circle = 'The shoe is NOT doing what it did before'"), + String(localized: "Lots of red = predictable shoe. Lots of blue = random shoe."), + String(localized: "These are for advanced players — beginners can ignore them!") + ] + ), RulePage( title: String(localized: "Strategy Tips"), icon: "lightbulb.fill", diff --git a/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift b/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift index 7a5b41e..e2621d5 100644 --- a/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift +++ b/Baccarat/Baccarat/Views/Sheets/StatisticsSheetView.swift @@ -14,6 +14,7 @@ struct StatisticsSheetView: View { @Environment(\.dismiss) private var dismiss @State private var selectedTab: StatisticsTab = .current @State private var selectedSession: BaccaratSession? + @State private var selectedRoadType: RoadType = .big var body: some View { SheetContainerView( @@ -88,9 +89,11 @@ struct StatisticsSheetView: View { // Session stats sessionStatsSection(session: session) - // Road displays for current session - bigRoadSection - roadMapSection + // Road maps section with selector + roadMapsSection(results: state.roundHistory) + + // Simple history section (horizontal dots) + historySection(results: state.roundHistory) } else { NoActiveSessionView() } @@ -318,28 +321,45 @@ struct StatisticsSheetView: View { } } - // MARK: - Road Sections + // MARK: - Road Maps Section - private var bigRoadSection: some View { - SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") { - if state.roundHistory.isEmpty { + @ViewBuilder + private func roadMapsSection(results: [RoundResult]) -> some View { + SheetSection(title: String(localized: "ROAD MAPS"), icon: "chart.bar.xaxis") { + if results.isEmpty { emptyRoadPlaceholder } else { - BigRoadView(results: state.roundHistory) - .frame(height: Size.bigRoadHeight) + RoadMapContainerView( + results: results, + selectedRoad: $selectedRoadType, + showSelector: true, + cellSize: Size.bigRoadCellSize, + showInfoButton: true + ) + .frame(height: Size.roadMapsHeight) } } } - private var roadMapSection: some View { + private var emptyRoadPlaceholder: some View { + Text(String(localized: "No rounds played yet")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, Design.Spacing.xLarge) + } + + // MARK: - History Section + + @ViewBuilder + private func historySection(results: [RoundResult]) -> some View { SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") { - if state.roundHistory.isEmpty { + if results.isEmpty { emptyRoadPlaceholder } else { - // Horizontal display matching what's shown during gameplay ScrollView(.horizontal) { HStack(spacing: Design.Spacing.xSmall) { - ForEach(state.roundHistory) { result in + ForEach(results) { result in RoadDot( result: result.result, dotSize: Size.roadDotSize, @@ -355,14 +375,6 @@ struct StatisticsSheetView: View { } } - private var emptyRoadPlaceholder: some View { - Text(String(localized: "No rounds played yet")) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, Design.Spacing.xLarge) - } - // MARK: - Helpers private func styleDisplayName(for rawValue: String) -> String { @@ -412,6 +424,7 @@ private struct SessionDetailView: View { @Environment(\.dismiss) private var dismiss @State private var showDeleteConfirmation = false + @State private var selectedRoadType: RoadType = .big var body: some View { SheetContainerView( @@ -499,14 +512,20 @@ private struct SessionDetailView: View { ) } - // Big Road section (Baccarat-specific) + // Road Maps section (Baccarat-specific) if !roundHistory.isEmpty { - SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") { - BigRoadView(results: roundHistory) - .frame(height: Size.bigRoadHeight) + SheetSection(title: String(localized: "ROAD MAPS"), icon: "chart.bar.xaxis") { + RoadMapContainerView( + results: roundHistory, + selectedRoad: $selectedRoadType, + showSelector: true, + cellSize: Size.bigRoadCellSize, + showInfoButton: true + ) + .frame(height: Size.roadMapsHeight) } - // History road section + // Simple history section (horizontal dots) SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") { ScrollView(.horizontal) { HStack(spacing: Design.Spacing.xSmall) { @@ -550,124 +569,8 @@ private struct SessionDetailView: View { } } -// MARK: - Big Road View - -private struct BigRoadView: View { - let results: [RoundResult] - - private let maxRows = 6 - private let cellSize: CGFloat = Size.bigRoadCellSize - - /// Convert results into columns for Big Road display. - private var columns: [[RoundResult]] { - var cols: [[RoundResult]] = [] - var currentCol: [RoundResult] = [] - var lastResult: GameResult? - - for result in results { - let currentResult = result.result - - if currentResult == .tie { - // Ties don't start new columns - if !currentCol.isEmpty { - currentCol.append(result) - } else if !cols.isEmpty { - cols[cols.count - 1].append(result) - } else { - currentCol.append(result) - } - } else if lastResult == nil || currentResult == lastResult { - currentCol.append(result) - lastResult = currentResult - } else { - if !currentCol.isEmpty { - cols.append(currentCol) - } - currentCol = [result] - lastResult = currentResult - } - } - - if !currentCol.isEmpty { - cols.append(currentCol) - } - - return cols - } - - var body: some View { - ScrollView(.horizontal) { - HStack(spacing: Design.Spacing.xxSmall) { - ForEach(Array(columns.enumerated()), id: \.offset) { _, column in - VStack(spacing: Design.Spacing.xxSmall) { - ForEach(Array(column.prefix(maxRows).enumerated()), id: \.offset) { _, result in - BigRoadCell(result: result) - } - - if column.count > maxRows { - Text("+\(column.count - maxRows)") - .font(.system(size: Design.BaseFontSize.xxSmall)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer(minLength: 0) - } - } - } - .padding(Design.Spacing.small) - } - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.black.opacity(Design.Opacity.light)) - ) - .scrollIndicators(.hidden) - } -} - -private struct BigRoadCell: View { - let result: RoundResult - - private let cellSize: CGFloat = Size.bigRoadCellSize - - private var color: Color { - switch result.result { - case .playerWins: return .blue - case .bankerWins: return .red - case .tie: return .green - } - } - - var body: some View { - ZStack { - Circle() - .stroke(color, lineWidth: Design.LineWidth.medium) - .frame(width: cellSize, height: cellSize) - - if result.hasPair { - Circle() - .fill(Color.yellow) - .frame(width: cellSize * 0.25, height: cellSize * 0.25) - .offset(y: cellSize * 0.3) - } - - if result.isNatural { - Circle() - .fill(Color.white) - .frame(width: cellSize * 0.25, height: cellSize * 0.25) - .offset(y: -cellSize * 0.3) - } - - if result.result == .tie { - Rectangle() - .fill(color) - .frame(width: cellSize * 0.8, height: Design.LineWidth.medium) - .rotationEffect(.degrees(-45)) - } - } - } -} - // MARK: - Local Size Constants +// Note: BigRoadView is now a shared component in Views/Table/BigRoadView.swift private enum Size { static let outcomeCircleSize: CGFloat = 48 @@ -678,6 +581,7 @@ private enum Size { static let bigRoadCellSize: CGFloat = 24 static let winIndicatorSize: CGFloat = 24 static let roadDotSize: CGFloat = 28 + static let roadMapsHeight: CGFloat = 220 } // MARK: - Preview diff --git a/Baccarat/Baccarat/Views/Table/BeadRoadView.swift b/Baccarat/Baccarat/Views/Table/BeadRoadView.swift new file mode 100644 index 0000000..417d591 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/BeadRoadView.swift @@ -0,0 +1,228 @@ +// +// BeadRoadView.swift +// Baccarat +// +// The Bead Road (also called "Bead Plate" or "Cube Road") shows all results in a simple grid. +// Results fill top to bottom, then left to right. +// + +import SwiftUI +import CasinoKit + +/// Displays the Bead Road - a simple grid of all results in order. +/// Results fill columns top-to-bottom, then move right. +struct BeadRoadView: View { + /// The round results to display. + let results: [RoundResult] + + /// Cell size for the dots. + var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize + + /// Maximum number of rows before wrapping. + var maxRows: Int = Design.RoadMap.maxRows + + // MARK: - Computed Properties + + /// Results organized into columns. + private var columns: [[RoundResult]] { + guard !results.isEmpty else { return [] } + + var cols: [[RoundResult]] = [] + var currentCol: [RoundResult] = [] + + for result in results { + currentCol.append(result) + if currentCol.count >= maxRows { + cols.append(currentCol) + currentCol = [] + } + } + + // Add remaining results as final column + if !currentCol.isEmpty { + cols.append(currentCol) + } + + return cols + } + + // MARK: - Body + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) { + ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in + columnView(for: column, index: columnIndex) + } + + // Invisible anchor for auto-scroll + Color.clear + .frame(width: 1, height: 1) + .id("end") + } + .padding(Design.Spacing.small) + } + .onChange(of: results.count) { + withAnimation { + proxy.scrollTo("end", anchor: .trailing) + } + } + } + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + // MARK: - Private Views + + @ViewBuilder + private func columnView(for column: [RoundResult], index: Int) -> some View { + VStack(spacing: Design.RoadMap.cellSpacing) { + ForEach(Array(column.enumerated()), id: \.offset) { rowIndex, result in + BeadRoadCell( + result: result, + size: cellSize + ) + .id("\(index)-\(rowIndex)") + } + + // Fill remaining space for consistent column height + if column.count < maxRows { + ForEach(0..<(maxRows - column.count), id: \.self) { _ in + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + if results.isEmpty { + return String(localized: "Bead Road: No results yet") + } + + let playerWins = results.filter { $0.result == .playerWins }.count + let bankerWins = results.filter { $0.result == .bankerWins }.count + let ties = results.filter { $0.result == .tie }.count + + return String(localized: "Bead Road: \(results.count) results. Player \(playerWins), Banker \(bankerWins), Ties \(ties)") + } +} + +// MARK: - Bead Road Cell + +/// A single cell in the Bead Road display. +/// Shows filled circles with P/B/T labels. +struct BeadRoadCell: View { + let result: RoundResult + var size: CGFloat = Design.RoadMap.bigRoadCellSize + + private var color: Color { + result.result.color + } + + private var label: String { + switch result.result { + case .playerWins: return "P" + case .bankerWins: return "B" + case .tie: return "T" + } + } + + private var markerSize: CGFloat { + size * 0.25 + } + + var body: some View { + ZStack { + // Filled circle + Circle() + .fill(color) + .frame(width: size, height: size) + + // Border + Circle() + .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + .frame(width: size, height: size) + + // Label + Text(label) + .font(.system(size: size * 0.45, weight: .bold)) + .foregroundStyle(.white) + + // Pair marker (bottom-left) + if result.hasPair { + Circle() + .fill(Color.yellow) + .frame(width: markerSize, height: markerSize) + .overlay( + Circle() + .strokeBorder(Color.white, lineWidth: Design.LineWidth.thin) + ) + .offset(x: -size * 0.35, y: size * 0.35) + } + + // Natural marker (top-right star) + if result.isNatural { + Image(systemName: "star.fill") + .font(.system(size: markerSize)) + .foregroundStyle(.yellow) + .offset(x: size * 0.35, y: -size * 0.35) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(cellAccessibilityLabel) + } + + private var cellAccessibilityLabel: String { + var label: String + switch result.result { + case .playerWins: label = String(localized: "Player win") + case .bankerWins: label = String(localized: "Banker win") + case .tie: label = String(localized: "Tie") + } + + if result.isNatural { + label += ", natural" + } + if result.hasPair { + label += ", pair" + } + + return label + } +} + +// MARK: - Preview + +#Preview("Bead Road") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + BeadRoadView( + results: [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true), + RoundResult(result: .tie, playerValue: 5, bankerValue: 5), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true), + RoundResult(result: .tie, playerValue: 6, bankerValue: 6), + RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8) + ] + ) + .frame(height: Design.RoadMap.containerHeight) + .padding() + } +} diff --git a/Baccarat/Baccarat/Views/Table/BigRoadView.swift b/Baccarat/Baccarat/Views/Table/BigRoadView.swift new file mode 100644 index 0000000..08f2e89 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/BigRoadView.swift @@ -0,0 +1,209 @@ +// +// BigRoadView.swift +// Baccarat +// +// The Big Road is the primary road map in Baccarat. +// Results stack vertically when the same side wins; a new column starts when the winner changes. +// + +import SwiftUI +import CasinoKit + +/// Displays the Big Road - the primary Baccarat road map. +/// Shows streaks as vertical columns that start a new column when the winner changes. +struct BigRoadView: View { + /// The round results to display. + let results: [RoundResult] + + /// Optional cell size override. + var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize + + /// Whether to show natural and pair markers. + var showMarkers: Bool = true + + // MARK: - Computed Properties + + private var entries: [BigRoadEntry] { + RoadMapBuilder.buildBigRoad(from: results) + } + + private var columns: [BigRoadColumn] { + RoadMapBuilder.buildBigRoadColumns(from: entries) + } + + // MARK: - Body + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) { + ForEach(columns) { column in + columnView(for: column) + } + + // Invisible anchor for auto-scroll + Color.clear + .frame(width: 1, height: 1) + .id("end") + } + .padding(Design.Spacing.small) + } + .onChange(of: results.count) { + withAnimation { + proxy.scrollTo("end", anchor: .trailing) + } + } + } + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + // MARK: - Private Views + + @ViewBuilder + private func columnView(for column: BigRoadColumn) -> some View { + VStack(spacing: Design.RoadMap.cellSpacing) { + ForEach(column.entries) { entry in + BigRoadCell( + entry: entry, + size: cellSize, + showMarkers: showMarkers + ) + } + + // Fill remaining space for consistent column height + if column.entries.count < Design.RoadMap.maxRows { + ForEach(0..<(Design.RoadMap.maxRows - column.entries.count), id: \.self) { _ in + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + if results.isEmpty { + return String(localized: "Big Road: No results yet") + } + + let playerWins = results.filter { $0.result == .playerWins }.count + let bankerWins = results.filter { $0.result == .bankerWins }.count + let ties = results.filter { $0.result == .tie }.count + + return String(localized: "Big Road: \(results.count) results. Player \(playerWins), Banker \(bankerWins), Ties \(ties)") + } +} + +// MARK: - Big Road Cell + +/// A single cell in the Big Road display. +struct BigRoadCell: View { + let entry: BigRoadEntry + var size: CGFloat = Design.RoadMap.bigRoadCellSize + var showMarkers: Bool = true + + private var markerSize: CGFloat { + size * 0.25 + } + + var body: some View { + ZStack { + // Main circle (hollow) + Circle() + .stroke(entry.color, lineWidth: Design.RoadMap.hollowStrokeWidth) + .frame(width: size, height: size) + + // Tie indicator (green diagonal line) + if entry.tieCount > 0 { + tieIndicator + } + + // Pair marker (bottom-left) + if showMarkers && entry.hasPair { + Circle() + .fill(Color.yellow) + .frame(width: markerSize, height: markerSize) + .offset(x: -size * 0.35, y: size * 0.35) + } + + // Natural marker (top-right star) + if showMarkers && entry.isNatural { + Image(systemName: "star.fill") + .font(.system(size: markerSize)) + .foregroundStyle(.yellow) + .offset(x: size * 0.35, y: -size * 0.35) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(cellAccessibilityLabel) + } + + @ViewBuilder + private var tieIndicator: some View { + if entry.tieCount == 1 { + // Single tie: diagonal green line + Path { path in + path.move(to: CGPoint(x: size * 0.25, y: size * 0.75)) + path.addLine(to: CGPoint(x: size * 0.75, y: size * 0.25)) + } + .stroke(Color.green, lineWidth: Design.LineWidth.medium) + .frame(width: size, height: size) + } else { + // Multiple ties: show count + Text("\(entry.tieCount)") + .font(.system(size: size * 0.4, weight: .bold)) + .foregroundStyle(.green) + } + } + + private var cellAccessibilityLabel: String { + var label = entry.result == .playerWins ? + String(localized: "Player win") : String(localized: "Banker win") + + if entry.tieCount > 0 { + label += ", \(entry.tieCount) \(entry.tieCount == 1 ? "tie" : "ties")" + } + if entry.isNatural { + label += ", natural" + } + if entry.hasPair { + label += ", pair" + } + + return label + } +} + +// MARK: - Preview + +#Preview("Big Road") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + BigRoadView( + results: [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true), + RoundResult(result: .tie, playerValue: 5, bankerValue: 5), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true), + RoundResult(result: .tie, playerValue: 6, bankerValue: 6), + RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8) + ] + ) + .frame(height: Design.RoadMap.containerHeight) + .padding() + } +} diff --git a/Baccarat/Baccarat/Views/Table/DerivedRoadView.swift b/Baccarat/Baccarat/Views/Table/DerivedRoadView.swift new file mode 100644 index 0000000..8ad469c --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/DerivedRoadView.swift @@ -0,0 +1,258 @@ +// +// DerivedRoadView.swift +// Baccarat +// +// Displays derived roads: Big Eye Boy, Small Road, and Cockroach Pig. +// These roads use hollow circles: red = pattern repeating, blue = pattern breaking. +// + +import SwiftUI +import CasinoKit + +/// Displays a derived road (Big Eye Boy, Small Road, or Cockroach Pig). +/// Uses hollow circles where red indicates pattern repeating and blue indicates pattern breaking. +struct DerivedRoadView: View { + /// The road type being displayed. + let roadType: RoadType + + /// The round results to analyze. + let results: [RoundResult] + + /// Cell size for the circles. + var cellSize: CGFloat = Design.RoadMap.cellSize + + // MARK: - Computed Properties + + private var entries: [DerivedRoadEntry] { + let bigRoad = RoadMapBuilder.buildBigRoad(from: results) + let columns = RoadMapBuilder.buildBigRoadColumns(from: bigRoad) + return RoadMapBuilder.buildDerivedRoad(from: columns, roadType: roadType) + } + + private var columns: [[DerivedRoadEntry]] { + guard !entries.isEmpty else { return [] } + + var cols: [Int: [DerivedRoadEntry]] = [:] + for entry in entries { + cols[entry.column, default: []].append(entry) + } + + // Convert to sorted array + let maxColumn = entries.map(\.column).max() ?? 0 + var result: [[DerivedRoadEntry]] = [] + for col in 0...maxColumn { + result.append(cols[col] ?? []) + } + + return result + } + + // MARK: - Body + + var body: some View { + if entries.isEmpty { + emptyState + } else { + roadContent + } + } + + @ViewBuilder + private var roadContent: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) { + ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in + columnView(for: column, index: columnIndex) + } + + // Invisible anchor for auto-scroll + Color.clear + .frame(width: 1, height: 1) + .id("end") + } + .padding(Design.Spacing.small) + } + .onChange(of: results.count) { + withAnimation { + proxy.scrollTo("end", anchor: .trailing) + } + } + } + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + @ViewBuilder + private var emptyState: some View { + VStack(spacing: Design.Spacing.small) { + Image(systemName: roadType.icon) + .font(.system(size: Design.IconSize.large)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Not enough data")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(roadType.shortDescription) + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.light)) + ) + } + + // MARK: - Private Views + + @ViewBuilder + private func columnView(for column: [DerivedRoadEntry], index: Int) -> some View { + VStack(spacing: Design.RoadMap.cellSpacing) { + ForEach(column) { entry in + DerivedRoadCell( + entry: entry, + size: cellSize + ) + } + + // Fill remaining space for consistent column height + if column.count < Design.RoadMap.maxRows { + ForEach(0..<(Design.RoadMap.maxRows - column.count), id: \.self) { _ in + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + if entries.isEmpty { + return "\(roadType.displayName): Not enough data yet" + } + + let redCount = entries.filter(\.isRed).count + let blueCount = entries.count - redCount + + return "\(roadType.displayName): \(entries.count) entries. \(redCount) red (pattern), \(blueCount) blue (break)" + } +} + +// MARK: - Derived Road Cell + +/// A single cell in a derived road display. +/// Shows hollow circles with red or blue color. +struct DerivedRoadCell: View { + let entry: DerivedRoadEntry + var size: CGFloat = Design.RoadMap.cellSize + + var body: some View { + Circle() + .stroke(entry.color, lineWidth: Design.RoadMap.hollowStrokeWidth) + .frame(width: size, height: size) + .accessibilityElement(children: .ignore) + .accessibilityLabel(entry.isRed ? + String(localized: "Red, pattern repeating") : + String(localized: "Blue, pattern breaking")) + } +} + +// MARK: - Previews + +#Preview("Big Eye Boy") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + VStack(spacing: Design.Spacing.medium) { + Text("Big Eye Boy") + .foregroundStyle(.white) + + DerivedRoadView( + roadType: .bigEyeBoy, + results: sampleResults + ) + .frame(height: Design.RoadMap.containerHeight) + } + .padding() + } +} + +#Preview("Small Road") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + VStack(spacing: Design.Spacing.medium) { + Text("Small Road") + .foregroundStyle(.white) + + DerivedRoadView( + roadType: .smallRoad, + results: sampleResults + ) + .frame(height: Design.RoadMap.containerHeight) + } + .padding() + } +} + +#Preview("Cockroach Pig") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + VStack(spacing: Design.Spacing.medium) { + Text("Cockroach Pig") + .foregroundStyle(.white) + + DerivedRoadView( + roadType: .cockroachPig, + results: sampleResults + ) + .frame(height: Design.RoadMap.containerHeight) + } + .padding() + } +} + +#Preview("Empty State") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + DerivedRoadView( + roadType: .bigEyeBoy, + results: [] + ) + .frame(height: Design.RoadMap.containerHeight) + .padding() + } +} + +// Sample results for previews +private let sampleResults: [RoundResult] = [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4), + RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 7), + RoundResult(result: .playerWins, playerValue: 6, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 5, bankerValue: 4), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 1), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 9) +] diff --git a/Baccarat/Baccarat/Views/Table/RoadMapContainerView.swift b/Baccarat/Baccarat/Views/Table/RoadMapContainerView.swift new file mode 100644 index 0000000..b9b22dc --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/RoadMapContainerView.swift @@ -0,0 +1,348 @@ +// +// RoadMapContainerView.swift +// Baccarat +// +// Container view for displaying road maps with a tabbed interface. +// Allows switching between Bead Road, Big Road, and derived roads. +// + +import SwiftUI +import CasinoKit + +/// Container view for road map displays with road type selection. +struct RoadMapContainerView: View { + /// The round results to display. + let results: [RoundResult] + + /// The currently selected road type. + @Binding var selectedRoad: RoadType + + /// Whether to show the road selector tabs. + var showSelector: Bool = true + + /// Cell size for road displays. + var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize + + /// Whether to show the info button. + var showInfoButton: Bool = true + + // MARK: - State + + @State private var showRoadInfo = false + + // MARK: - Body + + var body: some View { + VStack(spacing: Design.Spacing.xSmall) { + if showSelector { + headerWithSelector + } + + roadContent + } + } + + // MARK: - Header + + @ViewBuilder + private var headerWithSelector: some View { + HStack(spacing: Design.Spacing.small) { + // Road type picker + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Design.Spacing.xSmall) { + ForEach(RoadType.allCases) { roadType in + roadTypeButton(for: roadType) + } + } + } + + Spacer(minLength: 0) + + // Info button + if showInfoButton { + Button { + showRoadInfo = true + } label: { + Image(systemName: "info.circle") + .font(.system(size: Design.IconSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .accessibilityLabel(String(localized: "Information about \(selectedRoad.displayName)")) + } + } + .sheet(isPresented: $showRoadInfo) { + RoadInfoSheet(roadType: selectedRoad) + } + } + + @ViewBuilder + private func roadTypeButton(for roadType: RoadType) -> some View { + Button { + withAnimation(.easeInOut(duration: Design.Animation.quick)) { + selectedRoad = roadType + } + } label: { + HStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: roadType.icon) + .font(.system(size: Design.BaseFontSize.xSmall)) + + Text(roadType.displayName) + .font(.system(size: Design.BaseFontSize.xSmall, weight: .medium)) + } + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xSmall) + .background( + Capsule() + .fill(selectedRoad == roadType ? + Color.white.opacity(Design.Opacity.light) : + Color.clear) + ) + .foregroundStyle(selectedRoad == roadType ? .white : .white.opacity(Design.Opacity.medium)) + } + .buttonStyle(.plain) + .accessibilityLabel("\(roadType.displayName), \(selectedRoad == roadType ? "selected" : "not selected")") + } + + // MARK: - Road Content + + @ViewBuilder + private var roadContent: some View { + switch selectedRoad { + case .bead: + BeadRoadView(results: results, cellSize: cellSize) + + case .big: + BigRoadView(results: results, cellSize: cellSize) + + case .bigEyeBoy, .smallRoad, .cockroachPig: + DerivedRoadView(roadType: selectedRoad, results: results, cellSize: cellSize) + } + } +} + +// MARK: - Road Info Sheet + +/// Sheet view explaining the selected road type with casino-themed styling. +struct RoadInfoSheet: View { + let roadType: RoadType + + @Environment(\.dismiss) private var dismiss + + @ScaledMetric(relativeTo: .title) private var iconSize: CGFloat = Design.BaseFontSize.display + @ScaledMetric(relativeTo: .title) private var titleSize: CGFloat = Design.BaseFontSize.title + @ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = Design.BaseFontSize.body + + var body: some View { + NavigationStack { + ZStack { + Color.Sheet.background + .ignoresSafeArea() + + ScrollView { + VStack(spacing: Design.Spacing.xLarge) { + // Large centered icon (like RulesHelpView) + Image(systemName: roadType.icon) + .font(.system(size: iconSize)) + .foregroundStyle(Color.Sheet.accent) + .padding(.top, Design.Spacing.xxLarge) + + // Title + Text(roadType.displayName) + .font(.system(size: titleSize, weight: .bold)) + .foregroundStyle(.white) + + // Subtitle for derived roads + if roadType.isDerived { + Text(String(localized: "Derived Road")) + .font(.subheadline) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + // Bullet points (styled like RulesHelpView) + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + ForEach(explanationPoints, id: \.self) { point in + HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) { + Text("•") + .foregroundStyle(Color.Sheet.accent) + + Text(point) + .font(.system(size: bodySize)) + .foregroundStyle(.white.opacity(Design.Opacity.heavy)) + } + } + } + .padding(.horizontal, Design.Spacing.xxLarge) + + // Color legend for derived roads + if roadType.isDerived { + colorLegendSection + } + + // Beginner tip for derived roads + if roadType.isDerived { + beginnerTipSection + } + + Spacer() + } + } + } + .navigationTitle(String(localized: "About This Road")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "Done")) { + dismiss() + } + .bold() + .foregroundStyle(Color.Sheet.accent) + } + } + .toolbarBackground(Color.Sheet.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } + + // MARK: - Color Legend Section + + private var colorLegendSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text(String(localized: "Color Meaning")) + .font(.system(size: Design.BaseFontSize.body, weight: .semibold)) + .foregroundStyle(.white) + + HStack(spacing: Design.Spacing.large) { + legendItem(color: .red, text: String(localized: "Pattern repeating")) + legendItem(color: .blue, text: String(localized: "Pattern breaking")) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(Color.Sheet.sectionFill) + ) + .padding(.horizontal, Design.Spacing.large) + } + + // MARK: - Beginner Tip Section + + private var beginnerTipSection: some View { + HStack(alignment: .top, spacing: Design.Spacing.medium) { + Image(systemName: "lightbulb.fill") + .font(.system(size: Design.IconSize.medium)) + .foregroundStyle(Color.Sheet.accent) + + Text(String(localized: "These are for advanced players. Beginners can ignore them!")) + .font(.callout) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(Color.Sheet.accent.opacity(Design.Opacity.hint)) + ) + .padding(.horizontal, Design.Spacing.large) + } + + // MARK: - Helper Views + + @ViewBuilder + private func legendItem(color: Color, text: String) -> some View { + HStack(spacing: Design.Spacing.xSmall) { + Circle() + .stroke(color, lineWidth: Design.RoadMap.hollowStrokeWidth) + .frame(width: Design.RoadMap.cellSize, height: Design.RoadMap.cellSize) + + Text(text) + .font(.caption) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + } + + // MARK: - Data + + private var explanationPoints: [String] { + switch roadType { + case .bead: + return [ + String(localized: "Shows all results in a simple grid."), + String(localized: "Results fill top to bottom, then left to right."), + String(localized: "Blue = Player win, Red = Banker win, Green = Tie."), + String(localized: "The simplest road map to understand.") + ] + + case .big: + return [ + String(localized: "The most important road map in Baccarat."), + String(localized: "Results stack vertically when the same side wins."), + String(localized: "A new column starts when the winner changes."), + String(localized: "Long columns = hot streak. Short columns = choppy shoe."), + String(localized: "Ties are shown as a green line across the last circle.") + ] + + case .bigEyeBoy: + return [ + String(localized: "Analyzes patterns from the Big Road."), + String(localized: "Compares current streak to the previous column."), + String(localized: "Helps identify if the shoe is predictable or random."), + String(localized: "Starts after the first entry in column 2 of the Big Road.") + ] + + case .smallRoad: + return [ + String(localized: "Similar to Big Eye Boy, but looks further back."), + String(localized: "Compares current streak to 2 columns back."), + String(localized: "Identifies medium-term pattern trends."), + String(localized: "Starts after the first entry in column 3 of the Big Road.") + ] + + case .cockroachPig: + return [ + String(localized: "The finest pattern detection in Baccarat."), + String(localized: "Compares current streak to 3 columns back."), + String(localized: "Shows the most subtle pattern variations."), + String(localized: "Starts after the first entry in column 4 of the Big Road.") + ] + } + } +} + +// MARK: - Previews + +#Preview("Road Map Container") { + struct PreviewWrapper: View { + @State private var selectedRoad: RoadType = .big + + var body: some View { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + RoadMapContainerView( + results: [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6), + RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6) + ], + selectedRoad: $selectedRoad + ) + .frame(height: 220) + .padding() + } + } + } + + return PreviewWrapper() +} + +#Preview("Road Info Sheet") { + RoadInfoSheet(roadType: .bigEyeBoy) +} diff --git a/Baccarat/Baccarat/Views/Table/TrendBadgeView.swift b/Baccarat/Baccarat/Views/Table/TrendBadgeView.swift new file mode 100644 index 0000000..6122b25 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/TrendBadgeView.swift @@ -0,0 +1,222 @@ +// +// TrendBadgeView.swift +// Baccarat +// +// Displays streak and trend indicators near betting zones. +// + +import SwiftUI +import CasinoKit + +/// Displays a streak indicator badge. +/// Shows "B×5" or "P×3" format for streaks. +struct TrendBadgeView: View { + /// The current streak type (nil if no streak). + let streakType: GameResult? + + /// The streak count. + let streakCount: Int + + /// Minimum streak to display. + var minimumStreak: Int = 2 + + /// Whether to animate for hot streaks. + var animateHotStreak: Bool = true + + /// Threshold for "hot" streak animation. + var hotStreakThreshold: Int = 4 + + // MARK: - State + + @State private var isPulsing = false + + // MARK: - Scaled Metrics + + @ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.small + @ScaledMetric(relativeTo: .caption) private var badgeHeight: CGFloat = 24 + @ScaledMetric(relativeTo: .caption) private var horizontalPadding: CGFloat = Design.Spacing.small + + // MARK: - Computed Properties + + private var shouldDisplay: Bool { + streakType != nil && streakType != .tie && streakCount >= minimumStreak + } + + private var isHotStreak: Bool { + streakCount >= hotStreakThreshold + } + + private var badgeColor: Color { + guard let type = streakType else { return .clear } + return type == .bankerWins ? .red : .blue + } + + private var badgeText: String { + guard let type = streakType else { return "" } + let letter = type == .bankerWins ? "B" : "P" + return "\(letter)×\(streakCount)" + } + + // MARK: - Body + + var body: some View { + if shouldDisplay { + badgeContent + .onAppear { + if animateHotStreak && isHotStreak { + startPulsing() + } + } + .onChange(of: streakCount) { + if animateHotStreak && isHotStreak { + startPulsing() + } else { + isPulsing = false + } + } + } + } + + @ViewBuilder + private var badgeContent: some View { + HStack(spacing: Design.Spacing.xxSmall) { + // Streak icon + Image(systemName: "flame.fill") + .font(.system(size: fontSize)) + .foregroundStyle(isHotStreak ? .yellow : .white.opacity(Design.Opacity.strong)) + + // Streak text + Text(badgeText) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + .padding(.horizontal, horizontalPadding) + .frame(height: badgeHeight) + .background( + Capsule() + .fill(badgeColor.opacity(isHotStreak ? 0.9 : 0.7)) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + ) + .scaleEffect(isPulsing ? 1.05 : 1.0) + .shadow(color: isHotStreak ? badgeColor.opacity(0.5) : .clear, radius: isPulsing ? 8 : 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + // MARK: - Animation + + private func startPulsing() { + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + guard let type = streakType else { return "" } + let sideName = type == .bankerWins ? + String(localized: "Banker") : String(localized: "Player") + return String(localized: "\(sideName) streak of \(streakCount)") + } +} + +// MARK: - Compact Trend Summary + +/// A compact trend summary for the top bar. +struct CompactTrendView: View { + /// The current streak type (nil if no streak). + let streakType: GameResult? + + /// The streak count. + let streakCount: Int + + /// Minimum streak to display. + var minimumStreak: Int = 2 + + @ScaledMetric(relativeTo: .caption2) private var fontSize: CGFloat = Design.BaseFontSize.xSmall + + private var shouldDisplay: Bool { + streakType != nil && streakType != .tie && streakCount >= minimumStreak + } + + private var textColor: Color { + guard let type = streakType else { return .white } + return type == .bankerWins ? .red : .blue + } + + private var displayText: String { + guard let type = streakType else { return "" } + let letter = type == .bankerWins ? "B" : "P" + return "\(letter)×\(streakCount)" + } + + var body: some View { + if shouldDisplay { + Text(displayText) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(textColor) + .accessibilityLabel(accessibilityLabel) + } + } + + private var accessibilityLabel: String { + guard let type = streakType else { return "" } + let sideName = type == .bankerWins ? + String(localized: "Banker") : String(localized: "Player") + return String(localized: "\(sideName) streak of \(streakCount)") + } +} + +// MARK: - Previews + +#Preview("Trend Badge - Banker Streak") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + VStack(spacing: Design.Spacing.large) { + TrendBadgeView( + streakType: .bankerWins, + streakCount: 3 + ) + + TrendBadgeView( + streakType: .bankerWins, + streakCount: 5 + ) + + TrendBadgeView( + streakType: .playerWins, + streakCount: 4 + ) + + TrendBadgeView( + streakType: .playerWins, + streakCount: 7 + ) + } + } +} + +#Preview("Compact Trend") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + HStack(spacing: Design.Spacing.large) { + CompactTrendView( + streakType: .bankerWins, + streakCount: 4 + ) + + CompactTrendView( + streakType: .playerWins, + streakCount: 3 + ) + } + } +}