Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 129 additions & 11 deletions ComputerSolitaire/Game/GamePersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +58,9 @@ struct SavedGamePayload: Codable {
case redealState
case hasStartedTrackedGame
case isCurrentGameFinalized
case hintRequestsInCurrentGame
case undosUsedInCurrentGame
case usedRedealInCurrentGame
}

init(
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Comment thread
austin-smith marked this conversation as resolved.
)
undosUsedInCurrentGame = max(
0,
try container.decodeIfPresent(Int.self, forKey: .undosUsedInCurrentGame) ?? 0
)
usedRedealInCurrentGame = try container.decodeIfPresent(Bool.self, forKey: .usedRedealInCurrentGame) ?? false
}

func sanitizedForRestore() -> SavedGamePayload? {
Expand All @@ -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
Expand Down Expand Up @@ -172,7 +196,10 @@ struct SavedGamePayload: Codable {
history: Array(sanitizedHistory),
redealState: sanitizedRedealState,
hasStartedTrackedGame: sanitizedHasStartedTrackedGame,
isCurrentGameFinalized: sanitizedIsCurrentGameFinalized
isCurrentGameFinalized: sanitizedIsCurrentGameFinalized,
hintRequestsInCurrentGame: sanitizedHintRequestsInCurrentGame,
undosUsedInCurrentGame: sanitizedUndosUsedInCurrentGame,
usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame
)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Comment thread
austin-smith marked this conversation as resolved.
}
}

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
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 34 additions & 2 deletions ComputerSolitaire/Game/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand All @@ -41,6 +44,7 @@ final class SolitaireViewModel {
}

init() {
let startedAt = Date()
let initialState = GameState.newGame()
state = initialState
isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: initialState)
Expand All @@ -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 {
Expand Down Expand Up @@ -112,6 +119,7 @@ final class SolitaireViewModel {
activeHint = hint
hintWiggleToken = UUID()
scheduleHintAutoClear(for: hint)
hintRequestsInCurrentGame += 1
HapticManager.shared.play(.settingsSelection)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -247,6 +269,7 @@ final class SolitaireViewModel {
score = snapshot.score
hasAppliedTimeBonus = snapshot.hasAppliedTimeBonus
finalElapsedSeconds = nil
undosUsedInCurrentGame += 1
selection = nil
isDragging = false
pendingAutoMove = nil
Expand All @@ -272,7 +295,10 @@ final class SolitaireViewModel {
history: history,
redealState: redealState,
hasStartedTrackedGame: hasStartedTrackedGame,
isCurrentGameFinalized: isCurrentGameFinalized
isCurrentGameFinalized: isCurrentGameFinalized,
hintRequestsInCurrentGame: hintRequestsInCurrentGame,
undosUsedInCurrentGame: undosUsedInCurrentGame,
usedRedealInCurrentGame: usedRedealInCurrentGame
)
}

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ComputerSolitaire/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
Expand Down
Loading