1173 lines
40 KiB
Swift
1173 lines
40 KiB
Swift
//
|
||
// GameState.swift
|
||
// Baccarat
|
||
//
|
||
// Observable game state managing the flow of a baccarat game.
|
||
//
|
||
|
||
import Foundation
|
||
import SwiftUI
|
||
import CasinoKit
|
||
|
||
/// The current phase of a baccarat round.
|
||
enum GamePhase: Equatable {
|
||
case betting
|
||
case dealingInitial
|
||
case playerThirdCard
|
||
case bankerThirdCard
|
||
case showingResult
|
||
case roundComplete
|
||
}
|
||
|
||
/// Result of an individual bet after a round.
|
||
struct BetResult: Identifiable {
|
||
let id = UUID()
|
||
let type: BetType
|
||
let amount: Int
|
||
let payout: Int // Net winnings (positive) or loss (negative)
|
||
|
||
var isWin: Bool { payout > 0 }
|
||
var isLoss: Bool { payout < 0 }
|
||
var isPush: Bool { payout == 0 }
|
||
|
||
/// Display name for the bet type
|
||
var displayName: String {
|
||
switch type {
|
||
case .player: return "Player"
|
||
case .banker: return "Banker"
|
||
case .tie: return "Tie"
|
||
case .playerPair: return "P Pair"
|
||
case .bankerPair: return "B Pair"
|
||
case .dragonBonusPlayer: return "Dragon P"
|
||
case .dragonBonusBanker: return "Dragon B"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Main observable game state class managing all game logic and UI state.
|
||
/// Conforms to CasinoGameState for shared game behaviors.
|
||
@Observable
|
||
@MainActor
|
||
final class GameState: CasinoGameState {
|
||
// MARK: - CasinoGameState Conformance
|
||
|
||
typealias Stats = BaccaratStats
|
||
typealias GameSettingsType = GameSettings
|
||
|
||
/// The currently active session.
|
||
var currentSession: BaccaratSession?
|
||
|
||
/// History of completed sessions.
|
||
var sessionHistory: [BaccaratSession] = []
|
||
|
||
/// Starting balance for new sessions (from settings).
|
||
var startingBalance: Int { settings.startingBalance }
|
||
|
||
/// Current game style identifier (deck count for Baccarat).
|
||
var currentGameStyle: String { settings.deckCount.displayName }
|
||
|
||
/// Whether a session end has been requested (shows confirmation).
|
||
var showEndSessionConfirmation: Bool = false
|
||
|
||
/// Round histories for completed sessions (keyed by session ID string).
|
||
private var sessionRoundHistories: [String: [SavedRoundResult]] = [:]
|
||
|
||
// MARK: - Settings
|
||
let settings: GameSettings
|
||
|
||
// MARK: - Onboarding
|
||
let onboarding: OnboardingState
|
||
|
||
// MARK: - Sound
|
||
private let sound = SoundManager.shared
|
||
|
||
// MARK: - Persistence
|
||
let persistence: CloudSyncManager<BaccaratGameData>
|
||
|
||
// MARK: - Game Engine
|
||
private(set) var engine: BaccaratEngine
|
||
|
||
// MARK: - Player State
|
||
var balance: Int = 1_000
|
||
var currentBets: [Bet] = []
|
||
|
||
// MARK: - Round State
|
||
var currentPhase: GamePhase = .betting
|
||
var lastResult: GameResult?
|
||
var lastWinnings: Int = 0
|
||
|
||
// MARK: - Bet Results
|
||
var playerHadPair: Bool = false
|
||
var bankerHadPair: Bool = false
|
||
var betResults: [BetResult] = []
|
||
|
||
// MARK: - Card Display State (for animations)
|
||
var visiblePlayerCards: [Card] = []
|
||
var visibleBankerCards: [Card] = []
|
||
var playerCardsFaceUp: [Bool] = []
|
||
var bankerCardsFaceUp: [Bool] = []
|
||
|
||
// MARK: - History
|
||
var roundHistory: [RoundResult] = []
|
||
|
||
// MARK: - Animation Flags
|
||
var isAnimating: Bool = false
|
||
var showResultBanner: Bool = false
|
||
|
||
// MARK: - Reveal State
|
||
/// Whether the game is waiting for a user to reveal a card (Tap or Squeeze style).
|
||
var isWaitingForReveal: Bool = false
|
||
|
||
/// The index of the card currently being revealed (0-3 for initial, 4+ for others).
|
||
var currentRevealIndex: Int = 0
|
||
|
||
/// The progress of a squeeze reveal (0.0 to 1.0).
|
||
var revealProgress: Double = 0.0
|
||
|
||
/// Continuation to resume dealing after a manual reveal.
|
||
private var revealContinuation: CheckedContinuation<Void, Never>?
|
||
|
||
// MARK: - Computed Properties
|
||
|
||
var totalBetAmount: Int {
|
||
currentBets.reduce(0) { $0 + $1.amount }
|
||
}
|
||
|
||
var canPlaceBet: Bool {
|
||
currentPhase == .betting && !isAnimating
|
||
}
|
||
|
||
var canDeal: Bool {
|
||
currentPhase == .betting && hasMainBet && mainBetMeetsMinimum && !isAnimating
|
||
}
|
||
|
||
var playerHandValue: Int {
|
||
// Only calculate value from visible face-up cards
|
||
guard visiblePlayerCards.count == playerCardsFaceUp.count else { return 0 }
|
||
guard playerCardsFaceUp.allSatisfy({ $0 }) else {
|
||
// If not all cards are face up, calculate value from only the face-up cards
|
||
let faceUpCards = zip(visiblePlayerCards, playerCardsFaceUp)
|
||
.filter { $1 }
|
||
.map { $0.0 }
|
||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
||
}
|
||
return engine.playerHand.value
|
||
}
|
||
|
||
var bankerHandValue: Int {
|
||
// Only calculate value from visible face-up cards
|
||
guard visibleBankerCards.count == bankerCardsFaceUp.count else { return 0 }
|
||
guard bankerCardsFaceUp.allSatisfy({ $0 }) else {
|
||
// If not all cards are face up, calculate value from only the face-up cards
|
||
let faceUpCards = zip(visibleBankerCards, bankerCardsFaceUp)
|
||
.filter { $1 }
|
||
.map { $0.0 }
|
||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
||
}
|
||
return engine.bankerHand.value
|
||
}
|
||
|
||
// Recent results for the road display (last 20)
|
||
var recentResults: [RoundResult] {
|
||
Array(roundHistory.suffix(20))
|
||
}
|
||
|
||
/// Whether the game is over (can't afford to meet minimum bet).
|
||
var isGameOver: Bool {
|
||
currentPhase == .betting && balance < settings.minBet
|
||
}
|
||
|
||
// 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 {
|
||
.milliseconds(Int(400 * settings.dealingSpeed))
|
||
}
|
||
|
||
private var flipDelay: Duration {
|
||
.milliseconds(Int(300 * settings.dealingSpeed))
|
||
}
|
||
|
||
private var shortDelay: Duration {
|
||
.milliseconds(Int(200 * settings.dealingSpeed))
|
||
}
|
||
|
||
private var resultDelay: Duration {
|
||
.milliseconds(Int(800 * settings.dealingSpeed))
|
||
}
|
||
|
||
// MARK: - Initialization
|
||
|
||
/// Convenience initializer that constructs default settings on the main actor.
|
||
convenience init() {
|
||
self.init(settings: GameSettings())
|
||
}
|
||
|
||
init(settings: GameSettings) {
|
||
self.settings = settings
|
||
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
||
self.balance = settings.startingBalance
|
||
self.onboarding = OnboardingState(gameIdentifier: "baccarat")
|
||
self.onboarding.registerHintKeys("bettingZone", "dealButton", "firstResult")
|
||
self.persistence = CloudSyncManager<BaccaratGameData>()
|
||
|
||
// Sync sound settings with SoundManager
|
||
syncSoundSettings()
|
||
|
||
// Set up iCloud callback
|
||
persistence.onCloudDataReceived = { [weak self] cloudData in
|
||
self?.handleCloudDataReceived(cloudData)
|
||
}
|
||
|
||
// Load saved game data
|
||
loadSavedGame()
|
||
|
||
// Ensure we have an active session
|
||
ensureActiveSession()
|
||
}
|
||
|
||
/// Handles data received from iCloud (e.g., after fresh install or from another device).
|
||
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
|
||
// Only update if cloud has more progress than current state
|
||
guard cloudData.roundsPlayed > (currentSession?.roundsPlayed ?? 0) + sessionHistory.reduce(0, { $0 + $1.roundsPlayed }) else {
|
||
return
|
||
}
|
||
|
||
// Restore balance and sessions
|
||
self.balance = cloudData.balance
|
||
self.currentSession = cloudData.currentSession
|
||
self.sessionHistory = cloudData.sessionHistory
|
||
self.sessionRoundHistories = cloudData.sessionRoundHistories
|
||
|
||
// Restore round history for road map display
|
||
self.roundHistory = cloudData.currentSessionRoundHistory.compactMap { saved in
|
||
saved.toRoundResult()
|
||
}
|
||
}
|
||
|
||
// MARK: - Persistence
|
||
|
||
/// Loads saved game data from iCloud/local storage.
|
||
private func loadSavedGame() {
|
||
let data = persistence.load()
|
||
self.balance = data.balance
|
||
self.currentSession = data.currentSession
|
||
self.sessionHistory = data.sessionHistory
|
||
self.sessionRoundHistories = data.sessionRoundHistories
|
||
|
||
// Restore round history for road map display
|
||
self.roundHistory = data.currentSessionRoundHistory.compactMap { saved in
|
||
saved.toRoundResult()
|
||
}
|
||
|
||
CasinoDesign.debugLog("📂 Loaded game data:")
|
||
CasinoDesign.debugLog(" - balance: \(data.balance)")
|
||
CasinoDesign.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
|
||
CasinoDesign.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
|
||
CasinoDesign.debugLog(" - roundHistory count: \(roundHistory.count)")
|
||
CasinoDesign.debugLog(" - sessionRoundHistories count: \(sessionRoundHistories.count)")
|
||
if let session = data.currentSession {
|
||
CasinoDesign.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s")
|
||
}
|
||
}
|
||
|
||
/// Saves current game state to iCloud/local storage.
|
||
func saveGameData() {
|
||
// Update current session before saving
|
||
if var session = currentSession {
|
||
session.endingBalance = balance
|
||
// Keep session's game style in sync with current settings
|
||
session.gameStyle = currentGameStyle
|
||
currentSession = session
|
||
|
||
// Always keep the current session's round history in the archive
|
||
// This ensures it's available when the session ends and becomes historical
|
||
if !roundHistory.isEmpty {
|
||
let savedHistory = roundHistory.map { SavedRoundResult(from: $0) }
|
||
sessionRoundHistories[session.id.uuidString] = savedHistory
|
||
}
|
||
}
|
||
|
||
// Convert round history for persistence
|
||
let savedRoundHistory = roundHistory.map { SavedRoundResult(from: $0) }
|
||
|
||
let data = BaccaratGameData(
|
||
lastModified: Date(),
|
||
balance: balance,
|
||
currentSession: currentSession,
|
||
sessionHistory: sessionHistory,
|
||
currentSessionRoundHistory: savedRoundHistory,
|
||
sessionRoundHistories: sessionRoundHistories
|
||
)
|
||
persistence.save(data)
|
||
|
||
CasinoDesign.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0), road history: \(roundHistory.count)")
|
||
}
|
||
|
||
/// Whether iCloud sync is available.
|
||
var iCloudAvailable: Bool {
|
||
persistence.iCloudAvailable
|
||
}
|
||
|
||
/// Whether iCloud sync is enabled.
|
||
var iCloudEnabled: Bool {
|
||
get { persistence.iCloudEnabled }
|
||
set { persistence.iCloudEnabled = newValue }
|
||
}
|
||
|
||
/// Last sync date.
|
||
var lastSyncDate: Date? {
|
||
persistence.lastSyncDate
|
||
}
|
||
|
||
/// Forces a sync with iCloud.
|
||
func syncWithCloud() {
|
||
persistence.sync()
|
||
}
|
||
|
||
/// Syncs sound settings from GameSettings to SoundManager.
|
||
private func syncSoundSettings() {
|
||
sound.soundEnabled = settings.soundEnabled
|
||
sound.hapticsEnabled = settings.hapticsEnabled
|
||
sound.volume = settings.soundVolume
|
||
}
|
||
|
||
// MARK: - Computed Properties for Bets
|
||
|
||
/// Returns the current main bet (Player or Banker), if any.
|
||
var mainBet: Bet? {
|
||
currentBets.first(where: { $0.type == .player || $0.type == .banker })
|
||
}
|
||
|
||
/// Returns the current tie bet, if any.
|
||
var tieBet: Bet? {
|
||
currentBets.first(where: { $0.type == .tie })
|
||
}
|
||
|
||
/// Returns bets for a specific type.
|
||
func bet(for type: BetType) -> Bet? {
|
||
currentBets.first(where: { $0.type == type })
|
||
}
|
||
|
||
/// Whether the player has placed a main bet (required to deal).
|
||
var hasMainBet: Bool {
|
||
mainBet != nil
|
||
}
|
||
|
||
/// Whether the player has any side bets.
|
||
var hasSideBets: Bool {
|
||
currentBets.contains(where: { $0.type.isSideBet })
|
||
}
|
||
|
||
/// Minimum bet for the table.
|
||
var minBet: Int {
|
||
settings.minBet
|
||
}
|
||
|
||
/// Maximum bet for the table.
|
||
var maxBet: Int {
|
||
settings.maxBet
|
||
}
|
||
|
||
/// Returns the current bet amount for a specific bet type.
|
||
func betAmount(for type: BetType) -> Int {
|
||
currentBets.first(where: { $0.type == type })?.amount ?? 0
|
||
}
|
||
|
||
/// Whether the main bet meets the minimum requirement.
|
||
var mainBetMeetsMinimum: Bool {
|
||
guard let bet = mainBet else { return false }
|
||
return bet.amount >= minBet
|
||
}
|
||
|
||
/// Whether the main bet exists but is below the minimum required.
|
||
var isMainBetBelowMinimum: Bool {
|
||
guard let bet = mainBet else { return false }
|
||
return bet.amount < minBet
|
||
}
|
||
|
||
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
|
||
/// Used to determine card layout ordering (betted hand appears on bottom).
|
||
var bettedOnPlayer: Bool? {
|
||
guard let bet = mainBet else { return nil }
|
||
return bet.type == .player
|
||
}
|
||
|
||
/// Amount needed to reach the minimum bet.
|
||
var amountNeededForMinimum: Int {
|
||
guard let bet = mainBet else { return minBet }
|
||
return max(0, minBet - bet.amount)
|
||
}
|
||
|
||
/// Whether a bet type can accept more chips (hasn't hit max).
|
||
func canAddToBet(type: BetType, amount: Int) -> Bool {
|
||
let currentAmount = betAmount(for: type)
|
||
return currentAmount + amount <= maxBet
|
||
}
|
||
|
||
/// The minimum bet level across all bet types that can accept more chips.
|
||
/// Used by chip selector to determine if chips should be enabled.
|
||
/// Returns the smallest bet so chips stay enabled if ANY bet type can accept more.
|
||
var minBetForChipSelector: Int {
|
||
// All bet types are always available in Baccarat
|
||
let allBetTypes: [BetType] = [
|
||
.player, .banker, .tie,
|
||
.playerPair, .bankerPair,
|
||
.dragonBonusPlayer, .dragonBonusBanker
|
||
]
|
||
|
||
// Return the minimum bet amount across all bet types
|
||
// so chips stay enabled if any bet type can accept more
|
||
return allBetTypes.map { betAmount(for: $0) }.min() ?? 0
|
||
}
|
||
|
||
func revealCurrentCard() {
|
||
guard isWaitingForReveal else { return }
|
||
|
||
isWaitingForReveal = false
|
||
revealProgress = 1.0
|
||
|
||
let continuation = revealContinuation
|
||
revealContinuation = nil
|
||
continuation?.resume()
|
||
}
|
||
|
||
/// Updates the progress of a squeeze reveal.
|
||
func updateRevealProgress(_ progress: Double) {
|
||
revealProgress = progress
|
||
if progress >= 1.0 {
|
||
revealCurrentCard()
|
||
}
|
||
}
|
||
|
||
private func waitForReveal(index: Int) async {
|
||
guard settings.showAnimations else { return }
|
||
|
||
let isBottomSlot = index % 2 == 0
|
||
let isInteractiveStyle = settings.revealStyle == .tap || settings.revealStyle == .squeeze
|
||
|
||
if isInteractiveStyle && isBottomSlot {
|
||
// Wait for user interaction only on Bottom (Home) cards
|
||
currentRevealIndex = index
|
||
revealProgress = 0.0
|
||
isWaitingForReveal = true
|
||
|
||
await withCheckedContinuation { continuation in
|
||
revealContinuation = continuation
|
||
}
|
||
} else {
|
||
// Auto-reveal for all cards in .auto style, and Top (Away) cards in interactive styles
|
||
try? await Task.sleep(for: flipDelay)
|
||
}
|
||
}
|
||
// MARK: - Betting Actions
|
||
|
||
/// Places a bet of the specified amount on the given bet type.
|
||
/// Player and Banker are mutually exclusive. Side bets can be added independently.
|
||
/// Enforces min/max table limits.
|
||
func placeBet(type: BetType, amount: Int) {
|
||
guard canPlaceBet, balance >= amount else { return }
|
||
|
||
// Check if adding this bet would exceed max
|
||
let currentAmount = betAmount(for: type)
|
||
guard currentAmount + amount <= maxBet else { return }
|
||
|
||
// Handle mutually exclusive Player/Banker bets
|
||
if type == .player || type == .banker {
|
||
// Remove any existing opposite main bet and refund it
|
||
if let existingMainBet = mainBet, existingMainBet.type != type {
|
||
balance += existingMainBet.amount
|
||
currentBets.removeAll(where: { $0.type == existingMainBet.type })
|
||
}
|
||
}
|
||
|
||
// Check if there's already a bet of this type
|
||
if let index = currentBets.firstIndex(where: { $0.type == type }) {
|
||
// Add to existing bet
|
||
let existingBet = currentBets[index]
|
||
currentBets[index] = Bet(type: type, amount: existingBet.amount + amount)
|
||
} else {
|
||
currentBets.append(Bet(type: type, amount: amount))
|
||
}
|
||
|
||
balance -= amount
|
||
|
||
// Play chip placement sound and haptic
|
||
sound.playChipPlace()
|
||
}
|
||
|
||
/// Clears all current bets and returns the amounts to balance.
|
||
func clearBets() {
|
||
guard canPlaceBet, !currentBets.isEmpty else { return }
|
||
balance += totalBetAmount
|
||
currentBets = []
|
||
|
||
// Play clear bets sound and haptic
|
||
sound.playClearBets()
|
||
}
|
||
|
||
/// Undoes the last bet placed.
|
||
func undoLastBet() {
|
||
guard canPlaceBet, let lastBet = currentBets.last else { return }
|
||
balance += lastBet.amount
|
||
currentBets.removeLast()
|
||
}
|
||
|
||
// MARK: - Game Flow
|
||
|
||
/// Starts a new round by dealing cards with animation.
|
||
func deal() async {
|
||
guard canDeal else { return }
|
||
|
||
let bottomIsPlayer = bettedOnPlayer ?? true
|
||
|
||
isAnimating = true
|
||
engine.prepareNewRound()
|
||
|
||
// Clear visible cards and bet results
|
||
visiblePlayerCards = []
|
||
visibleBankerCards = []
|
||
playerCardsFaceUp = []
|
||
bankerCardsFaceUp = []
|
||
lastResult = nil
|
||
showResultBanner = false
|
||
playerHadPair = false
|
||
bankerHadPair = false
|
||
betResults = []
|
||
|
||
// Change to dealing phase - triggers layout animation (horizontal to vertical)
|
||
currentPhase = .dealingInitial
|
||
|
||
// Wait for layout animation to complete before dealing cards
|
||
if settings.showAnimations {
|
||
// Increased from 1s to 1.25s for better stability on all devices
|
||
try? await Task.sleep(for: .milliseconds(1250))
|
||
}
|
||
|
||
let initialCards = engine.dealInitialCards()
|
||
|
||
// Check if animations are enabled
|
||
if settings.showAnimations {
|
||
// Brief extra delay before the very first card deal starts
|
||
try? await Task.sleep(for: .milliseconds(250))
|
||
|
||
// Animate dealing: B1 (Bottom 1), T1 (Top 1), B2 (Bottom 2), T2 (Top 2)
|
||
|
||
// Map slots to roles: (isPlayerRole, engineCardIndex)
|
||
// engineCardIndex: 0=P1, 1=B1, 2=P2, 3=B2
|
||
let dealSequence: [(isPlayer: Bool, cardIndex: Int)] = [
|
||
(bottomIsPlayer, bottomIsPlayer ? 0 : 1), // B1
|
||
(!bottomIsPlayer, bottomIsPlayer ? 1 : 0), // T1
|
||
(bottomIsPlayer, bottomIsPlayer ? 2 : 3), // B2
|
||
(!bottomIsPlayer, bottomIsPlayer ? 3 : 2) // T2
|
||
]
|
||
|
||
for (stepIndex, step) in dealSequence.enumerated() {
|
||
try? await Task.sleep(for: dealDelay)
|
||
sound.playCardDeal()
|
||
|
||
let card = initialCards[step.cardIndex]
|
||
if step.isPlayer {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(false)
|
||
} else {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(false)
|
||
}
|
||
}
|
||
|
||
// Reveal according to selected style
|
||
switch settings.revealStyle {
|
||
case .auto, .tap, .squeeze:
|
||
// Sequential reveal: B1 -> T1 -> B2 -> T2
|
||
for i in 0..<4 {
|
||
await waitForReveal(index: i)
|
||
sound.playCardFlip()
|
||
|
||
let slotIndex = i / 2 // 0 for B1/T1, 1 for B2/T2
|
||
let isBottom = i % 2 == 0
|
||
|
||
if (isBottom && bottomIsPlayer) || (!isBottom && !bottomIsPlayer) {
|
||
playerCardsFaceUp[slotIndex] = true
|
||
} else {
|
||
bankerCardsFaceUp[slotIndex] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pause to let user see initial totals
|
||
if settings.revealStyle == .auto {
|
||
try? await Task.sleep(for: resultDelay)
|
||
}
|
||
} else {
|
||
// No animations - show all cards immediately
|
||
for (index, card) in initialCards.enumerated() {
|
||
if index % 2 == 0 {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(true)
|
||
} else {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for naturals
|
||
if engine.playerHand.isNatural || engine.bankerHand.isNatural {
|
||
// Pause before showing result for naturals
|
||
if settings.showAnimations {
|
||
try? await Task.sleep(for: resultDelay)
|
||
}
|
||
await showResult()
|
||
return
|
||
}
|
||
|
||
// --- THIRD CARD HANDLING ---
|
||
// Slots: index 4 = B3, index 5 = T3
|
||
let playerThird = engine.drawPlayerThirdCard()
|
||
let bankerThird = engine.drawBankerThirdCard()
|
||
|
||
// Slot B3
|
||
let b3Card = bottomIsPlayer ? playerThird : bankerThird
|
||
if let card = b3Card {
|
||
if settings.showAnimations {
|
||
try? await Task.sleep(for: dealDelay)
|
||
sound.playCardDeal()
|
||
if bottomIsPlayer {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(false)
|
||
} else {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(false)
|
||
}
|
||
} else {
|
||
if bottomIsPlayer {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(true)
|
||
} else {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slot T3
|
||
let t3Card = bottomIsPlayer ? bankerThird : playerThird
|
||
if let card = t3Card {
|
||
if settings.showAnimations {
|
||
try? await Task.sleep(for: dealDelay)
|
||
sound.playCardDeal()
|
||
if bottomIsPlayer {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(false)
|
||
} else {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(false)
|
||
}
|
||
} else {
|
||
if bottomIsPlayer {
|
||
visibleBankerCards.append(card)
|
||
bankerCardsFaceUp.append(true)
|
||
} else {
|
||
visiblePlayerCards.append(card)
|
||
playerCardsFaceUp.append(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reveal 3rd cards in Slot order: B3 then T3
|
||
if settings.showAnimations {
|
||
// Reveal B3 (Reveal Index 4)
|
||
if b3Card != nil {
|
||
await waitForReveal(index: 4)
|
||
sound.playCardFlip()
|
||
if bottomIsPlayer {
|
||
playerCardsFaceUp[2] = true
|
||
} else {
|
||
bankerCardsFaceUp[2] = true
|
||
}
|
||
|
||
if settings.revealStyle == .auto {
|
||
try? await Task.sleep(for: resultDelay)
|
||
}
|
||
}
|
||
|
||
// Reveal T3 (Reveal Index 5)
|
||
if t3Card != nil {
|
||
await waitForReveal(index: 5)
|
||
sound.playCardFlip()
|
||
if bottomIsPlayer {
|
||
bankerCardsFaceUp[2] = true
|
||
} else {
|
||
playerCardsFaceUp[2] = true
|
||
}
|
||
|
||
if settings.revealStyle == .auto {
|
||
try? await Task.sleep(for: resultDelay)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final pause after all cards are dealt before showing results
|
||
if settings.showAnimations {
|
||
try? await Task.sleep(for: resultDelay)
|
||
}
|
||
|
||
await showResult()
|
||
}
|
||
|
||
/// Shows the result and processes payouts.
|
||
private func showResult() async {
|
||
currentPhase = .showingResult
|
||
|
||
let result = engine.determineResult()
|
||
lastResult = result
|
||
|
||
// Record pair results for display
|
||
playerHadPair = engine.playerHasPair
|
||
bankerHadPair = engine.bankerHasPair
|
||
|
||
// Check for naturals
|
||
let isNatural = engine.playerHand.isNatural || engine.bankerHand.isNatural
|
||
|
||
// Calculate and apply payouts, track individual results
|
||
var totalWinnings = 0
|
||
var results: [BetResult] = []
|
||
|
||
// Track dragon bonus wins
|
||
var dragonPlayerWon = false
|
||
var dragonBankerWon = false
|
||
|
||
for bet in currentBets {
|
||
let payout = engine.calculatePayout(bet: bet, result: result)
|
||
totalWinnings += payout
|
||
|
||
// Track individual bet result
|
||
results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout))
|
||
|
||
// Track dragon bonus wins
|
||
if bet.type == .dragonBonusPlayer && payout > 0 {
|
||
dragonPlayerWon = true
|
||
}
|
||
if bet.type == .dragonBonusBanker && payout > 0 {
|
||
dragonBankerWon = true
|
||
}
|
||
|
||
// Return original bet if not a loss
|
||
if payout >= 0 {
|
||
balance += bet.amount
|
||
}
|
||
|
||
// Add winnings
|
||
if payout > 0 {
|
||
balance += payout
|
||
}
|
||
}
|
||
|
||
betResults = results
|
||
lastWinnings = totalWinnings
|
||
|
||
// Determine round outcome for session stats
|
||
let isWin = totalWinnings > 0
|
||
let isLoss = totalWinnings < 0
|
||
let outcome: RoundOutcome = isWin ? .win : (isLoss ? .lose : .push)
|
||
|
||
// Capture values for closure
|
||
let roundBetAmount = totalBetAmount
|
||
let wasPlayerWin = result == .playerWins
|
||
let wasBankerWin = result == .bankerWins
|
||
let wasTie = result == .tie
|
||
let hadPlayerPair = playerHadPair
|
||
let hadBankerPair = bankerHadPair
|
||
|
||
// Record round in session using CasinoKit protocol
|
||
recordSessionRound(
|
||
winnings: totalWinnings,
|
||
betAmount: roundBetAmount,
|
||
outcome: outcome
|
||
) { stats in
|
||
// Update Baccarat-specific stats
|
||
if isNatural { stats.naturals += 1 }
|
||
if wasPlayerWin { stats.playerWins += 1 }
|
||
if wasBankerWin { stats.bankerWins += 1 }
|
||
if wasTie { stats.ties += 1 }
|
||
if hadPlayerPair { stats.playerPairs += 1 }
|
||
if hadBankerPair { stats.bankerPairs += 1 }
|
||
if dragonPlayerWon { stats.dragonBonusPlayerWins += 1 }
|
||
if dragonBankerWon { stats.dragonBonusBankerWins += 1 }
|
||
}
|
||
|
||
CasinoDesign.debugLog("📊 Session stats update:")
|
||
CasinoDesign.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)")
|
||
CasinoDesign.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds")
|
||
|
||
// Play result sound based on MAIN BET outcome (not total winnings)
|
||
// This way winning the main hand plays win sound even if side bets lost
|
||
let mainBetResult = results.first(where: { $0.type == .player || $0.type == .banker })
|
||
|
||
if let mainResult = mainBetResult {
|
||
if mainResult.isWin {
|
||
// Main bet won - play win sound
|
||
let maxBetAmount = currentBets.map(\.amount).max() ?? 0
|
||
let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
|
||
sound.playWin(isBigWin: isBigWin && totalWinnings > 0)
|
||
} else if mainResult.isPush {
|
||
// Main bet pushed (tie)
|
||
sound.playPush()
|
||
} else {
|
||
// Main bet lost
|
||
sound.playLose()
|
||
}
|
||
} else {
|
||
// No main bet (only side bets) - use total winnings
|
||
if totalWinnings > 0 {
|
||
sound.playWin(isBigWin: false)
|
||
} else if totalWinnings < 0 {
|
||
sound.playLose()
|
||
} else {
|
||
sound.playPush()
|
||
}
|
||
}
|
||
|
||
// Record result in history (for road map)
|
||
roundHistory.append(RoundResult(
|
||
result: result,
|
||
playerValue: playerHandValue,
|
||
bankerValue: bankerHandValue,
|
||
playerPair: playerHadPair,
|
||
bankerPair: bankerHadPair
|
||
))
|
||
|
||
// Save game data to iCloud
|
||
saveGameData()
|
||
|
||
// Show result banner - stays until user taps New Round
|
||
showResultBanner = true
|
||
currentPhase = .roundComplete
|
||
isAnimating = false
|
||
}
|
||
|
||
/// Prepares for a new round.
|
||
func newRound() {
|
||
guard currentPhase == .roundComplete else { return }
|
||
|
||
// Play new round sound
|
||
sound.playNewRound()
|
||
|
||
// Dismiss result banner
|
||
showResultBanner = false
|
||
|
||
currentBets = []
|
||
visiblePlayerCards = []
|
||
visibleBankerCards = []
|
||
playerCardsFaceUp = []
|
||
bankerCardsFaceUp = []
|
||
lastResult = nil
|
||
lastWinnings = 0
|
||
playerHadPair = false
|
||
bankerHadPair = false
|
||
betResults = []
|
||
currentPhase = .betting
|
||
}
|
||
|
||
/// Rebet the same amounts as last round.
|
||
func rebet() {
|
||
guard currentPhase == .roundComplete || (currentPhase == .betting && currentBets.isEmpty) else { return }
|
||
|
||
if currentPhase == .roundComplete {
|
||
newRound()
|
||
}
|
||
}
|
||
|
||
// MARK: - Session Cleanup Hooks
|
||
|
||
/// Clean up round history when deleting a session.
|
||
func onWillDeleteSession(id: UUID) {
|
||
sessionRoundHistories.removeValue(forKey: id.uuidString)
|
||
}
|
||
|
||
/// Clean up all round histories when deleting all sessions.
|
||
func onWillDeleteAllSessions() {
|
||
sessionRoundHistories.removeAll()
|
||
}
|
||
|
||
// MARK: - Session Round History
|
||
|
||
/// Gets round history for a specific session.
|
||
func roundHistory(for session: BaccaratSession) -> [RoundResult] {
|
||
// If it's the current session, return the live round history
|
||
if session.id == currentSession?.id {
|
||
return roundHistory
|
||
}
|
||
|
||
// Otherwise look up from archived histories
|
||
guard let savedHistory = sessionRoundHistories[session.id.uuidString] else {
|
||
return []
|
||
}
|
||
|
||
return savedHistory.compactMap { $0.toRoundResult() }
|
||
}
|
||
|
||
// MARK: - SessionManagedGame Implementation
|
||
|
||
/// Resets game-specific state when starting a new session.
|
||
func resetForNewSession() {
|
||
// Note: Round history is already archived during saveGameData() calls
|
||
// Just clear the local round history for the new session
|
||
roundHistory = []
|
||
engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
||
newRoundInternal()
|
||
}
|
||
|
||
/// Internal new round reset (without sound).
|
||
private func newRoundInternal() {
|
||
currentBets = []
|
||
visiblePlayerCards = []
|
||
visibleBankerCards = []
|
||
playerCardsFaceUp = []
|
||
bankerCardsFaceUp = []
|
||
lastResult = nil
|
||
lastWinnings = 0
|
||
playerHadPair = false
|
||
bankerHadPair = false
|
||
betResults = []
|
||
currentPhase = .betting
|
||
}
|
||
|
||
/// Aggregated Baccarat-specific stats from all sessions.
|
||
var aggregatedBaccaratStats: BaccaratStats {
|
||
allSessions.aggregatedBaccaratStats()
|
||
}
|
||
|
||
// MARK: - Game Reset
|
||
|
||
/// Resets the entire game (keeps statistics).
|
||
/// Uses CasinoKit's performResetGame() which properly handles session ending.
|
||
func resetGame() {
|
||
performResetGame()
|
||
// Note: newRoundInternal() is called by resetForNewSession()
|
||
|
||
// Play new game sound
|
||
sound.playNewRound()
|
||
}
|
||
|
||
/// Completely clears all saved data and starts fresh.
|
||
func clearAllData() {
|
||
persistence.reset()
|
||
balance = settings.startingBalance
|
||
currentSession = nil
|
||
sessionHistory = []
|
||
sessionRoundHistories = [:]
|
||
roundHistory = []
|
||
startNewSession()
|
||
newRoundInternal()
|
||
|
||
// Play new game sound
|
||
sound.playNewRound()
|
||
}
|
||
|
||
/// Applies new settings (call after settings change).
|
||
func applySettings() {
|
||
resetGame()
|
||
}
|
||
}
|