// // AchievementEngine.swift // SportsTime // // Computes achievements based on stadium visits. // Recalculates and revokes achievements when visits are deleted. // import Foundation import SwiftData // MARK: - Achievement Delta struct AchievementDelta: Sendable { let newlyEarned: [AchievementDefinition] let revoked: [AchievementDefinition] let stillEarned: [AchievementDefinition] var hasChanges: Bool { !newlyEarned.isEmpty || !revoked.isEmpty } } // MARK: - Achievement Engine @MainActor final class AchievementEngine { // MARK: - Properties private let modelContext: ModelContext private let dataProvider: AppDataProvider private var stadiumIdsBySport: [Sport: [String]]? // MARK: - Initialization init(modelContext: ModelContext, dataProvider: AppDataProvider) { self.modelContext = modelContext self.dataProvider = dataProvider } /// Convenience initializer using the shared data provider convenience init(modelContext: ModelContext) { self.init(modelContext: modelContext, dataProvider: AppDataProvider.shared) } // MARK: - Public API /// Full recalculation (call after visit deleted or on app update) func recalculateAllAchievements() async throws -> AchievementDelta { // Get all visits let visits = try fetchAllVisits() let visitedStadiumIds = Set(visits.map { $0.stadiumId }) // Get currently earned achievements let currentAchievements = try fetchEarnedAchievements() let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId }) // Calculate which achievements should be earned var shouldBeEarned: Set = [] var newlyEarnedDefinitions: [AchievementDefinition] = [] var revokedDefinitions: [AchievementDefinition] = [] var stillEarnedDefinitions: [AchievementDefinition] = [] for definition in AchievementRegistry.all { let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds) if isEarned { shouldBeEarned.insert(definition.id) if currentAchievementIds.contains(definition.id) { stillEarnedDefinitions.append(definition) } else { newlyEarnedDefinitions.append(definition) } } else if currentAchievementIds.contains(definition.id) { revokedDefinitions.append(definition) } } // Apply changes // Grant new achievements for definition in newlyEarnedDefinitions { let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits) let achievement = Achievement( achievementTypeId: definition.id, sport: definition.sport, visitIds: visitIds ) modelContext.insert(achievement) } // Revoke achievements for definition in revokedDefinitions { if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id }) { achievement.revoke() } } // Restore previously revoked achievements that are now earned again for definition in stillEarnedDefinitions { if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id && $0.revokedAt != nil }) { achievement.restore() } } try modelContext.save() return AchievementDelta( newlyEarned: newlyEarnedDefinitions, revoked: revokedDefinitions, stillEarned: stillEarnedDefinitions ) } /// Quick check after new visit (incremental) func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] { let visits = try fetchAllVisits() let visitedStadiumIds = Set(visits.map { $0.stadiumId }) let currentAchievements = try fetchEarnedAchievements() let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId }) var newlyEarned: [AchievementDefinition] = [] for definition in AchievementRegistry.all { // Skip already earned guard !currentAchievementIds.contains(definition.id) else { continue } let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds) if isEarned { newlyEarned.append(definition) let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits) let achievement = Achievement( achievementTypeId: definition.id, sport: definition.sport, visitIds: visitIds ) modelContext.insert(achievement) } } try modelContext.save() return newlyEarned } /// Get all earned achievements func getEarnedAchievements() throws -> [AchievementDefinition] { let achievements = try fetchEarnedAchievements() return achievements.compactMap { AchievementRegistry.achievement(byId: $0.achievementTypeId) } } /// Get progress toward all achievements func getProgress() async throws -> [AchievementProgress] { let visits = try fetchAllVisits() let visitedStadiumIds = Set(visits.map { $0.stadiumId }) let earnedAchievements = try fetchEarnedAchievements() let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId }) var progress: [AchievementProgress] = [] for definition in AchievementRegistry.all { let (current, total) = calculateProgress( for: definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds ) let hasStoredAchievement = earnedIds.contains(definition.id) let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt progress.append(AchievementProgress( definition: definition, currentProgress: current, totalRequired: total, hasStoredAchievement: hasStoredAchievement, earnedAt: earnedAt )) } return progress } // MARK: - Requirement Checking private func checkRequirement( _ requirement: AchievementRequirement, visits: [StadiumVisit], visitedStadiumIds: Set ) -> Bool { switch requirement { case .firstVisit: return !visits.isEmpty case .visitCount(let count): return visitedStadiumIds.count >= count case .visitCountForSport(let count, let sport): let sportVisits = visits.filter { $0.sport == sport.rawValue } let sportStadiums = Set(sportVisits.map { $0.stadiumId }) return sportStadiums.count >= count case .completeDivision(let divisionId): return checkDivisionComplete(divisionId, visitedStadiumIds: visitedStadiumIds) case .completeConference(let conferenceId): return checkConferenceComplete(conferenceId, visitedStadiumIds: visitedStadiumIds) case .completeLeague(let sport): return checkLeagueComplete(sport, visitedStadiumIds: visitedStadiumIds) case .visitsInDays(let visitCount, let days): return checkVisitsInDays(visits: visits, requiredVisits: visitCount, withinDays: days) case .multipleLeagues(let leagueCount): return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount) case .specificStadium(let stadiumId): // Direct comparison - canonical IDs match everywhere return visitedStadiumIds.contains(stadiumId) } } private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set) -> Bool { guard let division = LeagueStructure.division(byId: divisionId) else { return false } // Get stadium IDs for teams in this division let stadiumIds = getStadiumIdsForDivision(divisionId) guard !stadiumIds.isEmpty else { return false } return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } } private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set) -> Bool { guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false } // Get stadium IDs for all teams in this conference let stadiumIds = getStadiumIdsForConference(conferenceId) guard !stadiumIds.isEmpty else { return false } return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } } private func checkLeagueComplete(_ sport: Sport, visitedStadiumIds: Set) -> Bool { let stadiumIds = getStadiumIdsForLeague(sport) guard !stadiumIds.isEmpty else { return false } return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } } private func checkVisitsInDays(visits: [StadiumVisit], requiredVisits: Int, withinDays: Int) -> Bool { guard visits.count >= requiredVisits else { return false } // Sort visits by date let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate } // Sliding window for i in 0...(sortedVisits.count - requiredVisits) { let windowStart = sortedVisits[i].visitDate let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max if daysDiff < withinDays { // Check unique stadiums in window let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)]) let uniqueStadiums = Set(windowVisits.map { $0.stadiumId }) if uniqueStadiums.count >= requiredVisits { return true } } } return false } private func checkMultipleLeagues(visits: [StadiumVisit], requiredLeagues: Int) -> Bool { let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) }) return leagues.count >= requiredLeagues } // MARK: - Progress Calculation private func calculateProgress( for requirement: AchievementRequirement, visits: [StadiumVisit], visitedStadiumIds: Set ) -> (current: Int, total: Int) { switch requirement { case .firstVisit: return (visits.isEmpty ? 0 : 1, 1) case .visitCount(let count): return (visitedStadiumIds.count, count) case .visitCountForSport(let count, let sport): let sportVisits = visits.filter { $0.sport == sport.rawValue } let sportStadiums = Set(sportVisits.map { $0.stadiumId }) return (sportStadiums.count, count) case .completeDivision(let divisionId): let stadiumIds = getStadiumIdsForDivision(divisionId) let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count return (visited, stadiumIds.count) case .completeConference(let conferenceId): let stadiumIds = getStadiumIdsForConference(conferenceId) let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count return (visited, stadiumIds.count) case .completeLeague(let sport): let stadiumIds = getStadiumIdsForLeague(sport) let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count return (visited, stadiumIds.count) case .visitsInDays(let visitCount, _): // For journey achievements, show total unique stadiums vs required return (min(visitedStadiumIds.count, visitCount), visitCount) case .multipleLeagues(let leagueCount): let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) }) return (leagues.count, leagueCount) case .specificStadium(let stadiumId): // Direct comparison - canonical IDs match everywhere return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1) } } // MARK: - Contributing Visits private func getContributingVisitIds(for requirement: AchievementRequirement, visits: [StadiumVisit]) -> [UUID] { switch requirement { case .firstVisit: return visits.first.map { [$0.id] } ?? [] case .visitCount, .visitCountForSport, .multipleLeagues: // All visits contribute return visits.map { $0.id } case .completeDivision(let divisionId): let stadiumIds = Set(getStadiumIdsForDivision(divisionId)) return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id } case .completeConference(let conferenceId): let stadiumIds = Set(getStadiumIdsForConference(conferenceId)) return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id } case .completeLeague(let sport): let stadiumIds = Set(getStadiumIdsForLeague(sport)) return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id } case .visitsInDays(let requiredVisits, let days): // Find the qualifying window of visits let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate } guard sortedVisits.count >= requiredVisits else { return [] } for i in 0...(sortedVisits.count - requiredVisits) { let windowStart = sortedVisits[i].visitDate let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max if daysDiff < days { return Array(sortedVisits[i..<(i + requiredVisits)]).map { $0.id } } } return [] case .specificStadium(let stadiumId): // Direct comparison - canonical IDs match everywhere return visits.filter { $0.stadiumId == stadiumId }.map { $0.id } } } // MARK: - Stadium Lookups private func getStadiumIdsForDivision(_ divisionId: String) -> [String] { // Query CanonicalTeam to find teams in this division let descriptor = FetchDescriptor( predicate: #Predicate { $0.divisionId == divisionId && $0.deprecatedAt == nil } ) guard let canonicalTeams = try? modelContext.fetch(descriptor) else { return [] } // Get canonical stadium IDs for these teams return canonicalTeams.map { $0.stadiumCanonicalId } } private func getStadiumIdsForConference(_ conferenceId: String) -> [String] { guard let conference = LeagueStructure.conference(byId: conferenceId) else { return [] } var stadiumIds: [String] = [] for divisionId in conference.divisionIds { stadiumIds.append(contentsOf: getStadiumIdsForDivision(divisionId)) } return stadiumIds } private func getStadiumIdsForLeague(_ sport: Sport) -> [String] { // Build cache lazily on first access if stadiumIdsBySport == nil { var cache: [Sport: [String]] = [:] for team in dataProvider.teams { cache[team.sport, default: []].append(team.stadiumId) } stadiumIdsBySport = cache } return stadiumIdsBySport?[sport] ?? [] } // MARK: - Data Fetching private func fetchAllVisits() throws -> [StadiumVisit] { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.visitDate, order: .forward)] ) return try modelContext.fetch(descriptor) } private func fetchEarnedAchievements() throws -> [Achievement] { let descriptor = FetchDescriptor( predicate: #Predicate { $0.revokedAt == nil } ) return try modelContext.fetch(descriptor) } } // MARK: - Achievement Progress struct AchievementProgress: Identifiable { let definition: AchievementDefinition let currentProgress: Int let totalRequired: Int let hasStoredAchievement: Bool // Whether an Achievement record exists in SwiftData let earnedAt: Date? var id: String { definition.id } /// Whether the achievement is earned (either stored or computed from progress) var isEarned: Bool { // Earned if we have a stored record OR if progress is complete hasStoredAchievement || (totalRequired > 0 && currentProgress >= totalRequired) } var progressPercentage: Double { guard totalRequired > 0 else { return 0 } return Double(currentProgress) / Double(totalRequired) } var progressText: String { if isEarned { return "Completed" } return "\(currentProgress)/\(totalRequired)" } }