From e2aa61a01c0867b97e0b208e367231b8850d02f6 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 22:53:43 -0700 Subject: [PATCH 1/6] add game mode switcher with per-mode game sessions and statistics every playable mode (klondike draw counts, spider suit counts, single-mode variants) is now a first-class game with its own save slot and statistics bucket. switching games stashes the outgoing session and resumes the target's exactly where it left off, with no statistics penalty. the switcher is a felt-set title on the board opening a drill-in picker; per-mode options leave settings. one-shot migrations carry legacy saves and pooled klondike/spider statistics forward. also fixes cross-variant view re-evaluation crashes during switches and unifies the header layout. --- .../Game/Klondike/GameSessionKlondike.swift | 15 - ComputerSolitaire/Game/Shared/GameMode.swift | 119 ++++ .../Game/Shared/GamePersistence.swift | 220 +++++++- .../Game/Shared/GameSession.swift | 64 ++- .../Game/Shared/GameVariant.swift | 9 +- ComputerSolitaire/GameMenuCommands.swift | 17 + .../Views/Pyramid/PyramidBoardView.swift | 8 +- ComputerSolitaire/Views/SettingsView.swift | 101 +--- .../Views/Shared/BoardViews.swift | 151 +++-- .../Views/Shared/ContentView.swift | 231 ++++++-- .../Views/Shared/GameModePickerView.swift | 530 ++++++++++++++++++ .../Views/Shared/SelectionChip.swift | 29 + .../Spider/SpiderCompletedRunPileView.swift | 9 +- .../Views/Spider/SpiderTopRowView.swift | 8 +- ComputerSolitaire/Views/StatisticsView.swift | 176 +++--- .../Views/TriPeaks/TriPeaksBoardView.swift | 8 +- .../Pyramid/PyramidPersistenceTests.swift | 2 +- .../Pyramid/PyramidSessionTests.swift | 2 +- .../Shared/GamePersistenceStoreTests.swift | 262 ++++++++- .../Shared/GameSessionActivationTests.swift | 278 +++++++++ .../Shared/GameSessionTrackingTests.swift | 58 +- .../Shared/GameStatisticsStoreTests.swift | 129 ++++- .../Shared/SolitaireViewModelCoreTests.swift | 33 +- .../Spider/SpiderScoringTests.swift | 2 +- .../TriPeaks/TriPeaksSessionTests.swift | 2 +- .../Yukon/YukonPersistenceTests.swift | 2 +- .../Yukon/YukonRulesTests.swift | 8 +- .../GameModePickerUITests.swift | 55 ++ README.md | 8 +- 29 files changed, 2097 insertions(+), 439 deletions(-) create mode 100644 ComputerSolitaire/Game/Shared/GameMode.swift create mode 100644 ComputerSolitaire/Views/Shared/GameModePickerView.swift create mode 100644 ComputerSolitaire/Views/Shared/SelectionChip.swift create mode 100644 ComputerSolitaireTests/Shared/GameSessionActivationTests.swift create mode 100644 ComputerSolitaireUITests/GameModePickerUITests.swift diff --git a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift index a740785..4bc8091 100644 --- a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -26,21 +26,6 @@ extension SolitaireViewModel { state.variant == .klondike } - func updateDrawMode(_ drawMode: DrawMode) { - guard state.variant == .klondike else { return } - clearHint() - setStockDrawCount(drawMode.rawValue) - if drawMode == .one { - setWasteDrawCount(min(1, state.waste.count)) - } else { - setWasteDrawCount(min(state.wasteDrawCount, drawMode.rawValue)) - } - selection = nil - isDragging = false - pendingAutoMove = nil - refreshAutoFinishAvailability() - } - func handleKlondikeStockTap() { guard state.variant == .klondike else { return } clearHint() diff --git a/ComputerSolitaire/Game/Shared/GameMode.swift b/ComputerSolitaire/Game/Shared/GameMode.swift new file mode 100644 index 0000000..853fbc9 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameMode.swift @@ -0,0 +1,119 @@ +import Foundation + +/// A playable game: a variant plus its rule-defining configuration. Klondike's +/// draw counts and Spider's suit counts are distinct games — each mode keeps +/// its own saved session and its own statistics. Raw values key persistence; +/// single-mode variants reuse their variant's raw value so existing saves and +/// statistics carry over. +/// Cases are declared in presentation order, mirroring `GameVariant`; modes +/// within a variant run easiest to hardest. +enum GameMode: String, CaseIterable, Codable { + case klondikeDrawOne = "klondike.draw1" + case klondikeDrawThree = "klondike.draw3" + case spiderOneSuit = "spider.suits1" + case spiderTwoSuits = "spider.suits2" + case spiderFourSuits = "spider.suits4" + case freecell + case tripeaks + case pyramid + case yukon + + var variant: GameVariant { + switch self { + case .klondikeDrawOne, .klondikeDrawThree: + return .klondike + case .freecell: + return .freecell + case .yukon: + return .yukon + case .spiderOneSuit, .spiderTwoSuits, .spiderFourSuits: + return .spider + case .pyramid: + return .pyramid + case .tripeaks: + return .tripeaks + } + } + + /// The stock draw mode this game deals with; nil for variants without one. + var drawMode: DrawMode? { + switch self { + case .klondikeDrawOne: + return .one + case .klondikeDrawThree: + return .three + case .freecell, .yukon, .spiderOneSuit, .spiderTwoSuits, .spiderFourSuits, .pyramid, .tripeaks: + return nil + } + } + + /// The suit count this game deals with; nil for variants without one. + var spiderSuitCount: SpiderSuitCount? { + switch self { + case .spiderOneSuit: + return .one + case .spiderTwoSuits: + return .two + case .spiderFourSuits: + return .four + case .klondikeDrawOne, .klondikeDrawThree, .freecell, .yukon, .pyramid, .tripeaks: + return nil + } + } + + /// The mode's qualifier within its variant (draw count, suit count); + /// single-mode variants fall back to the variant's title. + var optionTitle: String { + drawMode?.title ?? spiderSuitCount?.title ?? variant.title + } + + /// The qualifier that distinguishes this mode from its variant siblings + /// ("3-card", "2 Suits"); nil for single-mode variants. + var qualifier: String? { + guard GameMode.modes(for: variant).count > 1 else { return nil } + return optionTitle + } + + /// The mode's full display name: qualified for multi-mode variants + /// ("Klondike · 3-card"), the plain variant title otherwise. + var displayTitle: String { + guard let qualifier else { return variant.title } + return "\(variant.title) · \(qualifier)" + } + + /// The mode a game of `variant` plays when dealt with the given + /// configuration; configuration that doesn't apply to the variant is + /// ignored. + init( + variant: GameVariant, + drawMode: DrawMode = .three, + spiderSuitCount: SpiderSuitCount = .two + ) { + switch variant { + case .klondike: + self = drawMode == .one ? .klondikeDrawOne : .klondikeDrawThree + case .freecell: + self = .freecell + case .yukon: + self = .yukon + case .spider: + switch spiderSuitCount { + case .one: + self = .spiderOneSuit + case .two: + self = .spiderTwoSuits + case .four: + self = .spiderFourSuits + } + case .pyramid: + self = .pyramid + case .tripeaks: + self = .tripeaks + } + } + + /// All playable modes of a variant, in picker order. + static func modes(for variant: GameVariant) -> [GameMode] { + allCases.filter { $0.variant == variant } + } +} diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 1c05e2b..1c7fd5b 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -3,14 +3,19 @@ import SwiftData @Model final class SavedGameRecord { - static let currentRecordKey = "current" + /// Single-slot key used before saved games became per-mode. + static let legacyRecordKey = "current" + + static func key(for mode: GameMode) -> String { + mode.rawValue + } @Attribute(.unique) var key: String @Attribute(.externalStorage) var snapshotData: Data var updatedAt: Date init( - key: String = SavedGameRecord.currentRecordKey, + key: String, snapshotData: Data, updatedAt: Date = .now ) { @@ -131,6 +136,44 @@ struct SavedGamePayload: Codable { usedRedealInCurrentGame = try container.decodeIfPresent(Bool.self, forKey: .usedRedealInCurrentGame) ?? false } + /// The game this payload belongs to. Spider's suit count is derived from + /// its deal; the draw count is carried alongside the state. + var gameMode: GameMode { + GameMode( + variant: state.variant, + drawMode: DrawMode(rawValue: stockDrawCount) ?? .three, + spiderSuitCount: state.spiderSuitCount ?? .two + ) + } + + /// A copy whose statistics tracking is invalidated: the game stays + /// playable but can no longer finalize into a statistics bucket. + /// `hasStartedTrackedGame: false` alone blocks finalization; the other + /// tracking fields take their canonical untracked values (the same + /// normal form `sanitizedForRestore` produces). + func withStatisticsTrackingReset() -> SavedGamePayload { + SavedGamePayload( + schemaVersion: schemaVersion, + savedAt: savedAt, + state: state, + movesCount: movesCount, + score: score, + gameStartedAt: gameStartedAt, + pauseStartedAt: pauseStartedAt, + hasAppliedTimeBonus: hasAppliedTimeBonus, + finalElapsedSeconds: finalElapsedSeconds, + stockDrawCount: stockDrawCount, + scoringDrawCount: scoringDrawCount, + history: history, + redealState: redealState, + hasStartedTrackedGame: false, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } + func sanitizedForRestore() -> SavedGamePayload? { sanitizedForRestore(at: .now) } @@ -242,33 +285,99 @@ enum GamePersistenceError: Error { } enum GamePersistence { - static func load(from modelContext: ModelContext, now: Date = .now) -> SavedGamePayload? { + static func load( + mode: GameMode, + from modelContext: ModelContext, + now: Date = .now + ) -> SavedGamePayload? { do { - guard let record = try fetchCurrentRecord(in: modelContext) else { return nil } + let key = SavedGameRecord.key(for: mode) + guard let record = try fetchRecord(forKey: key, in: modelContext) else { return nil } let payload = try JSONDecoder().decode(SavedGamePayload.self, from: record.snapshotData) + guard payload.gameMode == mode else { + return nil + } return payload.sanitizedForRestore(at: now) } catch { return nil } } + /// Invalidates statistics tracking in the saved sessions of `modes`, so + /// games that were in progress when their statistics were reset can't + /// finalize pre-reset play into the fresh buckets. Best effort per slot — + /// a missing or unreadable slot never blocks the others. + static func invalidateStatisticsTracking( + for modes: [GameMode], + in modelContext: ModelContext, + now: Date = .now + ) { + for mode in modes { + guard let payload = load(mode: mode, from: modelContext, now: now) else { continue } + try? save(payload.withStatisticsTrackingReset(), in: modelContext, now: now) + } + } + static func save(_ payload: SavedGamePayload, in modelContext: ModelContext, now: Date = .now) throws { guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { throw GamePersistenceError.invalidPayload } let data = try JSONEncoder().encode(sanitizedPayload) - if let record = try fetchCurrentRecord(in: modelContext) { + let key = SavedGameRecord.key(for: sanitizedPayload.gameMode) + if let record = try fetchRecord(forKey: key, in: modelContext) { record.snapshotData = data record.updatedAt = now } else { - modelContext.insert(SavedGameRecord(snapshotData: data, updatedAt: now)) + modelContext.insert(SavedGameRecord(key: key, snapshotData: data, updatedAt: now)) } try modelContext.save() } - private static func fetchCurrentRecord(in modelContext: ModelContext) throws -> SavedGameRecord? { - let key = SavedGameRecord.currentRecordKey + /// Re-keys records from earlier keying schemes (the single "current" slot, + /// per-variant slots) to their payload's mode slot. + // TODO: Remove (with `SavedGameRecord.legacyRecordKey`) once upgrades + // from pre-per-mode releases no longer need supporting. + static func migrateLegacyRecordsIfNeeded(in modelContext: ModelContext) { + do { + let modeKeys = Set(GameMode.allCases.map(\.rawValue)) + let records = try modelContext.fetch(FetchDescriptor()) + var didChange = false + + for record in records where !modeKeys.contains(record.key) { + didChange = true + guard let payload = try? JSONDecoder().decode( + SavedGamePayload.self, + from: record.snapshotData + ) else { + modelContext.delete(record) + continue + } + + let targetKey = SavedGameRecord.key( + for: payload.gameMode + ) + if let occupyingRecord = try fetchRecord(forKey: targetKey, in: modelContext) { + if occupyingRecord.updatedAt >= record.updatedAt { + modelContext.delete(record) + } else { + modelContext.delete(occupyingRecord) + record.key = targetKey + } + } else { + record.key = targetKey + } + } + + if didChange { + try modelContext.save() + } + } catch { + // Leave the store untouched; hydration falls back to a fresh deal. + } + } + + private static func fetchRecord(forKey key: String, in modelContext: ModelContext) throws -> SavedGameRecord? { var descriptor = FetchDescriptor( predicate: #Predicate { record in record.key == key @@ -551,15 +660,21 @@ struct GameStatistics: Codable, Equatable { } enum GameStatisticsStore { - static func defaultsKey(for variant: GameVariant) -> String { - "stats.gameStatistics.\(variant.rawValue)" + /// Key of the pooled Klondike bucket used before statistics became per-mode. + static let legacyKlondikeDefaultsKey = "stats.gameStatistics.klondike" + + /// Key of the pooled Spider bucket used before statistics became per-mode. + static let legacySpiderDefaultsKey = "stats.gameStatistics.spider" + + static func defaultsKey(for mode: GameMode) -> String { + "stats.gameStatistics.\(mode.rawValue)" } static func load( - for variant: GameVariant, + for mode: GameMode, userDefaults: UserDefaults = .standard ) -> GameStatistics { - guard let data = userDefaults.data(forKey: defaultsKey(for: variant)), + guard let data = userDefaults.data(forKey: defaultsKey(for: mode)), let stats = try? JSONDecoder().decode(GameStatistics.self, from: data), stats.schemaVersion == GameStatistics.currentSchemaVersion else { return GameStatistics() @@ -569,39 +684,102 @@ enum GameStatisticsStore { static func save( _ stats: GameStatistics, - for variant: GameVariant, + for mode: GameMode, userDefaults: UserDefaults = .standard ) { guard let data = try? JSONEncoder().encode(stats) else { return } - userDefaults.set(data, forKey: defaultsKey(for: variant)) + userDefaults.set(data, forKey: defaultsKey(for: mode)) } static func update( - for variant: GameVariant, + for mode: GameMode, userDefaults: UserDefaults = .standard, _ mutate: (inout GameStatistics) -> Void ) { - var stats = load(for: variant, userDefaults: userDefaults) + var stats = load(for: mode, userDefaults: userDefaults) mutate(&stats) - save(stats, for: variant, userDefaults: userDefaults) + save(stats, for: mode, userDefaults: userDefaults) } static func markTrackingStarted( - for variant: GameVariant, + for mode: GameMode, userDefaults: UserDefaults = .standard, at date: Date = .now ) { - update(for: variant, userDefaults: userDefaults) { stats in + update(for: mode, userDefaults: userDefaults) { stats in stats.markTrackingStarted(at: date) } } static func reset( - for variant: GameVariant, + for mode: GameMode, userDefaults: UserDefaults = .standard, at date: Date = .now ) { - save(GameStatistics(trackedSince: date), for: variant, userDefaults: userDefaults) + save(GameStatistics(trackedSince: date), for: mode, userDefaults: userDefaults) + } + + /// Splits the pooled pre-per-mode Klondike bucket. Games, wins, and time + /// were pooled across draw modes and are assigned to the mode in active + /// use; high scores were always recorded per mode and go to their own + /// buckets. + // TODO: Remove (with `legacyKlondikeDefaultsKey`) once upgrades from + // pre-per-mode releases no longer need supporting. + static func migrateLegacyKlondikeStatisticsIfNeeded( + activeDrawMode: DrawMode, + userDefaults: UserDefaults = .standard + ) { + guard let data = userDefaults.data(forKey: legacyKlondikeDefaultsKey) else { return } + userDefaults.removeObject(forKey: legacyKlondikeDefaultsKey) + guard let legacy = try? JSONDecoder().decode(GameStatistics.self, from: data), + legacy.schemaVersion == GameStatistics.currentSchemaVersion else { + return + } + + let activeMode = GameMode(variant: .klondike, drawMode: activeDrawMode) + let otherMode: GameMode = activeMode == .klondikeDrawOne ? .klondikeDrawThree : .klondikeDrawOne + + var activeStats = legacy + var otherStats = GameStatistics(trackedSince: legacy.trackedSince) + if activeMode == .klondikeDrawOne { + activeStats.highScoreDrawThree = nil + otherStats.highScoreDrawThree = legacy.highScoreDrawThree + } else { + activeStats.highScoreDrawOne = nil + otherStats.highScoreDrawOne = legacy.highScoreDrawOne + } + + save(activeStats, for: activeMode, userDefaults: userDefaults) + save(otherStats, for: otherMode, userDefaults: userDefaults) + } + + /// Splits the pooled pre-per-mode Spider bucket, mirroring the Klondike + /// migration. Games, wins, and time were pooled across suit counts and + /// are assigned to the mode in active use; high scores were always + /// recorded per suit count and go to their own buckets. + // TODO: Remove (with `legacySpiderDefaultsKey`) once upgrades from + // pre-per-mode releases no longer need supporting. + static func migrateLegacySpiderStatisticsIfNeeded( + activeSuitCount: SpiderSuitCount, + userDefaults: UserDefaults = .standard + ) { + guard let data = userDefaults.data(forKey: legacySpiderDefaultsKey) else { return } + userDefaults.removeObject(forKey: legacySpiderDefaultsKey) + guard let legacy = try? JSONDecoder().decode(GameStatistics.self, from: data), + legacy.schemaVersion == GameStatistics.currentSchemaVersion else { + return + } + + let activeMode = GameMode(variant: .spider, spiderSuitCount: activeSuitCount) + for mode in GameMode.modes(for: .spider) { + var stats = mode == activeMode + ? legacy + : GameStatistics(trackedSince: legacy.trackedSince) + stats.highScoreOneSuit = mode == .spiderOneSuit ? legacy.highScoreOneSuit : nil + stats.highScoreTwoSuits = mode == .spiderTwoSuits ? legacy.highScoreTwoSuits : nil + stats.highScoreFourSuits = mode == .spiderFourSuits ? legacy.highScoreFourSuits : nil + save(stats, for: mode, userDefaults: userDefaults) + } } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index ddc4514..aeac396 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -70,13 +70,24 @@ final class SolitaireViewModel { redealState = initialState gameStartedAt = startedAt hasStartedTrackedGame = false - GameStatisticsStore.markTrackingStarted(for: variant, at: startedAt) + GameStatisticsStore.markTrackingStarted(for: gameMode, at: startedAt) } var gameVariant: GameVariant { state.variant } + /// The game this session currently hosts; keys its save slot and its + /// statistics bucket. Spider's suit count is derived from the deal; the + /// draw count is session state. + var gameMode: GameMode { + GameMode( + variant: state.variant, + drawMode: DrawMode(rawValue: stockDrawCount) ?? .three, + spiderSuitCount: state.spiderSuitCount ?? .two + ) + } + var isWin: Bool { state.isWon } @@ -213,15 +224,31 @@ final class SolitaireViewModel { return true } - func newGame( - variant: GameVariant? = nil, - drawMode: DrawMode = .three, - spiderSuitCount: SpiderSuitCount = .two - ) { + /// Finalizes the current game into its statistics and deals a fresh one; + /// `nil` replays the current mode. + func newGame(mode: GameMode? = nil) { finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) + startGame(mode: mode ?? gameMode) + } + + /// Activates `mode` without finalizing the current game's statistics, restoring the + /// mode's stashed session when one is available. Deals a fresh game when `payload` + /// is missing, belongs to another game, or fails restore sanitization. + @discardableResult + func activateGame(_ mode: GameMode, restoringFrom payload: SavedGamePayload?) -> Bool { + if let payload, payload.gameMode == mode, restore(from: payload) { + return true + } + startGame(mode: mode) + return false + } + + private func startGame(mode: GameMode) { clearHint() - let nextVariant = variant ?? state.variant - let initialState = GameState.newGame(variant: nextVariant, spiderSuitCount: spiderSuitCount) + let initialState = GameState.newGame( + variant: mode.variant, + spiderSuitCount: mode.spiderSuitCount ?? .two + ) state = initialState redealState = initialState selection = nil @@ -233,8 +260,8 @@ final class SolitaireViewModel { hasAppliedTimeBonus = false finalElapsedSeconds = nil pauseStartedAt = nil - applyNewGameVariantConfiguration(variant: nextVariant, drawMode: drawMode) - GameStatisticsStore.markTrackingStarted(for: nextVariant, at: gameStartedAt) + applyNewGameVariantConfiguration(variant: mode.variant, drawMode: mode.drawMode ?? .three) + GameStatisticsStore.markTrackingStarted(for: gameMode, at: gameStartedAt) hasStartedTrackedGame = true isCurrentGameFinalized = false hintRequestsInCurrentGame = 0 @@ -258,7 +285,7 @@ final class SolitaireViewModel { finalElapsedSeconds = nil pauseStartedAt = nil applyRedealVariantConfiguration() - GameStatisticsStore.markTrackingStarted(for: state.variant, at: gameStartedAt) + GameStatisticsStore.markTrackingStarted(for: gameMode, at: gameStartedAt) hasStartedTrackedGame = true isCurrentGameFinalized = false hintRequestsInCurrentGame = 0 @@ -328,7 +355,7 @@ final class SolitaireViewModel { } stockDrawCount = sanitizedPayload.stockDrawCount scoringDrawCount = sanitizedPayload.scoringDrawCount - GameStatisticsStore.markTrackingStarted(for: state.variant, at: gameStartedAt) + GameStatisticsStore.markTrackingStarted(for: gameMode, at: gameStartedAt) hasStartedTrackedGame = sanitizedPayload.hasStartedTrackedGame isCurrentGameFinalized = sanitizedPayload.isCurrentGameFinalized hintRequestsInCurrentGame = sanitizedPayload.hintRequestsInCurrentGame @@ -607,13 +634,12 @@ final class SolitaireViewModel { } } + /// The draw-mode basis statistics record under: always the game's own + /// mode, so the bucket and the high-score field it routes to can never + /// diverge — legacy saves may carry a `scoringDrawCount` that differs + /// from the mode they live and display as. private func statisticsDrawCountForCurrentVariant() -> Int { - switch state.variant { - case .klondike: - return scoringDrawCount - case .freecell, .yukon, .spider, .pyramid, .tripeaks: - return 0 - } + gameMode.drawMode?.rawValue ?? 0 } func refreshAutoFinishAvailability() { @@ -948,7 +974,7 @@ extension SolitaireViewModel { func finalizeCurrentGameIfNeeded(didWin: Bool, endedAt: Date) { guard hasStartedTrackedGame, !isCurrentGameFinalized else { return } let elapsedSeconds = elapsedActiveSeconds(at: endedAt) - GameStatisticsStore.update(for: state.variant) { stats in + GameStatisticsStore.update(for: gameMode) { stats in stats.recordCompletedGame( didWin: didWin, elapsedSeconds: elapsedSeconds, diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index b7d5d54..b7b5186 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -1,12 +1,15 @@ import Foundation +/// Cases are declared in presentation order — most-played game types first — +/// and every list in the app (picker, menus, statistics) follows it. Slot new +/// variants by how widely played they are, not at the end. enum GameVariant: String, CaseIterable, Codable { case klondike - case freecell - case yukon case spider - case pyramid + case freecell case tripeaks + case pyramid + case yukon var title: String { switch self { diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift index 9c5d691..4a0ca6a 100644 --- a/ComputerSolitaire/GameMenuCommands.swift +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -2,6 +2,7 @@ import SwiftUI #if os(macOS) struct GameMenuActions { + var switchVariant: (GameVariant) -> Void var newGame: () -> Void var redeal: () -> Void var undo: () -> Void @@ -11,6 +12,7 @@ struct GameMenuActions { } struct GameMenuState { + var currentVariant: GameVariant var canUndo: Bool var canAutoFinish: Bool var canHint: Bool @@ -44,6 +46,21 @@ struct GameMenuCommands: Commands { var body: some Commands { CommandMenu("Game") { + Picker("Game Mode", selection: Binding( + get: { state?.currentVariant ?? .klondike }, + set: { actions?.switchVariant($0) } + )) { + ForEach(Array(GameVariant.allCases.enumerated()), id: \.element) { index, variant in + Text(variant.title) + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + .tag(variant) + } + } + .pickerStyle(.inline) + .disabled(actions == nil) + + Divider() + Button { actions?.newGame() } label: { diff --git a/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift index bd40e7f..09ec26f 100644 --- a/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift +++ b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift @@ -25,8 +25,12 @@ struct PyramidBoardView: View { let boardHeight = cardSize.height + rowOverlap * CGFloat(PyramidGeometry.rowCount - 1) ZStack(alignment: .topLeading) { - ForEach(0.. some View { - Button { - guard gameVariantRawValue != variant.rawValue else { return } - HapticManager.shared.play(.settingsSelection) - withAnimation(.smooth(duration: 0.3)) { - gameVariantRawValue = variant.rawValue - } - } label: { - VStack(spacing: 3) { - Text(variant.title) - .font(.subheadline.weight(.bold)) - .lineLimit(1) - .minimumScaleFactor(0.75) - - Text(variant.subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - } - .settingsChip(isSelected: gameVariantRawValue == variant.rawValue) - } - .buttonStyle(.plain) - .accessibilityAddTraits(gameVariantRawValue == variant.rawValue ? .isSelected : []) - } - private func cardStyleCard(_ style: CardStyle) -> some View { let isSelected = cardStyleRawValue == style.rawValue @@ -378,7 +305,7 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } - .settingsChip(isSelected: isSelected) + .selectionChip(isSelected: isSelected) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) @@ -474,32 +401,6 @@ struct SettingsView: View { } } -private extension View { - func settingsChip(isSelected: Bool) -> some View { - self - .padding(.vertical, 10) - .padding(.horizontal, 10) - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - isSelected - ? AnyShapeStyle(Color.accentColor.opacity(0.12)) - : AnyShapeStyle(.quaternary.opacity(0.5)) - ) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke( - isSelected ? Color.accentColor : Color.primary.opacity(0.1), - lineWidth: isSelected ? 2 : 1 - ) - } - .opacity(isSelected ? 1 : 0.75) - .contentShape(Rectangle()) - } -} - #Preview { NavigationStack { SettingsView() diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index e5df31d..bc6cdc4 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -190,46 +190,63 @@ enum Layout { } struct HeaderView: View { + let gameTitle: String + /// The mode qualifier shown dimmed after the title ("3-card"); nil for + /// single-mode games. + let gameQualifier: String? let movesCount: Int let elapsedSeconds: Int let score: Int + let onGameTitleTapped: () -> Void let onScoreTapped: () -> Void + // The title gets its own row so the stat strip keeps the full board + // width and stays balanced over the board; sharing a row would push the + // strip off the board's centerline. var body: some View { - HStack(spacing: 10) { - StatTileView( - title: "Moves", - value: "\(movesCount)", - systemImage: "arrow.left.arrow.right" - ) - - StatTileView( - title: "Time", - value: formattedDuration(elapsedSeconds), - systemImage: "timer" - ) - - Button(action: onScoreTapped) { - StatTileView( - title: "Score", - value: "\(score)", - systemImage: "star.fill" - ) + VStack(alignment: .leading, spacing: 6) { + gameTitleButton + .padding(.leading, 4) + HStack(spacing: 10) { + statTiles } - .buttonStyle(.plain) - .accessibilityLabel("Score \(score). Open scoring details") + .headerContainer() } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.black.opacity(0.16)) + } + + private var gameTitleButton: some View { + Button(action: onGameTitleTapped) { + GameTitleView(title: gameTitle, qualifier: gameQualifier) + } + .buttonStyle(.plain) + .accessibilityLabel("Game: \(accessibilityGameName). Switch game mode") + } + + private var accessibilityGameName: String { + guard let gameQualifier else { return gameTitle } + return "\(gameTitle), \(gameQualifier)" + } + + @ViewBuilder + private var statTiles: some View { + StatTileView( + title: "Moves", + value: "\(movesCount)" ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(.white.opacity(0.1), lineWidth: 1) + + StatTileView( + title: "Time", + value: formattedDuration(elapsedSeconds) ) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + + Button(action: onScoreTapped) { + StatTileView( + title: "Score", + value: "\(score)" + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Score \(score). Open scoring details") } private func formattedDuration(_ totalSeconds: Int) -> String { @@ -249,19 +266,73 @@ struct HeaderView: View { } } +private extension View { + /// The header strip's outer chrome: translucent dark rounded container. + func headerContainer() -> some View { + self + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.black.opacity(0.16)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + } +} + +/// The current game's name set directly on the felt, like a card table's +/// engraved branding. The mode qualifier renders dimmed — it modifies the +/// name, it isn't part of it. Chevron signals the tap-to-switch affordance. +struct GameTitleView: View { + let title: String + let qualifier: String? + + var body: some View { + HStack(spacing: 5) { + Text(styledTitle) + .font(.system(size: 14, weight: .heavy, design: .rounded)) + .tracking(1.5) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) + + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + } + .shadow(color: .black.opacity(0.15), radius: 1, y: 1) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + + private var styledTitle: AttributedString { + var styled = AttributedString(title.uppercased()) + if let qualifier { + var suffix = AttributedString(" · \(qualifier.uppercased())") + suffix.foregroundColor = .white.opacity(0.5) + styled += suffix + } + return styled + } +} + +/// One stat in the header strip: a quiet uppercase label over its value, +/// echoing the game title's felt-set typography. The strip's container is +/// the only chrome — tiles are bare type. struct StatTileView: View { let title: String let value: String - let systemImage: String - var isEmphasized: Bool = false var body: some View { VStack(alignment: .leading, spacing: 2) { - Label(title, systemImage: systemImage) + Text(title.uppercased()) .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) + .tracking(0.8) + .foregroundStyle(.white.opacity(0.65)) .lineLimit(1) - .minimumScaleFactor(0.85) Text(value) .font(.system(size: 18, weight: .semibold, design: .rounded)) @@ -270,17 +341,7 @@ struct StatTileView: View { .lineLimit(1) .minimumScaleFactor(0.75) } - .padding(.horizontal, 10) - .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(isEmphasized ? .white.opacity(0.16) : .white.opacity(0.07)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(.white.opacity(isEmphasized ? 0.16 : 0.09), lineWidth: 1) - ) } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index d7cc1e2..dbd0a1d 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -123,6 +123,7 @@ struct ContentView: View { @State private var autosaveTask: Task? @State private var isAutoFinishing = false @State private var isShowingRulesAndScoring = false + @State private var isShowingGamePicker = false @State private var rulesAndScoringInitialSection: RulesAndScoringView.Section = .rules @State private var isShowingStats = false @State private var timeScoringPauseReasons: Set = [] @@ -135,6 +136,8 @@ struct ContentView: View { @AppStorage(SettingsKey.spiderSuitCount) private var spiderSuitCountRawValue = SpiderSuitCount.two.rawValue @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue private var gameVariant: GameVariant { GameVariant(rawValue: gameVariantRawValue) ?? .klondike @@ -154,7 +157,7 @@ struct ContentView: View { } private var isAnyMenuPresented: Bool { - isShowingSettings || isShowingRulesAndScoring || isShowingStats + isShowingSettings || isShowingRulesAndScoring || isShowingStats || isShowingGamePicker } private var shouldPauseForLifecycle: Bool { @@ -181,6 +184,28 @@ struct ContentView: View { .environment(\.cardStyle, currentCardStyle) ) ) + .overlay { + if isShowingGamePicker { + GameModePickerOverlay( + entries: gameModePickerEntries(), + currentMode: viewModel.gameMode, + feltColor: (TableBackgroundColor(rawValue: tableBackgroundColorRawValue) + ?? .defaultValue).color, + onSelect: { mode in + withAnimation(.smooth(duration: 0.25)) { + isShowingGamePicker = false + } + requestGameSwitch(to: mode) + }, + onDismiss: { + withAnimation(.smooth(duration: 0.25)) { + isShowingGamePicker = false + } + } + ) + .transition(.opacity) + } + } } private func sceneDecorations(for baseView: AnyView) -> some View { @@ -302,6 +327,36 @@ struct ContentView: View { ) } + private func gameModePickerEntries() -> [GameModePickerView.Entry] { + GameMode.allCases.map { mode in + GameModePickerView.Entry(mode: mode, isWon: gameModeIsWon(mode)) + } + } + + /// The sub-game a variant's picker card opens: the live game's mode when + /// the variant is active, else the last-played mode. + private func defaultMode(for variant: GameVariant) -> GameMode { + if viewModel.gameVariant == variant { + return viewModel.gameMode + } + return GameMode(variant: variant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) + } + + private func gameModeIsWon(_ mode: GameMode) -> Bool { + if mode == viewModel.gameMode { + return viewModel.isWin + } + return GamePersistence.load(mode: mode, from: modelContext)?.state.isWon ?? false + } + + /// UI entry point for switching games. Performs the switch and keeps the + /// AppStorage selection in sync. + private func requestGameSwitch(to mode: GameMode) { + guard mode != viewModel.gameMode else { return } + hapticFeedback.play(.settingsSelection) + switchGame(to: mode) + } + private func applySheets(to view: AnyView) -> AnyView { AnyView( view.sheet(isPresented: $isShowingSettings) { @@ -321,10 +376,10 @@ struct ContentView: View { .sheet(isPresented: $isShowingStats) { #if os(iOS) NavigationStack { - StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) + StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) } #else - StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) + StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) #endif } ) @@ -346,38 +401,15 @@ struct ContentView: View { .onChange(of: gameVariantRawValue) { _, newValue in guard hasLoadedGame, !isHydratingGame else { return } let variant = GameVariant(rawValue: newValue) ?? .klondike - // Restoring a game whose variant differs from the stored setting - // syncs the setting to the game; that write lands here after - // hydration ends and must not re-deal over the restored board. guard variant != viewModel.gameVariant else { return } - stopAutoFinish() - winCelebration.reset(to: .idle) - isScreenshotSession = false - viewModel.newGame(variant: variant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) - persistGameNow() - } - .onChange(of: drawModeRawValue) { (_, newValue: Int) in - guard viewModel.supportsDrawMode else { return } - let mode = DrawMode(rawValue: newValue) ?? .three - viewModel.updateDrawMode(mode) - scheduleAutosave() + switchGame(to: defaultMode(for: variant)) } .onChange(of: spiderSuitCountRawValue) { _, newValue in guard hasLoadedGame, !isHydratingGame else { return } - // Unlike a draw-mode change, a suit-count change recomposes the - // deck, so it always starts a new game (as variant changes do). guard viewModel.gameVariant == .spider else { return } let suitCount = SpiderSuitCount(rawValue: newValue) ?? .two - // Restoring a game whose suit count differs from the stored - // setting syncs the setting to the game; that write lands here - // after hydration ends and must not re-deal over the restored - // board. guard suitCount != viewModel.state.spiderSuitCount else { return } - stopAutoFinish() - winCelebration.reset(to: .idle) - isScreenshotSession = false - viewModel.newGame(variant: .spider, drawMode: drawMode, spiderSuitCount: suitCount) - persistGameNow() + switchGame(to: GameMode(variant: .spider, spiderSuitCount: suitCount)) } .onChange(of: isAnyMenuPresented) { _, _ in updateMenuPresentationPauseState() @@ -727,10 +759,17 @@ struct ContentView: View { boardContentWidth: CGFloat, onScoreTapped: @escaping () -> Void ) -> some View { - HeaderView( + return HeaderView( + gameTitle: viewModel.gameVariant.title, + gameQualifier: viewModel.gameMode.qualifier, movesCount: viewModel.movesCount, elapsedSeconds: elapsedSeconds, score: score, + onGameTitleTapped: { + withAnimation(.smooth(duration: 0.25)) { + isShowingGamePicker = true + } + }, onScoreTapped: onScoreTapped ) .frame(width: boardContentWidth, alignment: .leading) @@ -777,6 +816,7 @@ struct ContentView: View { #if os(macOS) private var gameMenuActions: GameMenuActions { GameMenuActions( + switchVariant: { requestGameSwitch(to: defaultMode(for: $0)) }, newGame: { startNewGameFromUI() }, redeal: redealFromUI, undo: { @@ -797,6 +837,7 @@ struct ContentView: View { private var gameMenuState: GameMenuState { GameMenuState( + currentVariant: gameVariant, canUndo: !isUndoDisabled, canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, canHint: !isHintDisabled, @@ -812,15 +853,29 @@ struct ContentView: View { viewModel.requestHint() } - private func startNewGameFromUI(variant: GameVariant? = nil) { + private func startNewGameFromUI() { stopAutoFinish() winCelebration.reset(to: .idle) isScreenshotSession = false - let selectedVariant = variant ?? gameVariant - viewModel.newGame(variant: selectedVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) + viewModel.newGame() persistGameNow() } + /// Syncs the stored game selection (variant plus per-variant configuration) + /// to the game now in play, so relaunches and picker defaults follow it. + private func rememberSelectedGame() { + if gameVariantRawValue != viewModel.gameVariant.rawValue { + gameVariantRawValue = viewModel.gameVariant.rawValue + } + if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { + drawModeRawValue = viewModel.stockDrawCount + } + if let suitCount = viewModel.state.spiderSuitCount, + spiderSuitCountRawValue != suitCount.rawValue { + spiderSuitCountRawValue = suitCount.rawValue + } + } + private func redealFromUI() { stopAutoFinish() winCelebration.reset(to: .idle) @@ -828,6 +883,61 @@ struct ContentView: View { persistGameNow() } + /// Stashes the current game into its own save slot, then resumes the target + /// game's stashed session (or deals fresh). Never records statistics — + /// switching games is not abandoning a game. + private func switchGame(to mode: GameMode) { + // Stash before any teardown: if persistence fails, the switch is + // abandoned and the live session keeps playing with nothing lost. + guard persistGameNow() else { return } + stopAutoFinish() + winCelebration.reset(to: .idle) + resetTransientBoardState() + isHydratingGame = true + isScreenshotSession = false + let payload = GamePersistence.load(mode: mode, from: modelContext) + viewModel.activateGame(mode, restoringFrom: payload) + rememberSelectedGame() + reconcileTimeScoringPause() + winCelebration.syncForLoadedGame( + launchPiles: winCascadeLaunchPiles, + launchTargets: winCascadeLaunchTargets, + isWin: viewModel.isWin, + dropFrames: dropFrames, + boardViewportSize: boardViewportSize + ) + previousWasteCount = viewModel.state.waste.count + previousStockCount = viewModel.state.stock.count + isHydratingGame = false + persistGameNow() + } + + /// Clears in-flight drag/drop/undo/draw animation state so stale animation + /// completions cannot mutate the game that replaces the current one. + private func resetTransientBoardState() { + activeTarget = nil + dragTranslation = .zero + dragReturnOffset = .zero + isReturningDrag = false + returningCards = [] + isDroppingCards = false + droppingSelection = nil + dropAnimationOffset = .zero + pendingDropDestination = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil + drawAnimationCards = [] + drawingCardIDs = [] + drawAnimationToken = UUID() + undoAnimationItems = [] + undoAnimationTargets = [:] + undoAnimationProgress = 0 + isUndoAnimating = false + hiddenCardIDs = [] + wasteFanProgress = [:] + hintHighlightOpacity = 0 + } + private func startAutoFinish() { guard !isAutoFinishDisabled else { return } isAutoFinishing = true @@ -1450,22 +1560,24 @@ struct ContentView: View { previousStockCount = viewModel.state.stock.count } + GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) + GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded(activeDrawMode: drawMode) + GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded(activeSuitCount: spiderSuitCount) + + // The stored selection decides which game's slot the app opens into. + let storedMode = GameMode( + variant: gameVariant, + drawMode: drawMode, + spiderSuitCount: spiderSuitCount + ) if restoreScreenshotFixtureIfRequested() { // Staged board loaded; shared post-load setup below still applies. - } else if let payload = GamePersistence.load(from: modelContext), viewModel.restore(from: payload) { - if gameVariantRawValue != viewModel.gameVariant.rawValue { - gameVariantRawValue = viewModel.gameVariant.rawValue - } - if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { - drawModeRawValue = viewModel.stockDrawCount - } - if let restoredSuitCount = viewModel.state.spiderSuitCount, - spiderSuitCountRawValue != restoredSuitCount.rawValue { - spiderSuitCountRawValue = restoredSuitCount.rawValue - } + } else if let payload = GamePersistence.load(mode: storedMode, from: modelContext), + viewModel.restore(from: payload) { + rememberSelectedGame() } else { winCelebration.reset(to: .idle) - viewModel.newGame(variant: gameVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) + viewModel.newGame(mode: storedMode) persistGameNow() } winCelebration.syncForLoadedGame( @@ -1476,6 +1588,14 @@ struct ContentView: View { boardViewportSize: boardViewportSize ) + reconcileTimeScoringPause() + } + + /// Rebuilds the pause-reason set from the current scene and menu state + /// and applies it to the session, resuming as well as pausing: a restored + /// payload can carry a pause from stash time that no present reason + /// justifies, and it must not stay frozen. + private func reconcileTimeScoringPause() { timeScoringPauseReasons = [] if shouldPauseForLifecycle { timeScoringPauseReasons.insert(.lifecycle) @@ -1580,34 +1700,31 @@ struct ContentView: View { return false } isScreenshotSession = true - if gameVariantRawValue != viewModel.gameVariant.rawValue { - gameVariantRawValue = viewModel.gameVariant.rawValue - } - if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { - drawModeRawValue = viewModel.stockDrawCount - } - if let restoredSuitCount = viewModel.state.spiderSuitCount, - spiderSuitCountRawValue != restoredSuitCount.rawValue { - spiderSuitCountRawValue = restoredSuitCount.rawValue - } + rememberSelectedGame() return true #else return false #endif } - private func persistGameNow() { - guard hasLoadedGame else { return } + /// Returns whether the session is safely on disk — true also when no + /// save was required (nothing loaded yet, or a screenshot board that + /// must never overwrite the real one). + @discardableResult + private func persistGameNow() -> Bool { + guard hasLoadedGame else { return true } // A screenshot session must never overwrite the real saved game. - guard !isScreenshotSession else { return } + guard !isScreenshotSession else { return true } autosaveTask?.cancel() autosaveTask = nil do { try GamePersistence.save(viewModel.persistencePayload(), in: modelContext) + return true } catch { #if DEBUG print("Failed to persist game state: \(error)") #endif + return false } } } diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift new file mode 100644 index 0000000..6f46b0e --- /dev/null +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -0,0 +1,530 @@ +import SwiftUI + +/// Game switcher presented centered over the board, organized in two levels: +/// a gallery of game families (art, name, description) and — for families +/// with multiple modes — a detail step listing each mode as a full row. +/// The ring plus checkmark mark the current game. Every game auto-resumes, +/// so being mid-game is the unremarkable default and gets no marker; the one +/// badge is "Won" — rare, temporary, and a cue that the game wants a fresh +/// deal. Single-mode games play directly from the gallery. Selection is +/// reported via `onSelect`. +struct GameModePickerView: View { + struct Entry: Identifiable { + let mode: GameMode + let isWon: Bool + + var id: GameMode { mode } + } + + let entries: [Entry] + let currentMode: GameMode + let feltColor: Color + let onSelect: (GameMode) -> Void + + /// The family whose modes the detail step shows; nil shows the gallery. + @State private var drilledFamily: GameVariant? + + var body: some View { + Group { + if let family = drilledFamily { + familyDetail(family) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + familyGallery + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + .padding(14) + } + + // MARK: - Family gallery + + private var familyGallery: some View { + VStack(spacing: 10) { + ForEach(GameVariant.allCases, id: \.self) { variant in + familyCard(variant) + } + } + } + + private func familyCard(_ variant: GameVariant) -> some View { + let modes = GameMode.modes(for: variant) + let isMultiMode = modes.count > 1 + let isActiveFamily = currentMode.variant == variant + + return Button { + if isMultiMode { + withAnimation(.smooth(duration: 0.25)) { + drilledFamily = variant + } + } else if let mode = modes.first { + onSelect(mode) + } + } label: { + HStack(spacing: 12) { + MiniBoardView(variant: variant, feltColor: feltColor, scale: 0.62) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(variant.title) + .font(.system(.subheadline, design: .rounded, weight: .bold)) + .foregroundStyle(.primary) + + Spacer(minLength: 8) + + if familyHasWonGame(variant) { + wonBadge + .fixedSize() + } + } + + Text(variant.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if isActiveFamily { + Image(systemName: "checkmark") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + + if isMultiMode { + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .selectionChip(isSelected: isActiveFamily) + } + .buttonStyle(.plain) + .accessibilityLabel(familyAccessibilityLabel(for: variant)) + .accessibilityAddTraits(isActiveFamily ? .isSelected : []) + } + + private func familyHasWonGame(_ variant: GameVariant) -> Bool { + entries.contains { $0.mode.variant == variant && $0.isWon } + } + + private func familyAccessibilityLabel(for variant: GameVariant) -> String { + var label = variant.title + if familyHasWonGame(variant) { + label += ", Won" + } + if currentMode.variant == variant { + label += ", current game" + } + if GameMode.modes(for: variant).count > 1 { + label += ", opens mode list" + } + return label + } + + // MARK: - Family detail + + private func familyDetail(_ variant: GameVariant) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Button { + withAnimation(.smooth(duration: 0.25)) { + drilledFamily = nil + } + } label: { + Label("Games", systemImage: "chevron.left") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + + Spacer(minLength: 8) + + Text(variant.title) + .font(.system(.subheadline, design: .rounded, weight: .bold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 2) + + HStack(spacing: 12) { + MiniBoardView(variant: variant, feltColor: feltColor) + + Text(variant.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + } + + VStack(spacing: 8) { + ForEach(GameMode.modes(for: variant), id: \.self) { mode in + modeRow(mode) + } + } + } + } + + private func modeRow(_ mode: GameMode) -> some View { + let isActive = mode == currentMode + + return Button { + onSelect(mode) + } label: { + HStack(spacing: 8) { + Text(mode.optionTitle) + .font(.system(.subheadline, design: .rounded, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 8) + + if isWon(mode) { + wonBadge + .fixedSize() + } + + if isActive { + Image(systemName: "checkmark") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + } + .selectionChip(isSelected: isActive) + } + .buttonStyle(.plain) + .accessibilityLabel(modeAccessibilityLabel(for: mode, isActive: isActive)) + .accessibilityAddTraits(isActive ? .isSelected : []) + } + + private func modeAccessibilityLabel(for mode: GameMode, isActive: Bool) -> String { + var label = mode.displayTitle + if isWon(mode) { + label += ", Won" + } + if isActive { + label += ", current game" + } + return label + } + + // MARK: - Shared + + private func isWon(_ mode: GameMode) -> Bool { + entries.first(where: { $0.mode == mode })?.isWon ?? false + } + + private var wonBadge: some View { + HStack(spacing: 4) { + Circle() + .fill(.yellow) + .frame(width: 5, height: 5) + + Text("Won") + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .foregroundStyle(.primary.opacity(0.9)) + } + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(.white.opacity(0.1))) + } +} + +/// Centered presentation of the game mode picker over a dimmed board, +/// matching the win overlay's chrome. +struct GameModePickerOverlay: View { + let entries: [GameModePickerView.Entry] + let currentMode: GameMode + let feltColor: Color + let onSelect: (GameMode) -> Void + let onDismiss: () -> Void + + /// The overlay takes keyboard focus while presented so Escape reaches it; + /// a custom overlay sits outside the window's cancel-action routing that + /// sheets get for free. + @FocusState private var isPickerFocused: Bool + + var body: some View { + ZStack { + // The scrim is the picker's cancel button: clicking outside the + // panel dismisses, matching a system presentation. + Button(action: onDismiss) { + Color.black.opacity(0.35) + .ignoresSafeArea() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss game picker") + + // Short windows (macOS near its minimum size, phone landscape, + // large accessibility text) can't fit the whole picker; fall back + // to scrolling the same content rather than clipping it. + ViewThatFits(in: .vertical) { + picker + + ScrollView { + picker + } + } + .frame(maxWidth: 360) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 24, y: 8) + ) + .environment(\.colorScheme, .dark) + .padding(24) + } + .focusable() + .focusEffectDisabled() + .focused($isPickerFocused) + .onKeyPress(.escape) { + onDismiss() + return .handled + } + .onAppear { isPickerFocused = true } + } + + private var picker: some View { + GameModePickerView( + entries: entries, + currentMode: currentMode, + feltColor: feltColor, + onSelect: onSelect + ) + } +} + +/// A miniature schematic of a variant's opening layout, drawn with tiny card +/// shapes on a felt swatch. +private struct MiniBoardView: View { + let variant: GameVariant + let feltColor: Color + var scale: CGFloat = 1 + + private enum MiniCard { + case faceUp + case faceDown + case slot + } + + // The swatches read as a set: one card size and stack offset for every + // variant, every board spanning the same content width, and the whole + // board centered in the swatch. Column spacing is the one per-variant + // knob — it flexes so denser games pack tighter, like the real table. + private var cardSize: CGSize { + CGSize(width: 8 * scale, height: 11.5 * scale) + } + + private var stackOffset: CGFloat { + 4 * scale + } + + private var contentWidth: CGFloat { + 98 * scale + } + + private var columnSpacing: CGFloat { + let columns = CGFloat(variant.boardColumnCount) + return (contentWidth - (columns * cardSize.width)) / (columns - 1) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4 * scale) { + topRow + switch variant { + case .pyramid: + pyramidRows + case .tripeaks: + triPeaksRows + case .klondike, .spider, .freecell, .yukon: + tableauRow + } + } + .frame(width: contentWidth) + .frame(width: 114 * scale, height: 80 * scale) + .background( + RoundedRectangle(cornerRadius: 9 * scale, style: .continuous) + .fill(feltColor) + .overlay( + RoundedRectangle(cornerRadius: 9 * scale, style: .continuous) + .fill(.black.opacity(0.1)) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 9 * scale, style: .continuous) + .stroke(.white.opacity(0.12), lineWidth: 1) + ) + .accessibilityHidden(true) + } + + private var topRow: some View { + HStack(spacing: columnSpacing) { + switch variant { + case .klondike: + miniCard(.faceDown) + miniCard(.slot) + Spacer(minLength: 0) + foundationSlots(count: 4) + case .freecell: + foundationSlots(count: 4) + Spacer(minLength: 0) + foundationSlots(count: 4) + case .yukon: + Spacer(minLength: 0) + foundationSlots(count: 4) + case .spider: + miniCard(.faceDown) + Spacer(minLength: 0) + foundationSlots(count: 8) + case .pyramid: + miniCard(.faceDown) + miniCard(.slot) + Spacer(minLength: 0) + miniCard(.slot) + case .tripeaks: + miniCard(.faceDown) + miniCard(.faceUp) + Spacer(minLength: 0) + } + } + } + + private func foundationSlots(count: Int) -> some View { + ForEach(0.. some View { + RoundedRectangle(cornerRadius: 2 * scale, style: .continuous) + .fill(fillStyle(for: kind)) + .overlay( + RoundedRectangle(cornerRadius: 2 * scale, style: .continuous) + .stroke( + style: StrokeStyle( + lineWidth: 0.75, + dash: kind == .slot ? [1.5, 1.5] : [] + ) + ) + .foregroundStyle(strokeStyle(for: kind)) + ) + .frame(width: cardSize.width, height: cardSize.height) + } + + private func fillStyle(for kind: MiniCard) -> AnyShapeStyle { + switch kind { + case .faceUp: + return AnyShapeStyle(.white.opacity(0.95)) + case .faceDown: + return AnyShapeStyle(Color(red: 0.18, green: 0.24, blue: 0.5)) + case .slot: + return AnyShapeStyle(.white.opacity(0.06)) + } + } + + private func strokeStyle(for kind: MiniCard) -> AnyShapeStyle { + switch kind { + case .faceUp: + return AnyShapeStyle(.black.opacity(0.35)) + case .faceDown: + return AnyShapeStyle(.white.opacity(0.35)) + case .slot: + return AnyShapeStyle(.white.opacity(0.4)) + } + } +} diff --git a/ComputerSolitaire/Views/Shared/SelectionChip.swift b/ComputerSolitaire/Views/Shared/SelectionChip.swift new file mode 100644 index 0000000..98b44e4 --- /dev/null +++ b/ComputerSolitaire/Views/Shared/SelectionChip.swift @@ -0,0 +1,29 @@ +import SwiftUI + +extension View { + /// The app's selectable-card chrome: rounded rectangle with an accent tint + /// and stroke when selected. Used by settings chips and the game mode picker. + func selectionChip(isSelected: Bool) -> some View { + self + .padding(.vertical, 10) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + isSelected + ? AnyShapeStyle(Color.accentColor.opacity(0.12)) + : AnyShapeStyle(.quaternary.opacity(0.5)) + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.1), + lineWidth: isSelected ? 2 : 1 + ) + } + .opacity(isSelected ? 1 : 0.75) + .contentShape(Rectangle()) + } +} diff --git a/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift b/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift index 80bc23b..0cff680 100644 --- a/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift +++ b/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift @@ -1,11 +1,13 @@ import SwiftUI -import Observation /// One of Spider's eight banked-run piles. Runs arrive here automatically, so /// unlike `FoundationView` this pile is never a tap, drag, or drop target; it /// still publishes its frames so the win cascade can launch cards from it. +/// The pile arrives by value — this view must stay renderable against any +/// game's state, because during a game switch it can re-evaluate after the +/// board's state has already changed variant. struct SpiderCompletedRunPileView: View { - @Bindable var viewModel: SolitaireViewModel + let pile: [Card] let index: Int let cardSize: CGSize let isCardTiltEnabled: Bool @@ -13,7 +15,6 @@ struct SpiderCompletedRunPileView: View { let hiddenCardIDs: Set var body: some View { - let pile = viewModel.state.foundations[index] let visibleDepth = min(pile.count, 4) let startIndex = pile.count - visibleDepth ZStack { @@ -61,7 +62,7 @@ struct SpiderCompletedRunPileView: View { } private var accessibilityValue: String { - guard let topCard = viewModel.state.foundations[index].last else { return "Empty" } + guard let topCard = pile.last else { return "Empty" } return "Full \(topCard.suit.accessibilityName) run" } } diff --git a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift index 03365c8..151cfe7 100644 --- a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift +++ b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift @@ -29,9 +29,13 @@ struct SpiderTopRowView: View { .frame(width: cardSize.width, height: cardSize.height) .accessibilityHidden(true) - ForEach(0..<8, id: \.self) { index in + // Iterate the piles the state actually holds, not a fixed 0..<8: + // during a game switch this row can re-evaluate against the + // incoming variant's four-foundation state before the board + // replaces it. + ForEach(Array(viewModel.state.foundations.enumerated()), id: \.offset) { index, pile in SpiderCompletedRunPileView( - viewModel: viewModel, + pile: pile, index: index, cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 71643f9..f90dc7b 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -7,6 +7,9 @@ struct StatisticsView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var selectedScope: Scope + /// The bucket within a multi-mode scope (Klondike draw counts, Spider + /// suit counts); ignored for single-mode scopes. + @State private var scopeMode: GameMode @State private var stats = GameStatistics() @State private var barHoverState: (label: String, x: CGFloat)? @State private var isShowingCleanWinsInfo = false @@ -20,12 +23,13 @@ struct StatisticsView: View { }() private enum Scope: String, CaseIterable, Identifiable { + // Mirrors GameVariant's presentation order, with the aggregate last. case klondike - case freecell - case yukon case spider - case pyramid + case freecell case tripeaks + case pyramid + case yukon case all var id: String { rawValue } @@ -84,9 +88,27 @@ struct StatisticsView: View { var id: String { label } } - init(viewModel: SolitaireViewModel?, initialVariant: GameVariant = .klondike) { + init(viewModel: SolitaireViewModel?, initialMode: GameMode = .klondikeDrawThree) { self.viewModel = viewModel - _selectedScope = State(initialValue: Scope(variant: initialVariant)) + _selectedScope = State(initialValue: Scope(variant: initialMode.variant)) + _scopeMode = State(initialValue: initialMode) + } + + /// The statistics bucket the current scope selection reads; nil for the + /// aggregate scope. + private var effectiveMode: GameMode? { + guard let variant = selectedScope.variant else { return nil } + let modes = GameMode.modes(for: variant) + guard modes.count > 1 else { return modes.first } + return modes.contains(scopeMode) ? scopeMode : GameMode(variant: variant) + } + + private var effectiveScopeTitle: String { + guard let variant = selectedScope.variant else { return "All" } + guard GameMode.modes(for: variant).count > 1, let mode = effectiveMode else { + return variant.title + } + return "\(variant.title) (\(mode.optionTitle))" } var body: some View { @@ -100,6 +122,16 @@ struct StatisticsView: View { } .pickerStyle(.segmented) .labelsHidden() + + if let variant = selectedScope.variant, GameMode.modes(for: variant).count > 1 { + Picker("Game Mode", selection: $scopeMode) { + ForEach(GameMode.modes(for: variant), id: \.self) { mode in + Text(mode.optionTitle).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } } Section { @@ -197,6 +229,14 @@ struct StatisticsView: View { loadStats() } .onChange(of: selectedScope) { _, _ in + if let variant = selectedScope.variant, + !GameMode.modes(for: variant).contains(scopeMode) { + scopeMode = GameMode(variant: variant) + } + loadStats() + barHoverState = nil + } + .onChange(of: scopeMode) { _, _ in loadStats() barHoverState = nil } @@ -268,26 +308,27 @@ struct StatisticsView: View { return durationLabel(bestTimeSeconds) } - /// Klondike splits its high score by draw mode and Spider by suit count - /// (scores across game modes aren't comparable); the other variants keep a - /// single high score. + /// Each mode bucket carries a single high score, routed to the field its + /// wins record into (Klondike per draw count, Spider per suit count). private var highScoreRowsForSelectedScope: [HighScoreRow] { - switch selectedScope { - case .klondike: - return [ - HighScoreRow(label: "High Score (3-card)", score: stats.highScoreDrawThree), - HighScoreRow(label: "High Score (1-card)", score: stats.highScoreDrawOne) - ] - case .freecell, .yukon, .pyramid, .tripeaks: - return [HighScoreRow(label: "High Score", score: stats.highScore)] - case .spider: - return [ - HighScoreRow(label: "High Score (1 Suit)", score: stats.highScoreOneSuit), - HighScoreRow(label: "High Score (2 Suits)", score: stats.highScoreTwoSuits), - HighScoreRow(label: "High Score (4 Suits)", score: stats.highScoreFourSuits) - ] - case .all: - return [] + guard let mode = effectiveMode else { return [] } + return [HighScoreRow(label: "High Score", score: highScore(for: mode))] + } + + private func highScore(for mode: GameMode) -> Int? { + switch mode { + case .klondikeDrawOne: + return stats.highScoreDrawOne + case .klondikeDrawThree: + return stats.highScoreDrawThree + case .spiderOneSuit: + return stats.highScoreOneSuit + case .spiderTwoSuits: + return stats.highScoreTwoSuits + case .spiderFourSuits: + return stats.highScoreFourSuits + case .freecell, .pyramid, .tripeaks, .yukon: + return stats.highScore } } @@ -327,7 +368,7 @@ struct StatisticsView: View { private func displayTotalTimeSeconds(at date: Date) -> Int { let liveElapsed: Int - if activeVariantMatchesSelectedScope { + if activeModeMatchesSelectedScope { liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 } else { liveElapsed = 0 @@ -402,15 +443,17 @@ struct StatisticsView: View { } private func resetStatistics() { - if let variant = selectedScope.variant { - GameStatisticsStore.reset(for: variant) - } else { - for variant in GameVariant.allCases { - GameStatisticsStore.reset(for: variant) - } + let affectedModes = effectiveMode.map { [$0] } ?? GameMode.allCases + for mode in affectedModes { + GameStatisticsStore.reset(for: mode) } - if selectedScope == .all || activeVariantMatchesSelectedScope { + // Stashed sessions of the affected modes must not finalize pre-reset + // play into the fresh buckets; the active session is handled below so + // its newer in-memory state wins the save slot. + GamePersistence.invalidateStatisticsTracking(for: affectedModes, in: modelContext) + + if selectedScope == .all || activeModeMatchesSelectedScope { viewModel?.resetStatisticsTracking() persistTrackingResetIfNeeded() } @@ -418,74 +461,35 @@ struct StatisticsView: View { barHoverState = nil } - private var activeVariantMatchesSelectedScope: Bool { - guard let variant = selectedScope.variant else { return true } - return viewModel?.gameVariant == variant + private var activeModeMatchesSelectedScope: Bool { + guard let mode = effectiveMode else { return true } + return viewModel?.gameMode == mode } private var resetDialogTitle: String { - switch selectedScope { - case .klondike: - return "Reset Klondike statistics?" - case .freecell: - return "Reset FreeCell statistics?" - case .yukon: - return "Reset Yukon statistics?" - case .spider: - return "Reset Spider statistics?" - case .pyramid: - return "Reset Pyramid statistics?" - case .tripeaks: - return "Reset TriPeaks statistics?" - case .all: - return "Reset all statistics?" - } + selectedScope == .all + ? "Reset all statistics?" + : "Reset \(effectiveScopeTitle) statistics?" } private var resetActionTitle: String { - switch selectedScope { - case .klondike: - return "Reset Klondike Statistics" - case .freecell: - return "Reset FreeCell Statistics" - case .yukon: - return "Reset Yukon Statistics" - case .spider: - return "Reset Spider Statistics" - case .pyramid: - return "Reset Pyramid Statistics" - case .tripeaks: - return "Reset TriPeaks Statistics" - case .all: - return "Reset All Statistics" - } + selectedScope == .all + ? "Reset All Statistics" + : "Reset \(effectiveScopeTitle) Statistics" } private var resetMessage: String { - switch selectedScope { - case .klondike: - return "This will reset only Klondike games, times, win rates, and high scores." - case .freecell: - return "This will reset only FreeCell games, times, win rates, and high scores." - case .yukon: - return "This will reset only Yukon games, times, win rates, and high scores." - case .spider: - return "This will reset only Spider games, times, win rates, and high scores." - case .pyramid: - return "This will reset only Pyramid games, times, win rates, and high scores." - case .tripeaks: - return "This will reset only TriPeaks games, times, win rates, and high scores." - case .all: - return "This will reset Klondike, FreeCell, Yukon, Spider, Pyramid, and TriPeaks statistics." - } + selectedScope == .all + ? "This will reset statistics for every game." + : "This will reset only \(effectiveScopeTitle) games, times, win rates, and high scores." } private func loadStats() { - if let variant = selectedScope.variant { - stats = GameStatisticsStore.load(for: variant) + if let mode = effectiveMode { + stats = GameStatisticsStore.load(for: mode) } else { stats = GameStatistics.aggregated( - GameVariant.allCases.map { GameStatisticsStore.load(for: $0) } + GameMode.allCases.map { GameStatisticsStore.load(for: $0) } ) } } diff --git a/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift index 0f487fd..aedbe46 100644 --- a/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift +++ b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift @@ -24,8 +24,12 @@ struct TriPeaksBoardView: View { let boardHeight = cardSize.height + rowOverlap * CGFloat(TriPeaksGeometry.rowCount - 1) ZStack(alignment: .topLeading) { - ForEach(0.. SolitaireViewModel { let viewModel = SolitaireViewModel(variant: .pyramid) - viewModel.newGame(variant: .pyramid) + viewModel.newGame(mode: .pyramid) return viewModel } diff --git a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift index ef07303..28f8b5e 100644 --- a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift @@ -4,9 +4,10 @@ import XCTest @MainActor final class GamePersistenceStoreTests: XCTestCase { - func testLoadReturnsNilWhenNoSavedRecord() throws { + func testLoadReturnsNilForVariantWithoutSave() throws { let context = try makeInMemoryContext() - XCTAssertNil(GamePersistence.load(from: context)) + XCTAssertNil(GamePersistence.load(mode: .klondikeDrawThree, from: context)) + XCTAssertNil(GamePersistence.load(mode: .freecell, from: context)) } func testSaveThenLoadRoundTrip() throws { @@ -33,7 +34,7 @@ final class GamePersistenceStoreTests: XCTestCase { ) try GamePersistence.save(payload, in: context) - let loaded = GamePersistence.load(from: context) + let loaded = GamePersistence.load(mode: .klondikeDrawThree, from: context) XCTAssertNotNil(loaded) XCTAssertEqual(loaded?.movesCount, 12) @@ -41,31 +42,72 @@ final class GamePersistenceStoreTests: XCTestCase { XCTAssertEqual(loaded?.state, state) } - func testSaveOverwritesExistingRecord() throws { + // A statistics reset must reach stashed sessions: their games stay + // playable but can no longer finalize pre-reset play into fresh buckets. + func testInvalidateStatisticsTrackingRewritesStashedSlots() throws { let context = try makeInMemoryContext() let state = GameStateFixtures.validPersistenceState() - let first = SavedGamePayload( + let payload = SavedGamePayload( state: state, - movesCount: 1, - score: 10, + movesCount: 12, stockDrawCount: DrawMode.three.rawValue, - history: [] - ) - let second = SavedGamePayload( - state: state, - movesCount: 9, - score: 90, - stockDrawCount: DrawMode.one.rawValue, - history: [] + history: [], + hasStartedTrackedGame: true, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 3, + undosUsedInCurrentGame: 2 ) + try GamePersistence.save(payload, in: context) + + GamePersistence.invalidateStatisticsTracking(for: [.klondikeDrawThree], in: context) - try GamePersistence.save(first, in: context) - try GamePersistence.save(second, in: context) + let invalidated = try XCTUnwrap(GamePersistence.load(mode: .klondikeDrawThree, from: context)) + XCTAssertFalse(invalidated.hasStartedTrackedGame) + XCTAssertEqual(invalidated.hintRequestsInCurrentGame, 0) + XCTAssertEqual(invalidated.undosUsedInCurrentGame, 0) + // The game itself is untouched — only its tracking is reset. + XCTAssertEqual(invalidated.movesCount, 12) + XCTAssertEqual(invalidated.state, state) - let loaded = GamePersistence.load(from: context) - XCTAssertEqual(loaded?.movesCount, 9) - XCTAssertEqual(loaded?.score, 90) - XCTAssertEqual(loaded?.stockDrawCount, DrawMode.one.rawValue) + // Slots without a save are skipped without disturbing anything. + GamePersistence.invalidateStatisticsTracking(for: [.freecell], in: context) + XCTAssertNil(GamePersistence.load(mode: .freecell, from: context)) + } + + func testSaveKeysRecordByPayloadVariant() throws { + let context = try makeInMemoryContext() + let klondikeState = GameStateFixtures.validPersistenceState() + let freeCellState = GameStateFixtures.seededFreeCellDeal(seed: 7) + + try GamePersistence.save(makePayload(state: klondikeState, movesCount: 3), in: context) + try GamePersistence.save(makePayload(state: freeCellState, movesCount: 8), in: context) + + let klondike = GamePersistence.load(mode: .klondikeDrawThree, from: context) + let freecell = GamePersistence.load(mode: .freecell, from: context) + + XCTAssertEqual(klondike?.state, klondikeState) + XCTAssertEqual(klondike?.movesCount, 3) + XCTAssertEqual(freecell?.state, freeCellState) + XCTAssertEqual(freecell?.movesCount, 8) + XCTAssertNil(GamePersistence.load(mode: .yukon, from: context)) + } + + func testSaveOverwritesOnlySameVariantSlot() throws { + let context = try makeInMemoryContext() + let freeCellState = GameStateFixtures.seededFreeCellDeal(seed: 7) + + try GamePersistence.save(makePayload(state: freeCellState, movesCount: 8), in: context) + try GamePersistence.save( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 1), + in: context + ) + try GamePersistence.save( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 9), + in: context + ) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 9) + XCTAssertEqual(GamePersistence.load(mode: .freecell, from: context)?.movesCount, 8) } func testSaveThrowsForInvalidPayload() throws { @@ -80,9 +122,187 @@ final class GamePersistenceStoreTests: XCTestCase { XCTAssertThrowsError(try GamePersistence.save(invalid, in: context)) } + func testKlondikeDrawModesKeepIndependentSlots() throws { + let context = try makeInMemoryContext() + let drawThreeState = GameStateFixtures.validPersistenceState() + let drawOneState = GameStateFixtures.validPersistenceState() + + try GamePersistence.save( + SavedGamePayload( + state: drawThreeState, + movesCount: 3, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ), + in: context + ) + try GamePersistence.save( + SavedGamePayload( + state: drawOneState, + movesCount: 7, + stockDrawCount: DrawMode.one.rawValue, + history: [] + ), + in: context + ) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 3) + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawOne, from: context)?.movesCount, 7) + } + + // MARK: - Legacy record migration + + func testMigrationSplitsVariantKeyedKlondikeRecordByDrawCount() throws { + let context = try makeInMemoryContext() + let payload = makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 5) + context.insert(SavedGameRecord( + key: "klondike", + snapshotData: try JSONEncoder().encode(payload) + )) + try context.save() + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 5) + XCTAssertNil(GamePersistence.load(mode: .klondikeDrawOne, from: context)) + XCTAssertNil(try fetchRecord(forKey: "klondike", in: context)) + } + + + func testMigrationRekeysLegacyRecordToPayloadVariant() throws { + let context = try makeInMemoryContext() + let state = GameStateFixtures.seededFreeCellDeal(seed: 7) + try insertLegacyRecord(makePayload(state: state, movesCount: 5), in: context) + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + let loaded = GamePersistence.load(mode: .freecell, from: context) + XCTAssertEqual(loaded?.state, state) + XCTAssertEqual(loaded?.movesCount, 5) + XCTAssertNil(try fetchRecord(forKey: SavedGameRecord.legacyRecordKey, in: context)) + } + + func testMigrationIsIdempotent() throws { + let context = try makeInMemoryContext() + try insertLegacyRecord( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 5), + in: context + ) + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 5) + XCTAssertEqual(try fetchAllRecords(in: context).count, 1) + } + + func testMigrationNoOpWithoutLegacyRecord() throws { + let context = try makeInMemoryContext() + try GamePersistence.save( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 5), + in: context + ) + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 5) + XCTAssertEqual(try fetchAllRecords(in: context).count, 1) + } + + func testMigrationDeletesUndecodableLegacyRecord() throws { + let context = try makeInMemoryContext() + context.insert(SavedGameRecord( + key: SavedGameRecord.legacyRecordKey, + snapshotData: Data("not a payload".utf8) + )) + try context.save() + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertTrue(try fetchAllRecords(in: context).isEmpty) + } + + func testMigrationKeepsOccupyingRecordWhenNewerThanLegacy() throws { + let context = try makeInMemoryContext() + try insertLegacyRecord( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 5), + in: context, + updatedAt: DateFixtures.plus(-60) + ) + try GamePersistence.save( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 9), + in: context, + now: DateFixtures.reference + ) + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 9) + XCTAssertNil(try fetchRecord(forKey: SavedGameRecord.legacyRecordKey, in: context)) + XCTAssertEqual(try fetchAllRecords(in: context).count, 1) + } + + func testMigrationRekeysLegacyRecordWhenNewerThanOccupying() throws { + let context = try makeInMemoryContext() + try GamePersistence.save( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 9), + in: context, + now: DateFixtures.plus(-60) + ) + try insertLegacyRecord( + makePayload(state: GameStateFixtures.validPersistenceState(), movesCount: 5), + in: context, + updatedAt: DateFixtures.reference + ) + + GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(GamePersistence.load(mode: .klondikeDrawThree, from: context)?.movesCount, 5) + XCTAssertNil(try fetchRecord(forKey: SavedGameRecord.legacyRecordKey, in: context)) + XCTAssertEqual(try fetchAllRecords(in: context).count, 1) + } + + // MARK: - Helpers + private func makeInMemoryContext() throws -> ModelContext { let configuration = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: SavedGameRecord.self, configurations: configuration) return ModelContext(container) } + + private func makePayload(state: GameState, movesCount: Int) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: movesCount, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ) + } + + private func insertLegacyRecord( + _ payload: SavedGamePayload, + in context: ModelContext, + updatedAt: Date = DateFixtures.reference + ) throws { + context.insert(SavedGameRecord( + key: SavedGameRecord.legacyRecordKey, + snapshotData: try JSONEncoder().encode(payload), + updatedAt: updatedAt + )) + try context.save() + } + + private func fetchRecord(forKey key: String, in context: ModelContext) throws -> SavedGameRecord? { + var descriptor = FetchDescriptor( + predicate: #Predicate { record in + record.key == key + } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func fetchAllRecords(in context: ModelContext) throws -> [SavedGameRecord] { + try context.fetch(FetchDescriptor()) + } } diff --git a/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift b/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift new file mode 100644 index 0000000..68a90be --- /dev/null +++ b/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift @@ -0,0 +1,278 @@ +import XCTest +@testable import Computer_Solitaire + +/// Covers `activateGame(_:restoringFrom:)` — the game-switch path that +/// stashes and resumes per-mode sessions without touching statistics. +@MainActor +final class GameSessionActivationTests: XCTestCase { + private static var retainedViewModels: [SolitaireViewModel] = [] + + // Verifies switching variants mid-game records no loss in either variant's bucket. + func testActivateVariantDoesNotRecordLoss() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + viewModel.newGame(mode: .klondikeDrawThree) + + viewModel.activateGame(.freecell, restoringFrom: nil) + viewModel.activateGame(.klondikeDrawThree, restoringFrom: nil) + + XCTAssertEqual(GameStatisticsStore.load(for: .klondikeDrawThree).gamesPlayed, 0) + XCTAssertEqual(GameStatisticsStore.load(for: .freecell).gamesPlayed, 0) + } + } + + // Verifies a stashed session survives the switch round trip exactly: board, progress, + // score, undo history, and redeal baseline. + func testActivateVariantRoundTripsStashedSession() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let state = GameStateFixtures.seededFreeCellDeal(seed: 7) + let redealState = GameStateFixtures.seededFreeCellDeal(seed: 8) + let snapshot = GameSnapshot( + state: GameStateFixtures.seededFreeCellDeal(seed: 9), + movesCount: 11, + score: 40, + undoContext: nil + ) + let payload = makePayload( + state: state, + movesCount: 12, + score: 45, + history: [snapshot], + redealState: redealState + ) + + XCTAssertTrue(viewModel.activateGame(.freecell, restoringFrom: payload)) + + XCTAssertEqual(viewModel.state, state) + XCTAssertEqual(viewModel.movesCount, 12) + XCTAssertEqual(viewModel.score, 45) + XCTAssertTrue(viewModel.canUndo) + + let stashed = viewModel.persistencePayload() + XCTAssertEqual(stashed.state, state) + XCTAssertEqual(stashed.history.count, 1) + XCTAssertEqual(stashed.history.first?.state, snapshot.state) + XCTAssertEqual(stashed.redealState, redealState) + } + } + + // Verifies a missing payload deals a fresh game for the requested variant. + func testActivateVariantDealsFreshWhenPayloadNil() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + viewModel.newGame(mode: .klondikeDrawThree) + + XCTAssertFalse(viewModel.activateGame(.freecell, restoringFrom: nil)) + + XCTAssertEqual(viewModel.gameVariant, .freecell) + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertEqual(viewModel.score, 0) + XCTAssertFalse(viewModel.canUndo) + XCTAssertTrue(viewModel.persistencePayload().hasStartedTrackedGame) + } + } + + // Verifies a payload that fails restore sanitization falls back to a fresh deal. + func testActivateVariantDealsFreshWhenPayloadInvalid() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let invalid = makePayload(state: GameStateFixtures.emptyBoard(), movesCount: 3) + + XCTAssertFalse(viewModel.activateGame(.klondikeDrawThree, restoringFrom: invalid)) + + XCTAssertEqual(viewModel.gameVariant, .klondike) + XCTAssertEqual(viewModel.movesCount, 0) + } + } + + // Verifies a payload belonging to another variant is rejected in favor of a fresh deal. + func testActivateVariantRejectsWrongVariantPayload() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let freeCellPayload = makePayload( + state: GameStateFixtures.seededFreeCellDeal(seed: 7), + movesCount: 12 + ) + + XCTAssertFalse(viewModel.activateGame(.yukon, restoringFrom: freeCellPayload)) + + XCTAssertEqual(viewModel.gameVariant, .yukon) + XCTAssertEqual(viewModel.movesCount, 0) + } + } + + // A legacy save can carry a scoring basis that differs from its mode + // (pre-per-mode Klondike allowed mid-game draw switches with scoring + // locked to the deal). Its statistics must land entirely in the bucket + // of the mode it lives and displays as. + func testLegacyMismatchedDrawCountsRecordIntoTheModesOwnBucket() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + state: GameStateFixtures.seededKlondikeDeal(seed: 7), + movesCount: 12, + stockDrawCount: DrawMode.one.rawValue, + scoringDrawCount: DrawMode.three.rawValue + ) + + XCTAssertTrue(viewModel.activateGame(.klondikeDrawOne, restoringFrom: payload)) + viewModel.finalizeCurrentGameIfNeeded(didWin: true, endedAt: DateFixtures.reference) + + let drawOne = GameStatisticsStore.load(for: .klondikeDrawOne) + XCTAssertEqual(drawOne.gamesPlayed, 1) + XCTAssertNotNil(drawOne.highScoreDrawOne) + XCTAssertNil(drawOne.highScoreDrawThree) + XCTAssertEqual(GameStatisticsStore.load(for: .klondikeDrawThree).gamesPlayed, 0) + } + } + + // Verifies a payload belonging to a sibling mode of the same variant is + // rejected: a draw-three Klondike session must not restore into a + // requested draw-one game. + func testActivateGameRejectsWrongModePayloadOfSameVariant() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let drawThreePayload = makePayload( + state: GameStateFixtures.seededKlondikeDeal(seed: 7), + movesCount: 12 + ) + + XCTAssertFalse(viewModel.activateGame(.klondikeDrawOne, restoringFrom: drawThreePayload)) + + XCTAssertEqual(viewModel.gameMode, .klondikeDrawOne) + XCTAssertEqual(viewModel.movesCount, 0) + } + } + + // Verifies elapsed time does not accrue while a session sits stashed: 600s of play + // stashed for 300s still reports 600s after reactivation. + func testStashedTimeDoesNotAccrue() { + withIsolatedStatsStore { + let clock = TestDateProvider(now: DateFixtures.reference) + let viewModel = SolitaireViewModel(dateProvider: clock) + Self.retainedViewModels.append(viewModel) + let payload = makePayload( + state: GameStateFixtures.seededFreeCellDeal(seed: 7), + movesCount: 12, + savedAt: DateFixtures.plus(-300), + gameStartedAt: DateFixtures.plus(-900) + ) + + XCTAssertTrue(viewModel.activateGame(.freecell, restoringFrom: payload)) + + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: clock.now), 600) + } + } + + // A payload stashed while paused restores paused; resuming must not + // charge the game for time spent paused or stashed. + func testRestoredPausedPayloadResumesWithoutAccruingStashTime() { + withIsolatedStatsStore { + let clock = TestDateProvider(now: DateFixtures.reference) + let viewModel = SolitaireViewModel(dateProvider: clock) + Self.retainedViewModels.append(viewModel) + let payload = makePayload( + state: GameStateFixtures.seededFreeCellDeal(seed: 7), + movesCount: 12, + savedAt: DateFixtures.plus(-300), + gameStartedAt: DateFixtures.plus(-900), + pauseStartedAt: DateFixtures.plus(-400) + ) + + XCTAssertTrue(viewModel.activateGame(.freecell, restoringFrom: payload)) + + // Still frozen at the pause point: 900 - 400 = 500 active seconds. + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: clock.now), 500) + + XCTAssertTrue(viewModel.resumeTimeScoring(at: clock.now)) + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: clock.now), 500) + + // The clock only advances again once play resumes. + clock.now = clock.now.addingTimeInterval(60) + XCTAssertEqual(viewModel.elapsedActiveSeconds(at: clock.now), 560) + } + } + + // Verifies reactivating a finalized (won) session does not finalize it again on New Game. + func testActivateVariantWithFinalizedPayloadDoesNotRefinalizeOnNewGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + state: GameStateFixtures.seededFreeCellDeal(seed: 7), + movesCount: 12, + isCurrentGameFinalized: true + ) + + XCTAssertTrue(viewModel.activateGame(.freecell, restoringFrom: payload)) + viewModel.newGame() + + XCTAssertEqual(GameStatisticsStore.load(for: .freecell).gamesPlayed, 0) + } + } + + // MARK: - Helpers + + private func withIsolatedStatsStore(_ body: () -> Void) { + let defaults = UserDefaults.standard + let statsKeys = GameMode.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } + let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in + if let data = defaults.data(forKey: key) { + result[key] = data + } + } + for key in statsKeys { + defaults.removeObject(forKey: key) + } + defer { + for key in statsKeys { + if let value = previousStatsData[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + body() + } + + private func makeViewModel() -> SolitaireViewModel { + let viewModel = SolitaireViewModel() + Self.retainedViewModels.append(viewModel) + return viewModel + } + + private func makePayload( + state: GameState, + movesCount: Int, + score: Int = 0, + savedAt: Date = DateFixtures.plus(-300), + gameStartedAt: Date = DateFixtures.plus(-600), + pauseStartedAt: Date? = nil, + stockDrawCount: Int = DrawMode.three.rawValue, + scoringDrawCount: Int = DrawMode.three.rawValue, + history: [GameSnapshot] = [], + redealState: GameState? = nil, + isCurrentGameFinalized: Bool = false + ) -> SavedGamePayload { + SavedGamePayload( + savedAt: savedAt, + state: state, + movesCount: movesCount, + score: score, + gameStartedAt: gameStartedAt, + pauseStartedAt: pauseStartedAt, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: stockDrawCount, + scoringDrawCount: scoringDrawCount, + history: history, + redealState: redealState ?? state, + hasStartedTrackedGame: true, + isCurrentGameFinalized: isCurrentGameFinalized, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } +} diff --git a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift index 9af4ef9..fd16980 100644 --- a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift @@ -10,7 +10,7 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertNotNil(stats.trackedSince) XCTAssertEqual(stats.gamesPlayed, 0) @@ -24,9 +24,9 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - viewModel.newGame(drawMode: .three) + viewModel.newGame() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 0) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -39,10 +39,10 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - viewModel.newGame(drawMode: .three) - viewModel.newGame(drawMode: .three) + viewModel.newGame() + viewModel.newGame() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 1) } } @@ -52,10 +52,10 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - viewModel.newGame(drawMode: .three) + viewModel.newGame() viewModel.redeal() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 1) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -89,9 +89,9 @@ final class GameSessionTrackingTests: XCTestCase { XCTAssertTrue(viewModel.restore(from: payload)) XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) - viewModel.newGame(drawMode: .three) + viewModel.newGame() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 0) } } @@ -108,9 +108,9 @@ final class GameSessionTrackingTests: XCTestCase { XCTAssertTrue(viewModel.restore(from: payload)) XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) - viewModel.newGame(drawMode: .three) + viewModel.newGame() - let stats = GameStatisticsStore.load(for: .klondike) + let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 0) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -123,52 +123,54 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - viewModel.newGame(drawMode: .three) + viewModel.newGame() let activeProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) - GameStatisticsStore.reset(for: .klondike) + GameStatisticsStore.reset(for: .klondikeDrawThree) 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(for: .klondike) + viewModel.newGame() + var stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 0) - viewModel.newGame(drawMode: .three) - stats = GameStatisticsStore.load(for: .klondike) + viewModel.newGame() + stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertEqual(stats.gamesPlayed, 1) } } - // Verifies switching variants finalizes the prior variant into its own stats bucket. - func testVariantSwitchFinalizesIntoPriorVariantBucket() { + // Verifies an explicit New Game in another mode finalizes the prior game into its + // own stats bucket. (The game picker goes through `activateGame` instead, which + // never finalizes — see GameSessionActivationTests.) + func testNewGameAcrossModesFinalizesIntoPriorBucket() { withIsolatedStatsStore { let viewModel = makeViewModel() - viewModel.newGame(variant: .klondike, drawMode: .three) - viewModel.newGame(variant: .freecell, drawMode: .three) + viewModel.newGame(mode: .klondikeDrawThree) + viewModel.newGame(mode: .freecell) - var klondikeStats = GameStatisticsStore.load(for: .klondike) + var klondikeStats = GameStatisticsStore.load(for: .klondikeDrawThree) var freeCellStats = GameStatisticsStore.load(for: .freecell) XCTAssertEqual(klondikeStats.gamesPlayed, 1) XCTAssertEqual(freeCellStats.gamesPlayed, 0) - viewModel.newGame(variant: .yukon, drawMode: .three) + viewModel.newGame(mode: .yukon) - klondikeStats = GameStatisticsStore.load(for: .klondike) + klondikeStats = GameStatisticsStore.load(for: .klondikeDrawThree) freeCellStats = GameStatisticsStore.load(for: .freecell) var yukonStats = GameStatisticsStore.load(for: .yukon) XCTAssertEqual(klondikeStats.gamesPlayed, 1) XCTAssertEqual(freeCellStats.gamesPlayed, 1) XCTAssertEqual(yukonStats.gamesPlayed, 0) - viewModel.newGame(variant: .klondike, drawMode: .three) + viewModel.newGame(mode: .klondikeDrawThree) - klondikeStats = GameStatisticsStore.load(for: .klondike) + klondikeStats = GameStatisticsStore.load(for: .klondikeDrawThree) yukonStats = GameStatisticsStore.load(for: .yukon) XCTAssertEqual(klondikeStats.gamesPlayed, 1) XCTAssertEqual(yukonStats.gamesPlayed, 1) @@ -177,7 +179,7 @@ final class GameSessionTrackingTests: XCTestCase { private func withIsolatedStatsStore(_ body: () -> Void) { let defaults = UserDefaults.standard - let statsKeys = GameVariant.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } + let statsKeys = GameMode.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in if let data = defaults.data(forKey: key) { result[key] = data diff --git a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift index e6faa87..2c1fd76 100644 --- a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift @@ -125,27 +125,27 @@ final class GameStatisticsStoreTests: XCTestCase { defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } GameStatisticsStore.markTrackingStarted( - for: .klondike, + for: .klondikeDrawThree, userDefaults: defaults, at: DateFixtures.reference ) - let marked = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let marked = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) XCTAssertEqual(marked.trackedSince, DateFixtures.reference) GameStatisticsStore.markTrackingStarted( - for: .klondike, + for: .klondikeDrawThree, userDefaults: defaults, at: DateFixtures.plus(60) ) - let notOverwritten = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let notOverwritten = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) XCTAssertEqual(notOverwritten.trackedSince, DateFixtures.reference) GameStatisticsStore.reset( - for: .klondike, + for: .klondikeDrawThree, userDefaults: defaults, at: DateFixtures.plus(120) ) - let reset = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let reset = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) XCTAssertEqual(reset.trackedSince, DateFixtures.plus(120)) XCTAssertEqual(reset.gamesPlayed, 0) XCTAssertEqual(reset.gamesWon, 0) @@ -155,7 +155,7 @@ final class GameStatisticsStoreTests: XCTestCase { let defaults = try makeIsolatedDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } - GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in + GameStatisticsStore.update(for: .klondikeDrawThree, userDefaults: defaults) { stats in stats.recordCompletedGame( didWin: true, elapsedSeconds: 123, @@ -167,17 +167,126 @@ final class GameStatisticsStoreTests: XCTestCase { ) } - let loaded = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let loaded = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) XCTAssertEqual(loaded.gamesPlayed, 1) XCTAssertEqual(loaded.gamesWon, 1) XCTAssertEqual(loaded.bestTimeSeconds, 123) } + func testLegacyKlondikeStatisticsMigrationSplitsBuckets() throws { + let defaults = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + let legacy = GameStatistics( + trackedSince: DateFixtures.reference, + gamesPlayed: 10, + gamesWon: 4, + totalTimeSeconds: 600, + bestTimeSeconds: 60, + highScoreDrawThree: 500, + highScoreDrawOne: 300, + cleanWins: 2 + ) + defaults.set( + try JSONEncoder().encode(legacy), + forKey: GameStatisticsStore.legacyKlondikeDefaultsKey + ) + + GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded( + activeDrawMode: .three, + userDefaults: defaults + ) + + // Pooled history lands in the active mode's bucket; the other mode keeps + // only the high score that was already recorded per draw count. + let drawThree = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) + XCTAssertEqual(drawThree.gamesPlayed, 10) + XCTAssertEqual(drawThree.gamesWon, 4) + XCTAssertEqual(drawThree.cleanWins, 2) + XCTAssertEqual(drawThree.highScoreDrawThree, 500) + XCTAssertNil(drawThree.highScoreDrawOne) + + let drawOne = GameStatisticsStore.load(for: .klondikeDrawOne, userDefaults: defaults) + XCTAssertEqual(drawOne.gamesPlayed, 0) + XCTAssertEqual(drawOne.highScoreDrawOne, 300) + XCTAssertEqual(drawOne.trackedSince, DateFixtures.reference) + + XCTAssertNil(defaults.data(forKey: GameStatisticsStore.legacyKlondikeDefaultsKey)) + + // Idempotent: a second run with no legacy bucket changes nothing. + GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded( + activeDrawMode: .one, + userDefaults: defaults + ) + XCTAssertEqual( + GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults).gamesPlayed, + 10 + ) + } + + func testLegacySpiderStatisticsMigrationSplitsBuckets() throws { + let defaults = try makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + let legacy = GameStatistics( + trackedSince: DateFixtures.reference, + gamesPlayed: 12, + gamesWon: 5, + totalTimeSeconds: 900, + bestTimeSeconds: 90, + highScoreOneSuit: 700, + highScoreTwoSuits: 500, + highScoreFourSuits: 300, + cleanWins: 3 + ) + defaults.set( + try JSONEncoder().encode(legacy), + forKey: GameStatisticsStore.legacySpiderDefaultsKey + ) + + GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded( + activeSuitCount: .two, + userDefaults: defaults + ) + + // Pooled history lands in the active mode's bucket; every mode keeps + // only the high score that was already recorded per suit count. + let twoSuits = GameStatisticsStore.load(for: .spiderTwoSuits, userDefaults: defaults) + XCTAssertEqual(twoSuits.gamesPlayed, 12) + XCTAssertEqual(twoSuits.gamesWon, 5) + XCTAssertEqual(twoSuits.cleanWins, 3) + XCTAssertEqual(twoSuits.highScoreTwoSuits, 500) + XCTAssertNil(twoSuits.highScoreOneSuit) + XCTAssertNil(twoSuits.highScoreFourSuits) + + let oneSuit = GameStatisticsStore.load(for: .spiderOneSuit, userDefaults: defaults) + XCTAssertEqual(oneSuit.gamesPlayed, 0) + XCTAssertEqual(oneSuit.highScoreOneSuit, 700) + XCTAssertNil(oneSuit.highScoreTwoSuits) + XCTAssertEqual(oneSuit.trackedSince, DateFixtures.reference) + + let fourSuits = GameStatisticsStore.load(for: .spiderFourSuits, userDefaults: defaults) + XCTAssertEqual(fourSuits.gamesPlayed, 0) + XCTAssertEqual(fourSuits.highScoreFourSuits, 300) + + XCTAssertNil(defaults.data(forKey: GameStatisticsStore.legacySpiderDefaultsKey)) + + // Idempotent: a second run with no legacy bucket changes nothing. + GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded( + activeSuitCount: .four, + userDefaults: defaults + ) + XCTAssertEqual( + GameStatisticsStore.load(for: .spiderTwoSuits, userDefaults: defaults).gamesPlayed, + 12 + ) + } + func testVariantStoresRemainIsolated() throws { let defaults = try makeIsolatedDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } - GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in + GameStatisticsStore.update(for: .klondikeDrawThree, userDefaults: defaults) { stats in stats.recordCompletedGame( didWin: true, elapsedSeconds: 100, @@ -189,7 +298,7 @@ final class GameStatisticsStoreTests: XCTestCase { ) } - let klondikeStats = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let klondikeStats = GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults) let freeCellStats = GameStatisticsStore.load(for: .freecell, userDefaults: defaults) XCTAssertEqual(klondikeStats.gamesPlayed, 1) diff --git a/ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift b/ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift index 0c205dd..a641c3d 100644 --- a/ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift +++ b/ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift @@ -7,7 +7,7 @@ final class SolitaireViewModelCoreTests: XCTestCase { func testNewGameResetsCoreStateAndAppliesDrawMode() { let viewModel = makeViewModel() - viewModel.newGame(drawMode: .one) + viewModel.newGame(mode: .klondikeDrawOne) XCTAssertEqual(viewModel.movesCount, 0) XCTAssertEqual(viewModel.score, 0) @@ -65,7 +65,9 @@ final class SolitaireViewModelCoreTests: XCTestCase { XCTAssertTrue(viewModel.state.stock.allSatisfy { !$0.isFaceUp }) } - func testRecyclePenaltyAppliesWhenDealtDrawOneEvenAfterSwitchingToDrawThree() { + // Legacy payloads could diverge stock and scoring draw counts (mid-game mode + // switching once existed); the recycle penalty follows the DEALT mode. + func testRecyclePenaltyAppliesWhenDealtDrawOneRestoredAtDrawThree() { var state = GameStateFixtures.validPersistenceState() state.waste = state.stock.map { card in var faceUp = card @@ -73,18 +75,22 @@ final class SolitaireViewModelCoreTests: XCTestCase { return faceUp } state.stock = [] - state.wasteDrawCount = min(1, state.waste.count) + state.wasteDrawCount = min(3, state.waste.count) let viewModel = makeViewModel( - restoring: payload(state: state, stockDrawCount: DrawMode.one.rawValue, score: 150) + restoring: payload( + state: state, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.one.rawValue, + score: 150 + ) ) - viewModel.updateDrawMode(.three) viewModel.handleStockTap() XCTAssertEqual(viewModel.score, 150 + Scoring.delta(for: .recycleWasteInDrawOne)) } - func testRecycleHasNoPenaltyWhenDealtDrawThreeEvenAfterSwitchingToDrawOne() { + func testRecycleHasNoPenaltyWhenDealtDrawThreeRestoredAtDrawOne() { var state = GameStateFixtures.validPersistenceState() state.waste = state.stock.map { card in var faceUp = card @@ -92,12 +98,16 @@ final class SolitaireViewModelCoreTests: XCTestCase { return faceUp } state.stock = [] - state.wasteDrawCount = min(3, state.waste.count) + state.wasteDrawCount = min(1, state.waste.count) let viewModel = makeViewModel( - restoring: payload(state: state, stockDrawCount: DrawMode.three.rawValue, score: 150) + restoring: payload( + state: state, + stockDrawCount: DrawMode.one.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + score: 150 + ) ) - viewModel.updateDrawMode(.one) viewModel.handleStockTap() XCTAssertEqual(viewModel.score, 150) @@ -152,7 +162,7 @@ final class SolitaireViewModelCoreTests: XCTestCase { func testPauseResumeAndElapsedTimeAccounting() { let clock = TestDateProvider(now: DateFixtures.reference) let viewModel = makeViewModel(dateProvider: clock) - viewModel.newGame(drawMode: .three) + viewModel.newGame() let start = DateFixtures.plus(-600) clock.now = DateFixtures.plus(120) @@ -206,6 +216,7 @@ final class SolitaireViewModelCoreTests: XCTestCase { state: GameState, savedAt: Date = DateFixtures.reference, stockDrawCount: Int, + scoringDrawCount: Int? = nil, score: Int = 0, gameStartedAt: Date = DateFixtures.reference, pauseStartedAt: Date? = nil @@ -220,7 +231,7 @@ final class SolitaireViewModelCoreTests: XCTestCase { hasAppliedTimeBonus: false, finalElapsedSeconds: nil, stockDrawCount: stockDrawCount, - scoringDrawCount: stockDrawCount, + scoringDrawCount: scoringDrawCount ?? stockDrawCount, history: [], redealState: state, hasStartedTrackedGame: true, diff --git a/ComputerSolitaireTests/Spider/SpiderScoringTests.swift b/ComputerSolitaireTests/Spider/SpiderScoringTests.swift index c1013e9..6e5c680 100644 --- a/ComputerSolitaireTests/Spider/SpiderScoringTests.swift +++ b/ComputerSolitaireTests/Spider/SpiderScoringTests.swift @@ -5,7 +5,7 @@ import XCTest final class SpiderScoringTests: XCTestCase { func testNewGameStartsAtTheClassicInitialScore() { let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .spider) + viewModel.newGame(mode: .spiderTwoSuits) XCTAssertEqual(viewModel.score, Scoring.spiderInitialScore) viewModel.redeal() diff --git a/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift b/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift index ead5048..21420f1 100644 --- a/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift +++ b/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift @@ -5,7 +5,7 @@ import XCTest final class TriPeaksSessionTests: XCTestCase { private func makeTriPeaksSession() -> SolitaireViewModel { let viewModel = SolitaireViewModel(variant: .tripeaks) - viewModel.newGame(variant: .tripeaks) + viewModel.newGame(mode: .tripeaks) return viewModel } diff --git a/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift b/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift index 4d102f9..39ac59c 100644 --- a/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift +++ b/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift @@ -32,7 +32,7 @@ final class YukonPersistenceTests: XCTestCase { func testViewModelRoundTripPreservesYukonGame() { let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .yukon) + viewModel.newGame(mode: .yukon) let payload = viewModel.persistencePayload() let restored = SolitaireViewModel() diff --git a/ComputerSolitaireTests/Yukon/YukonRulesTests.swift b/ComputerSolitaireTests/Yukon/YukonRulesTests.swift index bc76aa0..e257797 100644 --- a/ComputerSolitaireTests/Yukon/YukonRulesTests.swift +++ b/ComputerSolitaireTests/Yukon/YukonRulesTests.swift @@ -59,7 +59,7 @@ final class YukonRulesTests: XCTestCase { let eightSpades = TestCards.make(.spades, .eight) let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .yukon) + viewModel.newGame(mode: .yukon) viewModel.state = GameState( variant: .yukon, stock: [], @@ -92,7 +92,7 @@ final class YukonRulesTests: XCTestCase { let nineSpades = TestCards.make(.spades, .nine) let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .yukon) + viewModel.newGame(mode: .yukon) viewModel.state = GameState( variant: .yukon, stock: [], @@ -172,7 +172,7 @@ final class YukonRulesTests: XCTestCase { let threeHearts = TestCards.make(.hearts, .three) let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .yukon) + viewModel.newGame(mode: .yukon) viewModel.state = GameState( variant: .yukon, stock: [], @@ -193,7 +193,7 @@ final class YukonRulesTests: XCTestCase { let hiddenSix = TestCards.make(.hearts, .six, isFaceUp: false) let viewModel = SolitaireViewModel() - viewModel.newGame(variant: .yukon) + viewModel.newGame(mode: .yukon) viewModel.state = GameState( variant: .yukon, stock: [], diff --git a/ComputerSolitaireUITests/GameModePickerUITests.swift b/ComputerSolitaireUITests/GameModePickerUITests.swift new file mode 100644 index 0000000..4fedcf4 --- /dev/null +++ b/ComputerSolitaireUITests/GameModePickerUITests.swift @@ -0,0 +1,55 @@ +import XCTest + +/// Exercises the game picker overlay's dismissal affordances end-to-end. +final class GameModePickerUITests: XCTestCase { +#if os(macOS) + @MainActor + func testEscapeDismissesGamePicker() throws { + let app = XCUIApplication() + app.launch() + + let titleButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Switch game mode'") + ).firstMatch + XCTAssertTrue(titleButton.waitForExistence(timeout: 5), "Game title button should be on the board") + titleButton.click() + + let scrim = app.buttons["Dismiss game picker"] + XCTAssertTrue(scrim.waitForExistence(timeout: 3), "Picker overlay should open from the title button") + + app.typeKey(.escape, modifierFlags: []) + + XCTAssertTrue(scrim.waitForNonExistence(timeout: 3), "Escape should dismiss the picker overlay") + } + + /// A window near the supported minimum height can't fit all six family + /// cards; the picker must fall back to scrolling so every game stays + /// reachable. + @MainActor + func testShortWindowKeepsEveryGameReachable() throws { + let app = XCUIApplication() + app.launchArguments += ["-screenshotWindowSize", "900x420"] + app.launch() + + let titleButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Switch game mode'") + ).firstMatch + XCTAssertTrue(titleButton.waitForExistence(timeout: 5), "Game title button should be on the board") + titleButton.click() + + let yukonCard = app.buttons.matching( + NSPredicate(format: "label BEGINSWITH 'Yukon'") + ).firstMatch + XCTAssertTrue(yukonCard.waitForExistence(timeout: 3), "Picker should list every game") + + if !yukonCard.isHittable { + app.scrollViews.firstMatch.scroll(byDeltaX: 0, deltaY: -400) + } + XCTAssertTrue(yukonCard.isHittable, "The last game card must be reachable in a short window") + yukonCard.click() + + let yukonTitle = app.buttons["Game: Yukon. Switch game mode"] + XCTAssertTrue(yukonTitle.waitForExistence(timeout: 3), "Selecting the scrolled-to game should switch to it") + } +#endif +} diff --git a/README.md b/README.md index 4b477cb..6203b00 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, **Yukon**, **Spider** (1, 2, or 4 suits), **Pyramid**, and **TriPeaks** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, and **Yukon** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -23,8 +23,8 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | Variant | Description | Rules | |---------|-------------|-------| | **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/solitaire-rules-klondike.md) | -| **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | -| **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | | **Spider** | Two decks, ten piles — build full suit runs from King to Ace, with 1/2/4-suit difficulty | [Rules](docs/solitaire-rules-spider.md) | -| **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/solitaire-rules-pyramid.md) | +| **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | | **TriPeaks** | Chain uncovered cards one rank up or down to level three peaks | [Rules](docs/solitaire-rules-tripeaks.md) | +| **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/solitaire-rules-pyramid.md) | +| **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | From feaabdf6b74f8f2340d50ddfe209bd808e851a9f Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 23:15:48 -0700 Subject: [PATCH 2/6] restructure statistics into a game overview with per-game detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the seven-segment scope picker could not scale past a handful of games and hid cross-game comparison. statistics now opens as an overview — aggregate highlights plus one row per game with games played and win rate — drilling into each game's full breakdown, where the mode sub-picker (draw counts, suit counts) remains. opening mid-game deep links to the current game's detail. --- .../Views/Shared/ContentView.swift | 8 +- ComputerSolitaire/Views/StatisticsView.swift | 551 ++++++++++-------- 2 files changed, 298 insertions(+), 261 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index dbd0a1d..35f60e8 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -374,13 +374,9 @@ struct ContentView: View { } } .sheet(isPresented: $isShowingStats) { -#if os(iOS) - NavigationStack { - StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) - } -#else + // StatisticsView owns its NavigationStack: it drills from the + // all-games overview into per-game detail on both platforms. StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) -#endif } ) } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index f90dc7b..cf510de 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -2,128 +2,223 @@ import Foundation import SwiftUI import SwiftData +/// Statistics in two levels, mirroring the game picker's grammar: an +/// overview of every game with aggregate highlights, and a per-game detail +/// with the full breakdown. Opens deep-linked to the current game's detail. struct StatisticsView: View { let viewModel: SolitaireViewModel? - @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss - @State private var selectedScope: Scope - /// The bucket within a multi-mode scope (Klondike draw counts, Spider - /// suit counts); ignored for single-mode scopes. - @State private var scopeMode: GameMode - @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 - }() - - private enum Scope: String, CaseIterable, Identifiable { - // Mirrors GameVariant's presentation order, with the aggregate last. - case klondike - case spider - case freecell - case tripeaks - case pyramid - case yukon - case all - - var id: String { rawValue } - - init(variant: GameVariant) { - switch variant { - case .klondike: - self = .klondike - case .freecell: - self = .freecell - case .yukon: - self = .yukon - case .spider: - self = .spider - case .pyramid: - self = .pyramid - case .tripeaks: - self = .tripeaks + @State private var path: [GameVariant] + private let initialMode: GameMode + + init(viewModel: SolitaireViewModel?, initialMode: GameMode = .klondikeDrawThree) { + self.viewModel = viewModel + self.initialMode = initialMode + _path = State(initialValue: [initialMode.variant]) + } + + var body: some View { + NavigationStack(path: $path) { + StatisticsOverviewView( + viewModel: viewModel, + onDone: { dismiss() } + ) + .navigationDestination(for: GameVariant.self) { variant in + GameStatisticsDetailView( + viewModel: viewModel, + variant: variant, + initialMode: initialMode, + onDone: { dismiss() } + ) } } + } +} + +// MARK: - Overview + +/// Aggregate highlights plus one row per game — the two numbers that make +/// games comparable at a glance — in the shared presentation order. +private struct StatisticsOverviewView: View { + let viewModel: SolitaireViewModel? + let onDone: () -> Void + @Environment(\.modelContext) private var modelContext + @State private var aggregate = GameStatistics() + @State private var statsByVariant: [GameVariant: GameStatistics] = [:] + @State private var isShowingResetConfirmation = false - /// The variant this scope covers; nil for the aggregate scope. - var variant: GameVariant? { - switch self { - case .klondike: - return .klondike - case .freecell: - return .freecell - case .yukon: - return .yukon - case .spider: - return .spider - case .pyramid: - return .pyramid - case .tripeaks: - return .tripeaks - case .all: - return nil + var body: some View { + Form { + Section { + HStack(spacing: 0) { + statsHighlightCard( + icon: "percent", + label: "Win Rate", + value: statsWinRateLabel(aggregate) + ) + Divider() + .frame(height: 32) + statsHighlightCard( + icon: "number", + label: "Games Played", + value: "\(aggregate.gamesPlayed)" + ) + Divider() + .frame(height: 32) + statsHighlightCard( + icon: "trophy.fill", + label: "Games Won", + value: "\(aggregate.gamesWon)" + ) + } + .padding(.vertical, 2) + } header: { + Text("Highlights") + } + + Section { + ForEach(GameVariant.allCases, id: \.self) { variant in + NavigationLink(value: variant) { + gameRow(variant) + } + } + } header: { + Text("Games") } + + Text("Tracked since \(statsTrackedSinceLabel(aggregate))") + .font(.footnote) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } + .navigationTitle("Statistics") + .statisticsFormChrome() + .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", action: onDone) + .keyboardShortcut(.cancelAction) + } + } + .onAppear { + reload() + } + .confirmationDialog( + "Reset all statistics?", + isPresented: $isShowingResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset All Statistics", role: .destructive) { + resetAllStatistics() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will reset statistics for every game.") + } + } - var title: String { - switch self { - case .klondike, .freecell, .yukon, .spider, .pyramid, .tripeaks: - return variant?.title ?? "" - case .all: - return "All" + private func gameRow(_ variant: GameVariant) -> some View { + let stats = statsByVariant[variant] ?? GameStatistics() + return HStack { + Text(variant.title) + Spacer(minLength: 16) + if stats.gamesPlayed == 0 { + Text("Not played yet") + .font(.callout) + .foregroundStyle(.tertiary) + } else { + Text("\(stats.gamesPlayed) played · \(statsWinRateLabel(stats)) won") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) } } + .accessibilityElement(children: .combine) } - private struct HighScoreRow: Identifiable { - let label: String - let score: Int? + private func reload() { + statsByVariant = GameVariant.allCases.reduce(into: [:]) { result, variant in + result[variant] = GameStatistics.aggregated( + GameMode.modes(for: variant).map { GameStatisticsStore.load(for: $0) } + ) + } + aggregate = GameStatistics.aggregated( + GameMode.allCases.map { GameStatisticsStore.load(for: $0) } + ) + } - var id: String { label } + private func resetAllStatistics() { + for mode in GameMode.allCases { + GameStatisticsStore.reset(for: mode) + } + // Stashed sessions must not finalize pre-reset play into the fresh + // buckets; the active session is handled after so its newer in-memory + // state wins the save slot. + GamePersistence.invalidateStatisticsTracking(for: GameMode.allCases, in: modelContext) + viewModel?.resetStatisticsTracking() + persistTrackingReset(of: viewModel, in: modelContext) + reload() } +} - init(viewModel: SolitaireViewModel?, initialMode: GameMode = .klondikeDrawThree) { +// MARK: - Per-game detail + +private struct GameStatisticsDetailView: View { + let viewModel: SolitaireViewModel? + let variant: GameVariant + let onDone: () -> Void + @Environment(\.modelContext) private var modelContext + /// The bucket within a multi-mode game (Klondike draw counts, Spider + /// suit counts); fixed for single-mode games. + @State private var scopeMode: GameMode + @State private var stats = GameStatistics() + @State private var barHoverState: (label: String, x: CGFloat)? + @State private var isShowingCleanWinsInfo = false + @State private var isShowingResetConfirmation = false + + init( + viewModel: SolitaireViewModel?, + variant: GameVariant, + initialMode: GameMode, + onDone: @escaping () -> Void + ) { self.viewModel = viewModel - _selectedScope = State(initialValue: Scope(variant: initialMode.variant)) - _scopeMode = State(initialValue: initialMode) + self.variant = variant + self.onDone = onDone + _scopeMode = State( + initialValue: initialMode.variant == variant ? initialMode : GameMode(variant: variant) + ) } - /// The statistics bucket the current scope selection reads; nil for the - /// aggregate scope. - private var effectiveMode: GameMode? { - guard let variant = selectedScope.variant else { return nil } + private var effectiveMode: GameMode { let modes = GameMode.modes(for: variant) - guard modes.count > 1 else { return modes.first } + guard modes.count > 1 else { return modes.first ?? scopeMode } return modes.contains(scopeMode) ? scopeMode : GameMode(variant: variant) } private var effectiveScopeTitle: String { - guard let variant = selectedScope.variant else { return "All" } - guard GameMode.modes(for: variant).count > 1, let mode = effectiveMode else { - return variant.title - } - return "\(variant.title) (\(mode.optionTitle))" + guard GameMode.modes(for: variant).count > 1 else { return variant.title } + return "\(variant.title) (\(effectiveMode.optionTitle))" } var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in Form { - Section { - Picker("Statistics Scope", selection: $selectedScope) { - ForEach(Scope.allCases) { scope in - Text(scope.title).tag(scope) - } - } - .pickerStyle(.segmented) - .labelsHidden() - - if let variant = selectedScope.variant, GameMode.modes(for: variant).count > 1 { + if GameMode.modes(for: variant).count > 1 { + Section { Picker("Game Mode", selection: $scopeMode) { ForEach(GameMode.modes(for: variant), id: \.self) { mode in Text(mode.optionTitle).tag(mode) @@ -136,21 +231,21 @@ struct StatisticsView: View { Section { HStack(spacing: 0) { - highlightCard( + statsHighlightCard( icon: "percent", label: "Win Rate", - value: winRateLabel + value: statsWinRateLabel(stats) ) Divider() .frame(height: 32) - highlightCard( - icon: secondaryHighlightIcon, - label: secondaryHighlightLabel, - value: secondaryHighlightValue + statsHighlightCard( + icon: "timer", + label: "Best Time", + value: bestTimeLabel ) Divider() .frame(height: 32) - highlightCard( + statsHighlightCard( icon: "trophy.fill", label: "Games Won", value: "\(stats.gamesWon)" @@ -162,10 +257,10 @@ struct StatisticsView: View { } Section { - keyValueRow("Games Played", "\(stats.gamesPlayed)") - keyValueRow("Wins", "\(stats.gamesWon)") + statsKeyValueRow("Games Played", "\(stats.gamesPlayed)") + statsKeyValueRow("Wins", "\(stats.gamesWon)") VStack(spacing: 6) { - keyValueRow("Win Rate", winRateLabel) + statsKeyValueRow("Win Rate", statsWinRateLabel(stats)) if stats.gamesPlayed > 0 { winLossBar } @@ -175,20 +270,16 @@ struct StatisticsView: View { Text("Games") } - if selectedScope != .all { - Section { - keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) - keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) - keyValueRow("Best Time", bestTimeLabel) - ForEach(highScoreRowsForSelectedScope) { row in - keyValueRow(row.label, scoreLabel(row.score)) - } - } header: { - Text("Performance") - } + Section { + statsKeyValueRow("Total Time", statsDurationLabel(displayTotalTimeSeconds(at: context.date))) + statsKeyValueRow("Avg Time", statsDurationLabel(stats.averageTimeSeconds)) + statsKeyValueRow("Best Time", bestTimeLabel) + statsKeyValueRow("High Score", statsScoreLabel(highScore(for: effectiveMode))) + } header: { + Text("Performance") } - Text("Tracked since \(trackedSinceLabel)") + Text("Tracked since \(statsTrackedSinceLabel(stats))") .font(.footnote) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .center) @@ -196,14 +287,8 @@ struct StatisticsView: View { .listRowSeparator(.hidden) } } - .navigationTitle("Statistics") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) - .frame(minWidth: 420, minHeight: 320) -#endif + .navigationTitle(variant.title) + .statisticsFormChrome() .toolbar { #if os(macOS) ToolbarItem(placement: .automatic) { @@ -219,38 +304,28 @@ struct StatisticsView: View { } #endif ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - .keyboardShortcut(.cancelAction) + Button("Done", action: onDone) + .keyboardShortcut(.cancelAction) } } .onAppear { loadStats() } - .onChange(of: selectedScope) { _, _ in - if let variant = selectedScope.variant, - !GameMode.modes(for: variant).contains(scopeMode) { - scopeMode = GameMode(variant: variant) - } - loadStats() - barHoverState = nil - } .onChange(of: scopeMode) { _, _ in loadStats() barHoverState = nil } .confirmationDialog( - resetDialogTitle, + "Reset \(effectiveScopeTitle) statistics?", isPresented: $isShowingResetConfirmation, titleVisibility: .visible ) { - Button(resetActionTitle, role: .destructive) { + Button("Reset \(effectiveScopeTitle) Statistics", role: .destructive) { resetStatistics() } Button("Cancel", role: .cancel) {} } message: { - Text(resetMessage) + Text("This will reset only \(effectiveScopeTitle) games, times, win rates, and high scores.") } } @@ -299,22 +374,13 @@ struct StatisticsView: View { } } - private var winRateLabel: String { - String(format: "%.1f%%", stats.winRate * 100) - } - private var bestTimeLabel: String { guard let bestTimeSeconds = stats.bestTimeSeconds else { return "-" } - return durationLabel(bestTimeSeconds) + return statsDurationLabel(bestTimeSeconds) } /// Each mode bucket carries a single high score, routed to the field its /// wins record into (Klondike per draw count, Spider per suit count). - private var highScoreRowsForSelectedScope: [HighScoreRow] { - guard let mode = effectiveMode else { return [] } - return [HighScoreRow(label: "High Score", score: highScore(for: mode))] - } - private func highScore(for mode: GameMode) -> Int? { switch mode { case .klondikeDrawOne: @@ -332,43 +398,13 @@ struct StatisticsView: View { } } - private func scoreLabel(_ score: Int?) -> String { - score.map { "\($0)" } ?? "-" - } - - private var secondaryHighlightIcon: String { - if selectedScope == .all { - return "number" - } - return "timer" - } - - private var secondaryHighlightLabel: String { - if selectedScope == .all { - return "Games Played" - } - return "Best Time" - } - - private var secondaryHighlightValue: String { - if selectedScope == .all { - return "\(stats.gamesPlayed)" - } - return bestTimeLabel - } - 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) + String(format: "%.1f%%", stats.cleanWinRate * 100) } private func displayTotalTimeSeconds(at date: Date) -> Int { let liveElapsed: Int - if activeModeMatchesSelectedScope { + if activeModeMatchesScope { liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 } else { liveElapsed = 0 @@ -377,33 +413,6 @@ struct StatisticsView: View { 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) - .accessibilityHidden(true) - Text(value) - .font(.system(.headline, design: .monospaced, weight: .bold)) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .accessibilityElement(children: .combine) - } - - @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") @@ -437,71 +446,103 @@ struct StatisticsView: View { .padding(.leading, 2) } - private func durationLabel(_ seconds: Int) -> String { - let total = max(0, seconds) - return durationFormatter.string(from: TimeInterval(total)) ?? "0s" - } - private func resetStatistics() { - let affectedModes = effectiveMode.map { [$0] } ?? GameMode.allCases - for mode in affectedModes { - GameStatisticsStore.reset(for: mode) - } - - // Stashed sessions of the affected modes must not finalize pre-reset - // play into the fresh buckets; the active session is handled below so - // its newer in-memory state wins the save slot. - GamePersistence.invalidateStatisticsTracking(for: affectedModes, in: modelContext) - - if selectedScope == .all || activeModeMatchesSelectedScope { + GameStatisticsStore.reset(for: effectiveMode) + // The mode's stashed session must not finalize pre-reset play into + // the fresh bucket; an active session is handled after so its newer + // in-memory state wins the save slot. + GamePersistence.invalidateStatisticsTracking(for: [effectiveMode], in: modelContext) + if activeModeMatchesScope { viewModel?.resetStatisticsTracking() - persistTrackingResetIfNeeded() + persistTrackingReset(of: viewModel, in: modelContext) } loadStats() barHoverState = nil } - private var activeModeMatchesSelectedScope: Bool { - guard let mode = effectiveMode else { return true } - return viewModel?.gameMode == mode + private var activeModeMatchesScope: Bool { + viewModel?.gameMode == effectiveMode } - private var resetDialogTitle: String { - selectedScope == .all - ? "Reset all statistics?" - : "Reset \(effectiveScopeTitle) statistics?" + private func loadStats() { + stats = GameStatisticsStore.load(for: effectiveMode) } +} - private var resetActionTitle: String { - selectedScope == .all - ? "Reset All Statistics" - : "Reset \(effectiveScopeTitle) Statistics" - } +// MARK: - Shared pieces + +private let statsDurationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropLeading + return formatter +}() + +private func statsDurationLabel(_ seconds: Int) -> String { + let total = max(0, seconds) + return statsDurationFormatter.string(from: TimeInterval(total)) ?? "0s" +} - private var resetMessage: String { - selectedScope == .all - ? "This will reset statistics for every game." - : "This will reset only \(effectiveScopeTitle) games, times, win rates, and high scores." +private func statsWinRateLabel(_ stats: GameStatistics) -> String { + String(format: "%.1f%%", stats.winRate * 100) +} + +private func statsScoreLabel(_ score: Int?) -> String { + score.map { "\($0)" } ?? "-" +} + +private func statsTrackedSinceLabel(_ stats: GameStatistics) -> String { + guard let trackedSince = stats.trackedSince else { return "-" } + return trackedSince.formatted(date: .abbreviated, time: .omitted) +} + +private func statsHighlightCard(icon: String, label: String, value: String) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(value) + .font(.system(.headline, design: .monospaced, weight: .bold)) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) +} - private func loadStats() { - if let mode = effectiveMode { - stats = GameStatisticsStore.load(for: mode) - } else { - stats = GameStatistics.aggregated( - GameMode.allCases.map { GameStatisticsStore.load(for: $0) } - ) - } +private func statsKeyValueRow(_ key: String, _ value: String) -> some View { + HStack { + Text(key) + Spacer(minLength: 16) + Text(value) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) } +} - private func persistTrackingResetIfNeeded() { - guard let viewModel else { return } - do { - try GamePersistence.save(viewModel.persistencePayload(), in: modelContext) - } catch { +private func persistTrackingReset(of viewModel: SolitaireViewModel?, in modelContext: ModelContext) { + guard let viewModel else { return } + do { + try GamePersistence.save(viewModel.persistencePayload(), in: modelContext) + } catch { #if DEBUG - print("Failed to persist reset tracking state: \(error)") + print("Failed to persist reset tracking state: \(error)") +#endif + } +} + +private extension View { + /// Platform chrome every statistics page shares. + func statisticsFormChrome() -> some View { +#if os(iOS) + return navigationBarTitleDisplayMode(.inline) +#else + return formStyle(.grouped) + .padding(16) + .frame(minWidth: 420, minHeight: 320) #endif - } } } From 75e029e7a45976b1d1288cb45dcd51c83f22d7f5 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 23:16:04 -0700 Subject: [PATCH 3/6] open the migrated legacy game on first launch after upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings write to storage immediately while payloads save on a debounced autosave, so the stored mode can lag the legacy single-slot save by one change. the record migration now reports the mode of the game it moved out of the legacy slot and first hydration opens that game, re-syncing the stored selection — matching the old builds' payload-first restore. --- .../Game/Shared/GamePersistence.swift | 16 ++++++++++++++-- ComputerSolitaire/Views/Shared/ContentView.swift | 15 ++++++++++----- .../Shared/GamePersistenceStoreTests.swift | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 1c7fd5b..909638c 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -335,17 +335,24 @@ enum GamePersistence { } /// Re-keys records from earlier keying schemes (the single "current" slot, - /// per-variant slots) to their payload's mode slot. + /// per-variant slots) to their payload's mode slot. Returns the mode of + /// the game migrated out of the single legacy slot, if any: that game was + /// on screen when the old build last ran, so first hydration should open + /// it even when stored settings lag its payload (settings write + /// immediately; payloads save on a debounced autosave). // TODO: Remove (with `SavedGameRecord.legacyRecordKey`) once upgrades // from pre-per-mode releases no longer need supporting. - static func migrateLegacyRecordsIfNeeded(in modelContext: ModelContext) { + @discardableResult + static func migrateLegacyRecordsIfNeeded(in modelContext: ModelContext) -> GameMode? { do { let modeKeys = Set(GameMode.allCases.map(\.rawValue)) let records = try modelContext.fetch(FetchDescriptor()) var didChange = false + var migratedCurrentMode: GameMode? for record in records where !modeKeys.contains(record.key) { didChange = true + let isLegacyCurrentSlot = record.key == SavedGameRecord.legacyRecordKey guard let payload = try? JSONDecoder().decode( SavedGamePayload.self, from: record.snapshotData @@ -353,6 +360,9 @@ enum GamePersistence { modelContext.delete(record) continue } + if isLegacyCurrentSlot { + migratedCurrentMode = payload.gameMode + } let targetKey = SavedGameRecord.key( for: payload.gameMode @@ -372,8 +382,10 @@ enum GamePersistence { if didChange { try modelContext.save() } + return migratedCurrentMode } catch { // Leave the store untouched; hydration falls back to a fresh deal. + return nil } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 35f60e8..7b49130 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1556,24 +1556,29 @@ struct ContentView: View { previousStockCount = viewModel.state.stock.count } - GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) + let migratedCurrentMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded(activeDrawMode: drawMode) GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded(activeSuitCount: spiderSuitCount) - // The stored selection decides which game's slot the app opens into. - let storedMode = GameMode( + // The stored selection decides which game's slot the app opens into — + // except right after upgrading, when the game migrated out of the + // legacy single slot was the one on screen and wins over stored + // settings, which can lag its payload by one debounced autosave. + // `rememberSelectedGame()` re-syncs the stored selection on restore. + let launchMode = migratedCurrentMode ?? GameMode( variant: gameVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount ) if restoreScreenshotFixtureIfRequested() { // Staged board loaded; shared post-load setup below still applies. - } else if let payload = GamePersistence.load(mode: storedMode, from: modelContext), + } else if let payload = GamePersistence.load(mode: launchMode, from: modelContext), viewModel.restore(from: payload) { rememberSelectedGame() } else { winCelebration.reset(to: .idle) - viewModel.newGame(mode: storedMode) + viewModel.newGame(mode: launchMode) + rememberSelectedGame() persistGameNow() } winCelebration.syncForLoadedGame( diff --git a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift index 28f8b5e..e8a4526 100644 --- a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift @@ -182,6 +182,22 @@ final class GamePersistenceStoreTests: XCTestCase { XCTAssertNil(try fetchRecord(forKey: SavedGameRecord.legacyRecordKey, in: context)) } + // The game migrated out of the single legacy slot was on screen when the + // old build last ran; migration reports its mode so first hydration can + // open it even when stored settings lag the payload. + func testMigrationReportsModeOfLegacyCurrentGame() throws { + let context = try makeInMemoryContext() + let state = GameStateFixtures.seededFreeCellDeal(seed: 7) + try insertLegacyRecord(makePayload(state: state, movesCount: 5), in: context) + + let migratedMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + + XCTAssertEqual(migratedMode, .freecell) + + // Later launches have no legacy slot and report nothing. + XCTAssertNil(GamePersistence.migrateLegacyRecordsIfNeeded(in: context)) + } + func testMigrationIsIdempotent() throws { let context = try makeInMemoryContext() try insertLegacyRecord( From b74fea6bd8e98a40f22ca023899273b8a664b796 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 23:30:25 -0700 Subject: [PATCH 4/6] match game switcher card backs to selected color --- .../Views/Shared/GameModePickerView.swift | 21 ++++++++++++++++--- .../Shared/CardStyleTests.swift | 10 +++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index 6f46b0e..0c6c3f1 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -19,6 +19,7 @@ struct GameModePickerView: View { let entries: [Entry] let currentMode: GameMode let feltColor: Color + let cardBackColor: CardBackColor let onSelect: (GameMode) -> Void /// The family whose modes the detail step shows; nil shows the gallery. @@ -62,7 +63,12 @@ struct GameModePickerView: View { } } label: { HStack(spacing: 12) { - MiniBoardView(variant: variant, feltColor: feltColor, scale: 0.62) + MiniBoardView( + variant: variant, + feltColor: feltColor, + cardBackColor: cardBackColor, + scale: 0.62 + ) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 8) { @@ -146,7 +152,11 @@ struct GameModePickerView: View { .padding(.horizontal, 2) HStack(spacing: 12) { - MiniBoardView(variant: variant, feltColor: feltColor) + MiniBoardView( + variant: variant, + feltColor: feltColor, + cardBackColor: cardBackColor + ) Text(variant.subtitle) .font(.caption) @@ -239,6 +249,9 @@ struct GameModePickerOverlay: View { let onSelect: (GameMode) -> Void let onDismiss: () -> Void + @AppStorage(SettingsKey.cardBackColor) + private var cardBackColorRawValue = CardBackColor.defaultValue.id + /// The overlay takes keyboard focus while presented so Escape reaches it; /// a custom overlay sits outside the window's cancel-action routing that /// sheets get for free. @@ -294,6 +307,7 @@ struct GameModePickerOverlay: View { entries: entries, currentMode: currentMode, feltColor: feltColor, + cardBackColor: CardBackColor.from(rawValue: cardBackColorRawValue), onSelect: onSelect ) } @@ -304,6 +318,7 @@ struct GameModePickerOverlay: View { private struct MiniBoardView: View { let variant: GameVariant let feltColor: Color + let cardBackColor: CardBackColor var scale: CGFloat = 1 private enum MiniCard { @@ -511,7 +526,7 @@ private struct MiniBoardView: View { case .faceUp: return AnyShapeStyle(.white.opacity(0.95)) case .faceDown: - return AnyShapeStyle(Color(red: 0.18, green: 0.24, blue: 0.5)) + return AnyShapeStyle(cardBackColor.swatch) case .slot: return AnyShapeStyle(.white.opacity(0.06)) } diff --git a/ComputerSolitaireTests/Shared/CardStyleTests.swift b/ComputerSolitaireTests/Shared/CardStyleTests.swift index f0bf50f..ab2ae18 100644 --- a/ComputerSolitaireTests/Shared/CardStyleTests.swift +++ b/ComputerSolitaireTests/Shared/CardStyleTests.swift @@ -24,4 +24,14 @@ final class CardStyleTests: XCTestCase { XCTAssertEqual(CardStyle.pixel.title, "Pixel") XCTAssertEqual(CardStyle.pixel.subtitle, "8-bit Retro") } + + func testCardBackColorsResolveFromPersistedIdentifiers() { + for color in CardBackColor.all { + XCTAssertEqual(CardBackColor.from(rawValue: color.id), color) + } + } + + func testUnknownCardBackColorFallsBackToDefault() { + XCTAssertEqual(CardBackColor.from(rawValue: "unknown"), .defaultValue) + } } From 81e61f42194f53cdbd0a626a6116dccc9d8b1fe3 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 23:45:39 -0700 Subject: [PATCH 5/6] route legacy statistics splits by the migrated game's mode the pooled klondike/spider bucket splits assign history to the mode in active use. the game migrated out of the legacy single slot is the mode the old build would itself reconcile to on relaunch (payload-first write-back), so its qualifier steers its family's split; stored settings still steer the other family's. --- .../Views/Shared/ContentView.swift | 12 ++++- .../Shared/GamePersistenceStoreTests.swift | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 7b49130..ee0dbde 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1557,8 +1557,16 @@ struct ContentView: View { } let migratedCurrentMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) - GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded(activeDrawMode: drawMode) - GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded(activeSuitCount: spiderSuitCount) + // The pooled-bucket splits assign history to the mode in active use. + // The migrated game's own qualifier is the freshest signal for its + // family (stored settings can lag the payload); the other family + // still splits by its stored setting. + GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded( + activeDrawMode: migratedCurrentMode?.drawMode ?? drawMode + ) + GameStatisticsStore.migrateLegacySpiderStatisticsIfNeeded( + activeSuitCount: migratedCurrentMode?.spiderSuitCount ?? spiderSuitCount + ) // The stored selection decides which game's slot the app opens into — // except right after upgrading, when the game migrated out of the diff --git a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift index e8a4526..9886ac8 100644 --- a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift @@ -198,6 +198,43 @@ final class GamePersistenceStoreTests: XCTestCase { XCTAssertNil(GamePersistence.migrateLegacyRecordsIfNeeded(in: context)) } + // The migrated current game's own qualifier steers its family's + // pooled-bucket split — stored settings can lag the payload, and the + // history must land in the bucket of the game that actually opens. + func testMigratedCurrentModeSteersLegacyKlondikeStatisticsSplit() throws { + let context = try makeInMemoryContext() + let suiteName = "test.migration.stats.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let state = GameStateFixtures.seededKlondikeDeal(seed: 7) + try insertLegacyRecord( + makePayload(state: state, movesCount: 5, stockDrawCount: DrawMode.one.rawValue), + in: context + ) + let legacyStats = GameStatistics( + trackedSince: DateFixtures.reference, + gamesPlayed: 8, + gamesWon: 3 + ) + defaults.set( + try JSONEncoder().encode(legacyStats), + forKey: GameStatisticsStore.legacyKlondikeDefaultsKey + ) + + let migratedMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: context) + // Mirrors hydration: stored settings (draw-three here) lose to the + // migrated game's own mode. + GameStatisticsStore.migrateLegacyKlondikeStatisticsIfNeeded( + activeDrawMode: migratedMode?.drawMode ?? .three, + userDefaults: defaults + ) + + XCTAssertEqual(migratedMode, .klondikeDrawOne) + XCTAssertEqual(GameStatisticsStore.load(for: .klondikeDrawOne, userDefaults: defaults).gamesPlayed, 8) + XCTAssertEqual(GameStatisticsStore.load(for: .klondikeDrawThree, userDefaults: defaults).gamesPlayed, 0) + } + func testMigrationIsIdempotent() throws { let context = try makeInMemoryContext() try insertLegacyRecord( @@ -286,11 +323,15 @@ final class GamePersistenceStoreTests: XCTestCase { return ModelContext(container) } - private func makePayload(state: GameState, movesCount: Int) -> SavedGamePayload { + private func makePayload( + state: GameState, + movesCount: Int, + stockDrawCount: Int = DrawMode.three.rawValue + ) -> SavedGamePayload { SavedGamePayload( state: state, movesCount: movesCount, - stockDrawCount: DrawMode.three.rawValue, + stockDrawCount: stockDrawCount, history: [] ) } From 2fd1991970d976c62f20d4d650d4688abf35683d Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 00:11:51 -0700 Subject: [PATCH 6/6] fix game picker accessibility and layout sizing --- .../Views/Shared/BoardViews.swift | 21 ++++++++++++------ .../Views/Shared/ContentView.swift | 17 ++++++++++++-- .../Views/Shared/GameModePickerView.swift | 7 ++++++ .../Shared/LayoutTests.swift | 22 +++++++++++++++++++ .../GameModePickerUITests.swift | 21 ++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 ComputerSolitaireTests/Shared/LayoutTests.swift diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index bc6cdc4..eeee77c 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -14,10 +14,6 @@ enum Layout { let tableauMaxHeight: CGFloat } - /// Estimated height of HeaderView (stat tiles + padding); only feeds the - /// tableau-height budget, so an approximation is fine. - private static let headerHeightEstimate: CGFloat = 66 - /// Worst-case Klondike pile: 6 face-down cards under a full K–A run. private static let maxFaceDownGaps: CGFloat = 6 private static let maxFaceUpGaps: CGFloat = 12 @@ -41,10 +37,11 @@ enum Layout { boardHeight: CGFloat, verticalPadding: CGFloat, rowSpacing: CGFloat, + headerHeight: CGFloat, faceDownFraction: CGFloat, faceUpFraction: CGFloat ) -> CGFloat { - let chrome = (verticalPadding * 2) + headerHeightEstimate + (rowSpacing * 2) + let chrome = (verticalPadding * 2) + headerHeight + (rowSpacing * 2) // Top-row card + pile base card + gaps, in units of card height. let heightUnits = 2 + (readableFaceDownGaps * faceDownFraction) + (readableFaceUpGaps * faceUpFraction) let cardHeight = (boardHeight - chrome) / heightUnits @@ -54,7 +51,8 @@ enum Layout { static func metrics( for boardSize: CGSize, isRegularWidth: Bool = false, - tableauColumnCount: Int = 7 + tableauColumnCount: Int = 7, + headerHeight: CGFloat = HeaderView.estimatedHeight ) -> Metrics { let columnCount = max(1, tableauColumnCount) let boardWidth = boardSize.width @@ -82,6 +80,7 @@ enum Layout { boardHeight: boardSize.height, verticalPadding: verticalPadding, rowSpacing: rowSpacing, + headerHeight: headerHeight, faceDownFraction: faceDownFraction * landscapeOffsetScale, faceUpFraction: faceUpFraction * landscapeOffsetScale ) @@ -92,6 +91,7 @@ enum Layout { boardHeight: boardSize.height, verticalPadding: verticalPadding, rowSpacing: rowSpacing, + headerHeight: headerHeight, cardHeight: cardSize.height ) @@ -145,6 +145,7 @@ enum Layout { boardHeight: boardSize.height, verticalPadding: verticalPadding, rowSpacing: rowSpacing, + headerHeight: headerHeight, faceDownFraction: faceDownFraction, faceUpFraction: faceUpFraction ) @@ -157,6 +158,7 @@ enum Layout { boardHeight: boardSize.height, verticalPadding: verticalPadding, rowSpacing: rowSpacing, + headerHeight: headerHeight, cardHeight: cardSize.height ) @@ -182,14 +184,19 @@ enum Layout { boardHeight: CGFloat, verticalPadding: CGFloat, rowSpacing: CGFloat, + headerHeight: CGFloat, cardHeight: CGFloat ) -> CGFloat { - let chrome = (verticalPadding * 2) + headerHeightEstimate + (rowSpacing * 2) + cardHeight + let chrome = (verticalPadding * 2) + headerHeight + (rowSpacing * 2) + cardHeight return max(cardHeight * 2, boardHeight - chrome) } } struct HeaderView: View { + /// Used only for the first layout pass; ContentView replaces it with the + /// rendered height so future header changes cannot stale the board budget. + static let estimatedHeight: CGFloat = 82 + let gameTitle: String /// The mode qualifier shown dimmed after the title ("3-card"); nil for /// single-mode games. diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index ee0dbde..2808066 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -113,6 +113,7 @@ struct ContentView: View { @State private var hiddenCardIDs: Set = [] @State private var wasteFanProgress: [UUID: Double] = [:] @State private var boardViewportSize: CGSize = .zero + @State private var headerHeight = HeaderView.estimatedHeight @State private var previousWasteCount: Int = 0 @State private var previousStockCount: Int = 0 @State private var hasLoadedGame = false @@ -184,6 +185,7 @@ struct ContentView: View { .environment(\.cardStyle, currentCardStyle) ) ) + .accessibilityHidden(isShowingGamePicker) .overlay { if isShowingGamePicker { GameModePickerOverlay( @@ -480,10 +482,15 @@ struct ContentView: View { let metrics = Layout.metrics( for: geometry.size, isRegularWidth: horizontalSizeClass == .regular, - tableauColumnCount: boardColumnCount + tableauColumnCount: boardColumnCount, + headerHeight: headerHeight ) #else - let metrics = Layout.metrics(for: geometry.size, tableauColumnCount: boardColumnCount) + let metrics = Layout.metrics( + for: geometry.size, + tableauColumnCount: boardColumnCount, + headerHeight: headerHeight + ) #endif let cardSize = metrics.cardSize let boardContentWidth = (cardSize.width * CGFloat(boardColumnCount)) @@ -518,6 +525,12 @@ struct ContentView: View { boardContentWidth: boardContentWidth, onScoreTapped: openScoringDetails ) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { newHeight in + guard abs(newHeight - headerHeight) >= 0.5 else { return } + headerHeight = newHeight + } } TopRowView( viewModel: viewModel, diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index 0c6c3f1..f7f6fec 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -256,6 +256,7 @@ struct GameModePickerOverlay: View { /// a custom overlay sits outside the window's cancel-action routing that /// sheets get for free. @FocusState private var isPickerFocused: Bool + @AccessibilityFocusState private var isPickerAccessibilityFocused: Bool var body: some View { ZStack { @@ -291,7 +292,13 @@ struct GameModePickerOverlay: View { ) .environment(\.colorScheme, .dark) .padding(24) + .accessibilityElement(children: .contain) + .accessibilityLabel("Game picker") + .accessibilityFocused($isPickerAccessibilityFocused) } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityDefaultFocus($isPickerAccessibilityFocused, true) .focusable() .focusEffectDisabled() .focused($isPickerFocused) diff --git a/ComputerSolitaireTests/Shared/LayoutTests.swift b/ComputerSolitaireTests/Shared/LayoutTests.swift new file mode 100644 index 0000000..9f2e282 --- /dev/null +++ b/ComputerSolitaireTests/Shared/LayoutTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class LayoutTests: XCTestCase { + func testMeasuredHeaderHeightReducesTableauBudget() { + let boardSize = CGSize(width: 1_200, height: 900) + let shorterHeader = Layout.metrics( + for: boardSize, + tableauColumnCount: 7, + headerHeight: 66 + ) + let tallerHeader = Layout.metrics( + for: boardSize, + tableauColumnCount: 7, + headerHeight: 82 + ) + + XCTAssertLessThan(tallerHeader.cardSize.height, shorterHeader.cardSize.height) + XCTAssertLessThan(tallerHeader.tableauMaxHeight, shorterHeader.tableauMaxHeight) + } +} diff --git a/ComputerSolitaireUITests/GameModePickerUITests.swift b/ComputerSolitaireUITests/GameModePickerUITests.swift index 4fedcf4..60e45f3 100644 --- a/ComputerSolitaireUITests/GameModePickerUITests.swift +++ b/ComputerSolitaireUITests/GameModePickerUITests.swift @@ -22,6 +22,27 @@ final class GameModePickerUITests: XCTestCase { XCTAssertTrue(scrim.waitForNonExistence(timeout: 3), "Escape should dismiss the picker overlay") } + /// The custom overlay must behave like a system modal for assistive + /// technologies: obscured board controls leave the accessibility tree. + @MainActor + func testPickerHidesBoardAccessibility() throws { + let app = XCUIApplication() + app.launch() + + let titleButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Switch game mode'") + ).firstMatch + XCTAssertTrue(titleButton.waitForExistence(timeout: 5), "Game title button should be on the board") + titleButton.click() + + let scrim = app.buttons["Dismiss game picker"] + XCTAssertTrue(scrim.waitForExistence(timeout: 3), "Picker overlay should open from the title button") + XCTAssertTrue( + titleButton.waitForNonExistence(timeout: 3), + "The obscured board should leave the accessibility tree while the picker is open" + ) + } + /// A window near the supported minimum height can't fit all six family /// cards; the picker must fall back to scrolling so every game stays /// reachable.