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..909638c 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,111 @@ 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. 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. + @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 + ) else { + modelContext.delete(record) + continue + } + if isLegacyCurrentSlot { + migratedCurrentMode = payload.gameMode + } + + 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() + } + return migratedCurrentMode + } catch { + // Leave the store untouched; hydration falls back to a fresh deal. + return nil + } + } + + private static func fetchRecord(forKey key: String, in modelContext: ModelContext) throws -> SavedGameRecord? { var descriptor = FetchDescriptor( predicate: #Predicate { record in record.key == key @@ -551,15 +672,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 +696,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..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,54 +184,76 @@ 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. + 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 +273,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 +348,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..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 @@ -123,6 +124,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 +137,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 +158,7 @@ struct ContentView: View { } private var isAnyMenuPresented: Bool { - isShowingSettings || isShowingRulesAndScoring || isShowingStats + isShowingSettings || isShowingRulesAndScoring || isShowingStats || isShowingGamePicker } private var shouldPauseForLifecycle: Bool { @@ -181,6 +185,29 @@ struct ContentView: View { .environment(\.cardStyle, currentCardStyle) ) ) + .accessibilityHidden(isShowingGamePicker) + .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 +329,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) { @@ -319,13 +376,9 @@ struct ContentView: View { } } .sheet(isPresented: $isShowingStats) { -#if os(iOS) - NavigationStack { - StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) - } -#else - StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) -#endif + // StatisticsView owns its NavigationStack: it drills from the + // all-games overview into per-game detail on both platforms. + StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) } ) } @@ -346,38 +399,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() @@ -452,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)) @@ -490,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, @@ -727,10 +768,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 +825,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 +846,7 @@ struct ContentView: View { private var gameMenuState: GameMenuState { GameMenuState( + currentVariant: gameVariant, canUndo: !isUndoDisabled, canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, canHint: !isHintDisabled, @@ -812,15 +862,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 +892,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 +1569,37 @@ struct ContentView: View { previousStockCount = viewModel.state.stock.count } + let migratedCurrentMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) + // 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 + // 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(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: launchMode, from: modelContext), + viewModel.restore(from: payload) { + rememberSelectedGame() } else { winCelebration.reset(to: .idle) - viewModel.newGame(variant: gameVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) + viewModel.newGame(mode: launchMode) + rememberSelectedGame() persistGameNow() } winCelebration.syncForLoadedGame( @@ -1476,6 +1610,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 +1722,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..f7f6fec --- /dev/null +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -0,0 +1,552 @@ +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 cardBackColor: CardBackColor + 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, + cardBackColor: cardBackColor, + 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, + cardBackColor: cardBackColor + ) + + 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 + + @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. + @FocusState private var isPickerFocused: Bool + @AccessibilityFocusState private var isPickerAccessibilityFocused: 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) + .accessibilityElement(children: .contain) + .accessibilityLabel("Game picker") + .accessibilityFocused($isPickerAccessibilityFocused) + } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityDefaultFocus($isPickerAccessibilityFocused, true) + .focusable() + .focusEffectDisabled() + .focused($isPickerFocused) + .onKeyPress(.escape) { + onDismiss() + return .handled + } + .onAppear { isPickerFocused = true } + } + + private var picker: some View { + GameModePickerView( + entries: entries, + currentMode: currentMode, + feltColor: feltColor, + cardBackColor: CardBackColor.from(rawValue: cardBackColorRawValue), + 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 + let cardBackColor: CardBackColor + 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(cardBackColor.swatch) + 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..cf510de 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -2,123 +2,250 @@ 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 - @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 { - case klondike - case freecell - case yukon - case spider - case pyramid - case tripeaks - 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() } + ) } } + } +} - /// 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 +// 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 + + 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?, initialVariant: GameVariant = .klondike) { +// 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: initialVariant)) + self.variant = variant + self.onDone = onDone + _scopeMode = State( + initialValue: initialMode.variant == variant ? initialMode : GameMode(variant: variant) + ) + } + + private var effectiveMode: GameMode { + let modes = GameMode.modes(for: variant) + guard modes.count > 1 else { return modes.first ?? scopeMode } + return modes.contains(scopeMode) ? scopeMode : GameMode(variant: variant) + } + + private var effectiveScopeTitle: String { + 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) + 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) + } } + .pickerStyle(.segmented) + .labelsHidden() } - .pickerStyle(.segmented) - .labelsHidden() } 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)" @@ -130,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 } @@ -143,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) @@ -164,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) { @@ -187,30 +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 + .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.") } } @@ -259,75 +374,37 @@ 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) - } - - /// 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. - 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 [] - } - } - - 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" + return statsDurationLabel(bestTimeSeconds) } - private var secondaryHighlightValue: String { - if selectedScope == .all { - return "\(stats.gamesPlayed)" + /// 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 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 } - 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 activeVariantMatchesSelectedScope { + if activeModeMatchesScope { liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 } else { liveElapsed = 0 @@ -336,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") @@ -396,108 +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() { - if let variant = selectedScope.variant { - GameStatisticsStore.reset(for: variant) - } else { - for variant in GameVariant.allCases { - GameStatisticsStore.reset(for: variant) - } - } - - if selectedScope == .all || activeVariantMatchesSelectedScope { + 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 activeVariantMatchesSelectedScope: Bool { - guard let variant = selectedScope.variant else { return true } - return viewModel?.gameVariant == variant + private var activeModeMatchesScope: Bool { + viewModel?.gameMode == effectiveMode } - 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?" - } + private func loadStats() { + stats = GameStatisticsStore.load(for: effectiveMode) } +} - 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" - } - } +// MARK: - Shared pieces - 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." - } +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 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 variant = selectedScope.variant { - stats = GameStatisticsStore.load(for: variant) - } else { - stats = GameStatistics.aggregated( - GameVariant.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 - } } } 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/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) + } } diff --git a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift index ef07303..9886ac8 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) + + 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) + + // 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) - try GamePersistence.save(first, in: context) - try GamePersistence.save(second, in: context) + let klondike = GamePersistence.load(mode: .klondikeDrawThree, from: context) + let freecell = GamePersistence.load(mode: .freecell, from: context) - let loaded = GamePersistence.load(from: context) - XCTAssertEqual(loaded?.movesCount, 9) - XCTAssertEqual(loaded?.score, 90) - XCTAssertEqual(loaded?.stockDrawCount, DrawMode.one.rawValue) + 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,244 @@ 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)) + } + + // 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)) + } + + // 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( + 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, + stockDrawCount: Int = DrawMode.three.rawValue + ) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: movesCount, + stockDrawCount: stockDrawCount, + 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/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/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..60e45f3 --- /dev/null +++ b/ComputerSolitaireUITests/GameModePickerUITests.swift @@ -0,0 +1,76 @@ +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") + } + + /// 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. + @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) |