Files
Sportstime/SportsTime/Core/Services/AchievementEngine.swift
Trey t 04b62f147e fix: resolve compiler warnings across codebase
- PaywallView: remove unnecessary nil coalescing for currencyCode
- GameDAGRouter: change var to let for immutable compositeKeys
- GamesHistoryRow/View: add missing wnba and nwsl switch cases
- VisitDetailView: fix unused variable in preview
- AchievementEngine: use convenience init to avoid default parameter warning
- ProgressCardGenerator: use method overload instead of default parameter
- StadiumProximityMatcher: extract constants to ProximityConstants enum

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:16:40 -06:00

459 lines
17 KiB
Swift

//
// 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
// 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<String> = []
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<String>
) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>
) -> (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 }
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<CanonicalTeam>(
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] {
// Get all stadium canonical IDs for this sport
return dataProvider.stadiums
.filter { stadium in
// Check if stadium hosts teams of this sport
dataProvider.teams.contains { team in
team.stadiumId == stadium.id && team.sport == sport
}
}
.map { $0.id }
}
// MARK: - Data Fetching
private func fetchAllVisits() throws -> [StadiumVisit] {
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .forward)]
)
return try modelContext.fetch(descriptor)
}
private func fetchEarnedAchievements() throws -> [Achievement] {
let descriptor = FetchDescriptor<Achievement>(
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)"
}
}