diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/GamePersistence.swift index 950b871..e63a2b7 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/GamePersistence.swift @@ -38,6 +38,9 @@ struct SavedGamePayload: Codable { let redealState: GameState? let hasStartedTrackedGame: Bool let isCurrentGameFinalized: Bool + let hintRequestsInCurrentGame: Int + let undosUsedInCurrentGame: Int + let usedRedealInCurrentGame: Bool enum CodingKeys: String, CodingKey { case schemaVersion @@ -55,6 +58,9 @@ struct SavedGamePayload: Codable { case redealState case hasStartedTrackedGame case isCurrentGameFinalized + case hintRequestsInCurrentGame + case undosUsedInCurrentGame + case usedRedealInCurrentGame } init( @@ -72,7 +78,10 @@ struct SavedGamePayload: Codable { history: [GameSnapshot], redealState: GameState? = nil, hasStartedTrackedGame: Bool = true, - isCurrentGameFinalized: Bool = false + isCurrentGameFinalized: Bool = false, + hintRequestsInCurrentGame: Int = 0, + undosUsedInCurrentGame: Int = 0, + usedRedealInCurrentGame: Bool = false ) { self.schemaVersion = schemaVersion self.savedAt = savedAt @@ -89,6 +98,9 @@ struct SavedGamePayload: Codable { self.redealState = redealState self.hasStartedTrackedGame = hasStartedTrackedGame self.isCurrentGameFinalized = isCurrentGameFinalized + self.hintRequestsInCurrentGame = max(0, hintRequestsInCurrentGame) + self.undosUsedInCurrentGame = max(0, undosUsedInCurrentGame) + self.usedRedealInCurrentGame = usedRedealInCurrentGame } init(from decoder: Decoder) throws { @@ -108,6 +120,15 @@ struct SavedGamePayload: Codable { redealState = try container.decodeIfPresent(GameState.self, forKey: .redealState) hasStartedTrackedGame = try container.decodeIfPresent(Bool.self, forKey: .hasStartedTrackedGame) ?? true isCurrentGameFinalized = try container.decodeIfPresent(Bool.self, forKey: .isCurrentGameFinalized) ?? false + hintRequestsInCurrentGame = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .hintRequestsInCurrentGame) ?? 0 + ) + undosUsedInCurrentGame = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .undosUsedInCurrentGame) ?? 0 + ) + usedRedealInCurrentGame = try container.decodeIfPresent(Bool.self, forKey: .usedRedealInCurrentGame) ?? false } func sanitizedForRestore() -> SavedGamePayload? { @@ -129,6 +150,9 @@ struct SavedGamePayload: Codable { }() let sanitizedHasStartedTrackedGame = hasStartedTrackedGame let sanitizedIsCurrentGameFinalized = sanitizedHasStartedTrackedGame ? isCurrentGameFinalized : false + let sanitizedHintRequestsInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, hintRequestsInCurrentGame) : 0 + let sanitizedUndosUsedInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, undosUsedInCurrentGame) : 0 + let sanitizedUsedRedealInCurrentGame = sanitizedHasStartedTrackedGame ? usedRedealInCurrentGame : false let sanitizedHistory = history .filter { $0.movesCount >= 0 && $0.state.isValidForPersistence } .map { snapshot in @@ -172,7 +196,10 @@ struct SavedGamePayload: Codable { history: Array(sanitizedHistory), redealState: sanitizedRedealState, hasStartedTrackedGame: sanitizedHasStartedTrackedGame, - isCurrentGameFinalized: sanitizedIsCurrentGameFinalized + isCurrentGameFinalized: sanitizedIsCurrentGameFinalized, + hintRequestsInCurrentGame: sanitizedHintRequestsInCurrentGame, + undosUsedInCurrentGame: sanitizedUndosUsedInCurrentGame, + usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame ) } } @@ -224,29 +251,77 @@ struct GameStatistics: Codable, Equatable { static let currentSchemaVersion = 1 let schemaVersion: Int + var trackedSince: Date? var gamesPlayed: Int var gamesWon: Int var totalTimeSeconds: Int var bestTimeSeconds: Int? - var highScoreDrawThree: Int - var highScoreDrawOne: Int + var highScoreDrawThree: Int? + var highScoreDrawOne: Int? + var cleanWins: Int + + enum CodingKeys: String, CodingKey { + case schemaVersion + case trackedSince + case gamesPlayed + case gamesWon + case totalTimeSeconds + case bestTimeSeconds + case highScoreDrawThree + case highScoreDrawOne + case cleanWins + } init( schemaVersion: Int = currentSchemaVersion, + trackedSince: Date? = nil, gamesPlayed: Int = 0, gamesWon: Int = 0, totalTimeSeconds: Int = 0, bestTimeSeconds: Int? = nil, - highScoreDrawThree: Int = 0, - highScoreDrawOne: Int = 0 + highScoreDrawThree: Int? = nil, + highScoreDrawOne: Int? = nil, + cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion + self.trackedSince = trackedSince self.gamesPlayed = max(0, gamesPlayed) self.gamesWon = max(0, min(gamesWon, gamesPlayed)) self.totalTimeSeconds = max(0, totalTimeSeconds) self.bestTimeSeconds = bestTimeSeconds.map { max(0, $0) } - self.highScoreDrawThree = max(0, highScoreDrawThree) - self.highScoreDrawOne = max(0, highScoreDrawOne) + self.highScoreDrawThree = highScoreDrawThree.map { max(0, $0) } + self.highScoreDrawOne = highScoreDrawOne.map { max(0, $0) } + self.cleanWins = max(0, min(cleanWins, self.gamesWon)) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? Self.currentSchemaVersion + let decodedGamesPlayed = max(0, try container.decodeIfPresent(Int.self, forKey: .gamesPlayed) ?? 0) + let decodedGamesWon = max( + 0, + min( + try container.decodeIfPresent(Int.self, forKey: .gamesWon) ?? 0, + decodedGamesPlayed + ) + ) + + schemaVersion = decodedSchemaVersion + trackedSince = try container.decodeIfPresent(Date.self, forKey: .trackedSince) + gamesPlayed = decodedGamesPlayed + gamesWon = decodedGamesWon + totalTimeSeconds = max(0, try container.decodeIfPresent(Int.self, forKey: .totalTimeSeconds) ?? 0) + bestTimeSeconds = try container.decodeIfPresent(Int.self, forKey: .bestTimeSeconds).map { max(0, $0) } + highScoreDrawThree = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawThree).map { max(0, $0) } + highScoreDrawOne = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawOne).map { max(0, $0) } + cleanWins = max( + 0, + min( + try container.decodeIfPresent(Int.self, forKey: .cleanWins) ?? 0, + decodedGamesWon + ) + ) } var winRate: Double { @@ -259,14 +334,24 @@ struct GameStatistics: Codable, Equatable { return totalTimeSeconds / gamesPlayed } + var cleanWinRate: Double { + guard gamesWon > 0 else { return 0 } + return Double(cleanWins) / Double(gamesWon) + } + mutating func recordCompletedGame( didWin: Bool, elapsedSeconds: Int, finalScore: Int, - drawCount: Int + drawCount: Int, + hintsUsedInGame: Int, + undosUsedInGame: Int, + usedRedealInGame: Bool ) { let sanitizedElapsed = max(0, elapsedSeconds) let sanitizedScore = max(0, finalScore) + let sanitizedHintsUsedInGame = max(0, hintsUsedInGame) + let sanitizedUndosUsedInGame = max(0, undosUsedInGame) gamesPlayed = addingSafely(gamesPlayed, 1) totalTimeSeconds = addingSafely(totalTimeSeconds, sanitizedElapsed) @@ -281,12 +366,29 @@ struct GameStatistics: Codable, Equatable { } if drawCount == DrawMode.one.rawValue { - highScoreDrawOne = max(highScoreDrawOne, sanitizedScore) + highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) } else { - highScoreDrawThree = max(highScoreDrawThree, sanitizedScore) + highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) + } + + let isCleanWin = sanitizedHintsUsedInGame == 0 + && sanitizedUndosUsedInGame == 0 + && !usedRedealInGame + if isCleanWin { + cleanWins = min(gamesWon, addingSafely(cleanWins, 1)) } } + mutating func markTrackingStarted(at date: Date = .now) { + if trackedSince == nil { + trackedSince = date + } + } + + mutating func reset(at date: Date = .now) { + self = GameStatistics(trackedSince: date) + } + private func addingSafely(_ lhs: Int, _ rhs: Int) -> Int { let (sum, overflow) = lhs.addingReportingOverflow(rhs) return overflow ? Int.max : sum @@ -318,6 +420,22 @@ enum GameStatisticsStore { mutate(&stats) save(stats, userDefaults: userDefaults) } + + static func markTrackingStarted( + userDefaults: UserDefaults = .standard, + at date: Date = .now + ) { + update(userDefaults: userDefaults) { stats in + stats.markTrackingStarted(at: date) + } + } + + static func reset( + userDefaults: UserDefaults = .standard, + at date: Date = .now + ) { + save(GameStatistics(trackedSince: date), userDefaults: userDefaults) + } } private struct CardIdentity: Hashable { diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index 6dc989e..f30fcf3 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -31,6 +31,9 @@ final class SolitaireViewModel { private var scoringDrawCount: Int = DrawMode.three.rawValue private var hasStartedTrackedGame = false private var isCurrentGameFinalized = false + private var hintRequestsInCurrentGame: Int = 0 + private var undosUsedInCurrentGame: Int = 0 + private var usedRedealInCurrentGame = false private var history: [GameSnapshot] = [] @@ -41,6 +44,7 @@ final class SolitaireViewModel { } init() { + let startedAt = Date() let initialState = GameState.newGame() state = initialState isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: initialState) @@ -49,6 +53,9 @@ final class SolitaireViewModel { stockDrawCount: DrawMode.three.rawValue ) != nil redealState = initialState + gameStartedAt = startedAt + hasStartedTrackedGame = false + GameStatisticsStore.markTrackingStarted(at: startedAt) } var isWin: Bool { @@ -112,6 +119,7 @@ final class SolitaireViewModel { activeHint = hint hintWiggleToken = UUID() scheduleHintAutoClear(for: hint) + hintRequestsInCurrentGame += 1 HapticManager.shared.play(.settingsSelection) } @@ -136,6 +144,14 @@ final class SolitaireViewModel { return elapsedActiveSeconds(at: date) } + func resetStatisticsTracking() { + hasStartedTrackedGame = false + isCurrentGameFinalized = true + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = false + } + func displayScore(at date: Date = .now) -> Int { guard !hasAppliedTimeBonus else { return score } let elapsedSeconds = elapsedActiveSeconds(at: date) @@ -193,6 +209,9 @@ final class SolitaireViewModel { scoringDrawCount = drawMode.rawValue hasStartedTrackedGame = true isCurrentGameFinalized = false + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = false state.wasteDrawCount = 0 history.removeAll() refreshAutoFinishAvailability() @@ -214,6 +233,9 @@ final class SolitaireViewModel { scoringDrawCount = stockDrawCount hasStartedTrackedGame = true isCurrentGameFinalized = false + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = true state.wasteDrawCount = min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) history.removeAll() refreshAutoFinishAvailability() @@ -247,6 +269,7 @@ final class SolitaireViewModel { score = snapshot.score hasAppliedTimeBonus = snapshot.hasAppliedTimeBonus finalElapsedSeconds = nil + undosUsedInCurrentGame += 1 selection = nil isDragging = false pendingAutoMove = nil @@ -272,7 +295,10 @@ final class SolitaireViewModel { history: history, redealState: redealState, hasStartedTrackedGame: hasStartedTrackedGame, - isCurrentGameFinalized: isCurrentGameFinalized + isCurrentGameFinalized: isCurrentGameFinalized, + hintRequestsInCurrentGame: hintRequestsInCurrentGame, + undosUsedInCurrentGame: undosUsedInCurrentGame, + usedRedealInCurrentGame: usedRedealInCurrentGame ) } @@ -295,6 +321,9 @@ final class SolitaireViewModel { scoringDrawCount = sanitizedPayload.scoringDrawCount hasStartedTrackedGame = sanitizedPayload.hasStartedTrackedGame isCurrentGameFinalized = sanitizedPayload.isCurrentGameFinalized + hintRequestsInCurrentGame = sanitizedPayload.hintRequestsInCurrentGame + undosUsedInCurrentGame = sanitizedPayload.undosUsedInCurrentGame + usedRedealInCurrentGame = sanitizedPayload.usedRedealInCurrentGame history = Array(sanitizedPayload.history.suffix(Self.maxUndoHistoryCount)) var restoredRedealState = sanitizedPayload.redealState ?? history.first?.state ?? state restoredRedealState.wasteDrawCount = min( @@ -708,7 +737,10 @@ private extension SolitaireViewModel { didWin: didWin, elapsedSeconds: elapsedSeconds, finalScore: score, - drawCount: scoringDrawCount + drawCount: scoringDrawCount, + hintsUsedInGame: hintRequestsInCurrentGame, + undosUsedInGame: undosUsedInCurrentGame, + usedRedealInGame: usedRedealInCurrentGame ) } isCurrentGameFinalized = true diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index f6442b1..1235e3b 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -296,10 +296,10 @@ struct ContentView: View { .sheet(isPresented: $isShowingStats) { #if os(iOS) NavigationStack { - StatsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel) } #else - StatsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel) #endif } ) diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 65d63ec..b774595 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -235,89 +235,6 @@ private struct SettingsCard: View { } } -struct StatsView: View { - let viewModel: SolitaireViewModel? - @Environment(\.dismiss) private var dismiss - @State private var stats = GameStatistics() - private let durationFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.day, .hour, .minute, .second] - formatter.unitsStyle = .abbreviated - formatter.zeroFormattingBehavior = .dropLeading - return formatter - }() - - var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - Form { - Section("Games") { - keyValueRow("Games Played", "\(stats.gamesPlayed)") - keyValueRow("Games Won", "\(stats.gamesWon)") - keyValueRow("Win Rate", winRateLabel) - } - - Section("Performance") { - keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) - keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) - keyValueRow("Best Time", bestTimeLabel) - keyValueRow("High Score (3-card)", "\(stats.highScoreDrawThree)") - keyValueRow("High Score (1-card)", "\(stats.highScoreDrawOne)") - } - } - } - .navigationTitle("Statistics") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) - .frame(minWidth: 420, minHeight: 320) -#endif - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - .keyboardShortcut(.cancelAction) - } - } - .onAppear { - stats = GameStatisticsStore.load() - } - } - - private var winRateLabel: String { - String(format: "%.1f%%", stats.winRate * 100) - } - - private var bestTimeLabel: String { - guard let bestTimeSeconds = stats.bestTimeSeconds else { return "-" } - return durationLabel(bestTimeSeconds) - } - - private func displayTotalTimeSeconds(at date: Date) -> Int { - let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 - let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) - return overflow ? Int.max : max(0, sum) - } - - @ViewBuilder - private func keyValueRow(_ key: String, _ value: String) -> some View { - HStack { - Text(key) - Spacer(minLength: 16) - Text(value) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - - private func durationLabel(_ seconds: Int) -> String { - let total = max(0, seconds) - return durationFormatter.string(from: TimeInterval(total)) ?? "0s" - } -} - #Preview { SettingsView() } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift new file mode 100644 index 0000000..503daa9 --- /dev/null +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -0,0 +1,279 @@ +import Foundation +import SwiftUI +import SwiftData + +struct StatisticsView: View { + let viewModel: SolitaireViewModel? + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @State private var stats = GameStatistics() + @State private var barHoverState: (label: String, x: CGFloat)? + @State private var isShowingCleanWinsInfo = false + @State private var isShowingResetConfirmation = false + private let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropLeading + return formatter + }() + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + Form { + Section { + HStack(spacing: 0) { + highlightCard( + icon: "percent", + label: "Win Rate", + value: winRateLabel + ) + Divider() + .frame(height: 32) + highlightCard( + icon: "timer", + label: "Best Time", + value: bestTimeLabel + ) + Divider() + .frame(height: 32) + highlightCard( + icon: "trophy.fill", + label: "Games Won", + value: "\(stats.gamesWon)" + ) + } + .padding(.vertical, 2) + } header: { + Text("Highlights") + } + + Section { + keyValueRow("Games Played", "\(stats.gamesPlayed)") + keyValueRow("Wins", "\(stats.gamesWon)") + VStack(spacing: 6) { + keyValueRow("Win Rate", winRateLabel) + if stats.gamesPlayed > 0 { + winLossBar + } + } + cleanWinsRow + } header: { + Text("Games") + } + + Section { + keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) + keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) + keyValueRow("Best Time", bestTimeLabel) + keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") + keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") + } header: { + Text("Performance") + } + + Text("Tracked since \(trackedSinceLabel)") + .font(.footnote) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .navigationTitle("Statistics") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#else + .formStyle(.grouped) + .padding(16) + .frame(minWidth: 420, minHeight: 320) +#endif + .toolbar { +#if os(macOS) + ToolbarItem(placement: .automatic) { + Button("Reset Stats") { + isShowingResetConfirmation = true + } + } +#else + ToolbarItem(placement: .topBarLeading) { + Button("Reset Stats") { + isShowingResetConfirmation = true + } + } +#endif + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + .onAppear { + stats = GameStatisticsStore.load() + } + .confirmationDialog( + "Reset statistics?", + isPresented: $isShowingResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset Statistics", role: .destructive) { + resetStatistics() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("All games, times, win rates, and high scores will be reset.") + } + } + + private var winLossBar: some View { + let losses = stats.gamesPlayed - stats.gamesWon + let winsLabel = "\(stats.gamesWon) \(stats.gamesWon == 1 ? "win" : "wins")" + let lossesLabel = "\(losses) \(losses == 1 ? "loss" : "losses")" + return GeometryReader { geo in + let winFraction = CGFloat(stats.gamesWon) / CGFloat(max(1, stats.gamesPlayed)) + let winWidth = max(winFraction > 0 ? 4 : 0, geo.size.width * winFraction - 1) + let lossWidth = max(winFraction < 1 ? 4 : 0, geo.size.width * (1 - winFraction) - 1) + HStack(spacing: 2) { + barSegment(fill: .green.opacity(0.6), width: winWidth, label: winsLabel, xOffset: 0) + barSegment(fill: .red.opacity(0.35), width: lossWidth, label: lossesLabel, xOffset: winWidth + 2) + } + .overlay(alignment: .topLeading) { + if let hover = barHoverState { + Text(hover.label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .fixedSize() + .position(x: hover.x, y: -16) + .allowsHitTesting(false) + } + } + } + .frame(height: 8) + .padding(.top, 2) + } + + private func barSegment(fill: some ShapeStyle, width: CGFloat, label: String, xOffset: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(fill) + .frame(width: width) + .onContinuousHover { phase in + switch phase { + case .active(let location): + barHoverState = (label: label, x: xOffset + location.x) + case .ended: + barHoverState = nil + } + } + } + + private var winRateLabel: String { + String(format: "%.1f%%", stats.winRate * 100) + } + + private var bestTimeLabel: String { + guard let bestTimeSeconds = stats.bestTimeSeconds else { return "-" } + return durationLabel(bestTimeSeconds) + } + + private var cleanWinRateLabel: String { + return String(format: "%.1f%%", stats.cleanWinRate * 100) + } + + private var trackedSinceLabel: String { + guard let trackedSince = stats.trackedSince else { return "-" } + return trackedSince.formatted(date: .abbreviated, time: .omitted) + } + + private func displayTotalTimeSeconds(at date: Date) -> Int { + let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) + return overflow ? Int.max : max(0, sum) + } + + private func highlightCard(icon: String, label: String, value: String) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.headline, design: .monospaced, weight: .bold)) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func keyValueRow(_ key: String, _ value: String) -> some View { + HStack { + Text(key) + Spacer(minLength: 16) + Text(value) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + private var cleanWinsRow: some View { + HStack(spacing: 4) { + Text("Clean Wins") + Button { + isShowingCleanWinsInfo = true + } label: { + Image(systemName: "info.circle") + .font(.footnote) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clean Wins info") + .popover(isPresented: $isShowingCleanWinsInfo, arrowEdge: .top) { + Text("Wins completed without the use of hints, undos, or redeals.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 280, alignment: .leading) + .padding(12) +#if os(iOS) + .presentationCompactAdaptation(.popover) +#endif + } + Spacer(minLength: 12) + Text("\(stats.cleanWins) (\(cleanWinRateLabel))") + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 2) + .padding(.leading, 2) + } + + private func durationLabel(_ seconds: Int) -> String { + let total = max(0, seconds) + return durationFormatter.string(from: TimeInterval(total)) ?? "0s" + } + + private func resetStatistics() { + GameStatisticsStore.reset() + viewModel?.resetStatisticsTracking() + persistTrackingResetIfNeeded() + stats = GameStatisticsStore.load() + barHoverState = nil + } + + private func persistTrackingResetIfNeeded() { + guard let viewModel else { return } + do { + try GamePersistence.save(viewModel.persistencePayload(), in: modelContext) + } catch { +#if DEBUG + print("Failed to persist reset tracking state: \(error)") +#endif + } + } +} diff --git a/ComputerSolitaireTests/GameSessionTrackingTests.swift b/ComputerSolitaireTests/GameSessionTrackingTests.swift new file mode 100644 index 0000000..a9961d4 --- /dev/null +++ b/ComputerSolitaireTests/GameSessionTrackingTests.swift @@ -0,0 +1,193 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameSessionTrackingTests: XCTestCase { + private let statsKey = GameStatisticsStore.defaultsKey + private static var retainedViewModels: [SolitaireViewModel] = [] + + // Verifies app startup initializes tracking metadata without starting a trackable game. + func testInitMarksTrackingStartWithoutActiveTrackedGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + let stats = GameStatisticsStore.load() + XCTAssertNotNil(stats.trackedSince) + XCTAssertEqual(stats.gamesPlayed, 0) + + let initialProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: initialProbeDate), 0) + } + } + + // Verifies the first explicit New Game starts tracking and does not finalize bootstrap state. + func testFirstNewGameStartsTrackingWithoutFinalizingBootstrapSession() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + // Verifies starting a second game finalizes exactly one previously tracked session. + func testSecondNewGameFinalizesExactlyOneTrackedGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + } + } + + // Verifies redeal finalizes the current tracked session once and starts a fresh one. + func testRedealFinalizesCurrentTrackedGameExactlyOnce() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + viewModel.redeal() + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + // Verifies restore resumes live elapsed reporting when payload is active and unfinalized. + func testRestoreWithActiveTrackedGameReportsLiveElapsed() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: true, + isCurrentGameFinalized: false + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + } + } + + // Verifies finalized restored sessions are not finalized again when starting a new game. + func testRestoreWithFinalizedGameDoesNotFinalizeAgainOnNewGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: true, + isCurrentGameFinalized: true + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + } + } + + // Verifies untracked restored sessions stay untracked until an explicit New Game starts tracking. + func testRestoreWithUntrackedPayloadRemainsUntrackedUntilNewGameStarts() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: false, + isCurrentGameFinalized: true + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + // Verifies resetting statistics untracks the active session so pre-reset progress is not counted. + func testResetStatisticsUntracksCurrentSessionUntilNextNewGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + let activeProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) + + GameStatisticsStore.reset() + viewModel.resetStatisticsTracking() + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) + let resetPayload = viewModel.persistencePayload() + XCTAssertFalse(resetPayload.hasStartedTrackedGame) + XCTAssertTrue(resetPayload.isCurrentGameFinalized) + + viewModel.newGame(drawMode: .three) + var stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + viewModel.newGame(drawMode: .three) + stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + } + } + + private func withIsolatedStatsStore(_ body: () -> Void) { + let defaults = UserDefaults.standard + let previousStatsData = defaults.data(forKey: statsKey) + defaults.removeObject(forKey: statsKey) + defer { + if let previousStatsData { + defaults.set(previousStatsData, forKey: statsKey) + } else { + defaults.removeObject(forKey: statsKey) + } + } + body() + } + + private func makeViewModel() -> SolitaireViewModel { + let viewModel = SolitaireViewModel() + Self.retainedViewModels.append(viewModel) + return viewModel + } + + private func makePayload( + hasStartedTrackedGame: Bool, + isCurrentGameFinalized: Bool + ) -> SavedGamePayload { + let state = GameState.newGame() + return SavedGamePayload( + savedAt: Date().addingTimeInterval(-300), + state: state, + movesCount: 0, + score: 0, + gameStartedAt: Date().addingTimeInterval(-600), + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + history: [], + redealState: state, + hasStartedTrackedGame: hasStartedTrackedGame, + isCurrentGameFinalized: isCurrentGameFinalized, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } +} diff --git a/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift b/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift new file mode 100644 index 0000000..4e2d80b --- /dev/null +++ b/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameStatisticsCleanWinTests: XCTestCase { + func testCleanWinIncrementsWhenNoHintUndoOrRedealWasUsed() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 120, + finalScore: 200, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 1) + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 1) + XCTAssertEqual(stats.cleanWinRate, 1.0, accuracy: 0.0001) + } + + func testWinWithHintIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 1, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testWinWithUndoIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 2, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testWinWithRedealIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: true + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testLossNeverCountsAsCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: false, + elapsedSeconds: 300, + finalScore: 150, + drawCount: DrawMode.one.rawValue, + hintsUsedInGame: 2, + undosUsedInGame: 3, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 1) + XCTAssertEqual(stats.gamesWon, 0) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testDecodingLegacyStatsDefaultsCleanWinsToZero() throws { + let legacyJSON = """ + { + "schemaVersion": 1, + "gamesPlayed": 8, + "gamesWon": 3, + "totalTimeSeconds": 1200, + "bestTimeSeconds": 140, + "highScoreDrawThree": 780, + "highScoreDrawOne": 620 + } + """ + + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let decoded = try JSONDecoder().decode(GameStatistics.self, from: data) + + XCTAssertEqual(decoded.cleanWins, 0) + XCTAssertEqual(decoded.gamesPlayed, 8) + XCTAssertEqual(decoded.gamesWon, 3) + } +}