Remove unused planning files
Deleted files replaced by new scenario-based planners: - RouteCandidateBuilder.swift - RouteOptimizer.swift (TSP solver) - ScheduleMatcher.swift (corridor logic) - LegacyPlanningTypes.swift - TripScorer.swift - DateRangeValidator.swift - DrivingFeasibilityValidator.swift - GeographicSanityChecker.swift - MustStopValidator.swift These were superseded by ScenarioA/B/C planners, GeographicRouteExplorer, and ItineraryBuilder. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,232 +0,0 @@
|
||||
//
|
||||
// RouteCandidateBuilder.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Builds route candidates for different planning scenarios
|
||||
enum RouteCandidateBuilder {
|
||||
|
||||
// MARK: - Scenario A: Linear Candidates (Date Range)
|
||||
|
||||
/// Builds linear route candidates from games sorted chronologically
|
||||
/// - Parameters:
|
||||
/// - games: Available games sorted by start time
|
||||
/// - mustStop: Optional must-stop location
|
||||
/// - Returns: Array of route candidates
|
||||
static func buildLinearCandidates(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
mustStop: LocationInput?
|
||||
) -> [RouteCandidate] {
|
||||
guard !games.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Group games by stadium
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Build stops from chronological game order
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStop = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Look up stadium for coordinates and city info
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
guard !stops.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Linear route through \(stops.count) cities"
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Scenario B: Expand Around Anchors (Selected Games)
|
||||
|
||||
/// Expands route around user-selected anchor games
|
||||
/// - Parameters:
|
||||
/// - anchors: User-selected games (must-see)
|
||||
/// - allGames: All available games
|
||||
/// - dateRange: Trip date range
|
||||
/// - mustStop: Optional must-stop location
|
||||
/// - Returns: Array of route candidates
|
||||
static func expandAroundAnchors(
|
||||
anchors: [Game],
|
||||
allGames: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
dateRange: DateInterval,
|
||||
mustStop: LocationInput?
|
||||
) -> [RouteCandidate] {
|
||||
guard !anchors.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Start with anchor games as the core route
|
||||
let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Build stops from anchor games
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
for game in sortedAnchors {
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: [game.id],
|
||||
arrivalDate: game.gameDate,
|
||||
departureDate: game.gameDate,
|
||||
location: location,
|
||||
firstGameStart: game.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
guard !stops.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Route connecting \(anchors.count) selected games"
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Scenario C: Directional Routes (Start + End)
|
||||
|
||||
/// Builds directional routes from start to end location
|
||||
/// - Parameters:
|
||||
/// - start: Start location
|
||||
/// - end: End location
|
||||
/// - games: Available games
|
||||
/// - dateRange: Optional trip date range
|
||||
/// - Returns: Array of route candidates
|
||||
static func buildDirectionalRoutes(
|
||||
start: LocationInput,
|
||||
end: LocationInput,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
dateRange: DateInterval?
|
||||
) -> [RouteCandidate] {
|
||||
// Filter games by date range if provided
|
||||
let filteredGames: [Game]
|
||||
if let range = dateRange {
|
||||
filteredGames = games.filter { range.contains($0.startTime) }
|
||||
} else {
|
||||
filteredGames = games
|
||||
}
|
||||
|
||||
guard !filteredGames.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Sort games chronologically
|
||||
let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Build stops: start -> games -> end
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
// Start stop (no games)
|
||||
let startStop = ItineraryStop(
|
||||
city: start.name,
|
||||
state: "",
|
||||
coordinate: start.coordinate,
|
||||
games: [],
|
||||
arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||
departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||
location: start,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(startStop)
|
||||
|
||||
// Game stops
|
||||
for game in sortedGames {
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: [game.id],
|
||||
arrivalDate: game.gameDate,
|
||||
departureDate: game.gameDate,
|
||||
location: location,
|
||||
firstGameStart: game.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
// End stop (no games)
|
||||
let endStop = ItineraryStop(
|
||||
city: end.name,
|
||||
state: "",
|
||||
coordinate: end.coordinate,
|
||||
games: [],
|
||||
arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||
location: end,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(endStop)
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Directional route from \(start.name) to \(end.name)"
|
||||
)]
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
//
|
||||
// RouteOptimizer.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Optimization strategy for ranking itinerary options.
|
||||
enum OptimizationStrategy {
|
||||
case balanced // Balance games vs driving
|
||||
case maximizeGames // Prioritize seeing more games
|
||||
case minimizeDriving // Prioritize shorter routes
|
||||
case scenic // Prioritize scenic routes
|
||||
}
|
||||
|
||||
/// Route optimizer for ranking and scoring itinerary options.
|
||||
///
|
||||
/// The TSP-solving logic has been moved to scenario-specific candidate
|
||||
/// generation in TripPlanningEngine. This optimizer now focuses on:
|
||||
/// - Ranking multiple route options
|
||||
/// - Scoring routes based on optimization strategy
|
||||
/// - Reordering games within constraints
|
||||
struct RouteOptimizer {
|
||||
|
||||
// MARK: - Route Ranking
|
||||
|
||||
/// Ranks a list of itinerary options based on the optimization strategy.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - options: Unranked itinerary options
|
||||
/// - strategy: Optimization strategy for scoring
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Options sorted by score (best first) with rank assigned
|
||||
func rankOptions(
|
||||
_ options: [ItineraryOption],
|
||||
strategy: OptimizationStrategy = .balanced,
|
||||
request: PlanningRequest
|
||||
) -> [ItineraryOption] {
|
||||
// Score each option
|
||||
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
|
||||
let score = scoreOption(option, strategy: strategy, request: request)
|
||||
return (option, score)
|
||||
}
|
||||
|
||||
// Sort by score (lower is better for our scoring system)
|
||||
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
|
||||
|
||||
// Assign ranks
|
||||
return sorted.enumerated().map { index, scored in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: scored.0.stops,
|
||||
travelSegments: scored.0.travelSegments,
|
||||
totalDrivingHours: scored.0.totalDrivingHours,
|
||||
totalDistanceMiles: scored.0.totalDistanceMiles,
|
||||
geographicRationale: scored.0.geographicRationale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Scores an itinerary option based on the optimization strategy.
|
||||
/// Lower scores are better.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - option: Itinerary option to score
|
||||
/// - strategy: Optimization strategy
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Score value (lower is better)
|
||||
func scoreOption(
|
||||
_ option: ItineraryOption,
|
||||
strategy: OptimizationStrategy,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
switch strategy {
|
||||
case .balanced:
|
||||
return scoreBalanced(option, request: request)
|
||||
case .maximizeGames:
|
||||
return scoreMaximizeGames(option, request: request)
|
||||
case .minimizeDriving:
|
||||
return scoreMinimizeDriving(option, request: request)
|
||||
case .scenic:
|
||||
return scoreScenic(option, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Balanced scoring: trade-off between games and driving.
|
||||
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Each game "saves" 2 hours of driving in value
|
||||
let gameValue = Double(option.totalGames) * 2.0
|
||||
let drivingPenalty = option.totalDrivingHours
|
||||
|
||||
// Also factor in must-see games coverage
|
||||
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
|
||||
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
|
||||
|
||||
return drivingPenalty - gameValue - mustSeeBonus
|
||||
}
|
||||
|
||||
/// Maximize games scoring: prioritize number of games.
|
||||
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Heavily weight game count
|
||||
let gameScore = -Double(option.totalGames) * 100.0
|
||||
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
|
||||
|
||||
return gameScore + drivingPenalty
|
||||
}
|
||||
|
||||
/// Minimize driving scoring: prioritize shorter routes.
|
||||
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Primarily driving time
|
||||
let drivingScore = option.totalDrivingHours
|
||||
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
|
||||
|
||||
return drivingScore - gameBonus
|
||||
}
|
||||
|
||||
/// Scenic scoring: balance games with route pleasantness.
|
||||
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// More relaxed pacing is better
|
||||
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
|
||||
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
|
||||
|
||||
let drivingScore = option.totalDrivingHours * 0.3
|
||||
let gameBonus = Double(option.totalGames) * 2.0
|
||||
|
||||
return pacingScore + drivingScore - gameBonus
|
||||
}
|
||||
|
||||
/// Calculates what percentage of must-see games are covered.
|
||||
private func calculateMustSeeCoverage(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
let mustSeeIds = request.preferences.mustSeeGameIds
|
||||
if mustSeeIds.isEmpty { return 1.0 }
|
||||
|
||||
let coveredGames = option.stops.flatMap { $0.games }
|
||||
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
|
||||
|
||||
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
|
||||
}
|
||||
|
||||
// MARK: - Route Improvement
|
||||
|
||||
/// Attempts to improve a route by swapping non-essential stops.
|
||||
/// Only applies to stops without must-see games.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - option: Itinerary option to improve
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Improved itinerary option, or original if no improvement found
|
||||
func improveRoute(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> ItineraryOption {
|
||||
// For now, return as-is since games must remain in chronological order
|
||||
// Future: implement swap logic for same-day games in different cities
|
||||
return option
|
||||
}
|
||||
|
||||
// MARK: - Validation Helpers
|
||||
|
||||
/// Checks if all must-see games are included in the option.
|
||||
func includesAllMustSeeGames(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Bool {
|
||||
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
|
||||
}
|
||||
|
||||
/// Returns must-see games that are missing from the option.
|
||||
func missingMustSeeGames(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Set<UUID> {
|
||||
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||
return request.preferences.mustSeeGameIds.subtracting(includedGames)
|
||||
}
|
||||
|
||||
// MARK: - Distance Calculations
|
||||
|
||||
/// Calculates total route distance for a sequence of coordinates.
|
||||
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
|
||||
guard coordinates.count >= 2 else { return 0 }
|
||||
|
||||
var total: Double = 0
|
||||
for i in 0..<(coordinates.count - 1) {
|
||||
total += distance(from: coordinates[i], to: coordinates[i + 1])
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Calculates distance between two coordinates in meters.
|
||||
private func distance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLoc.distance(from: toLoc)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Support
|
||||
|
||||
/// Legacy optimization method for backward compatibility.
|
||||
/// Delegates to the new TripPlanningEngine for actual routing.
|
||||
func optimize(
|
||||
graph: RouteGraph,
|
||||
request: PlanningRequest,
|
||||
candidates: [GameCandidate],
|
||||
strategy: OptimizationStrategy = .balanced
|
||||
) -> CandidateRoute {
|
||||
// Build a simple chronological route from candidates
|
||||
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
|
||||
var route = CandidateRoute()
|
||||
|
||||
// Add start node
|
||||
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
|
||||
route.nodeSequence.append(startNode.id)
|
||||
}
|
||||
|
||||
// Add stadium nodes in chronological order
|
||||
var visitedStadiums = Set<UUID>()
|
||||
for candidate in sortedCandidates {
|
||||
// Find the node for this stadium
|
||||
for node in graph.nodes {
|
||||
if case .stadium(let stadiumId) = node.type,
|
||||
stadiumId == candidate.stadium.id,
|
||||
!visitedStadiums.contains(stadiumId) {
|
||||
route.nodeSequence.append(node.id)
|
||||
route.games.append(candidate.game.id)
|
||||
visitedStadiums.insert(stadiumId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add end node
|
||||
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
|
||||
route.nodeSequence.append(endNode.id)
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
for i in 0..<(route.nodeSequence.count - 1) {
|
||||
if let edge = graph.edges(from: route.nodeSequence[i])
|
||||
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
|
||||
route.totalDistance += edge.distanceMeters
|
||||
route.totalDuration += edge.durationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// Score the route
|
||||
route.score = scoreRoute(route, strategy: strategy, graph: graph)
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
/// Legacy route scoring for CandidateRoute.
|
||||
private func scoreRoute(
|
||||
_ route: CandidateRoute,
|
||||
strategy: OptimizationStrategy,
|
||||
graph: RouteGraph
|
||||
) -> Double {
|
||||
switch strategy {
|
||||
case .balanced:
|
||||
return route.totalDuration - Double(route.games.count) * 3600 * 2
|
||||
|
||||
case .maximizeGames:
|
||||
return -Double(route.games.count) * 10000 + route.totalDuration
|
||||
|
||||
case .minimizeDriving:
|
||||
return route.totalDuration
|
||||
|
||||
case .scenic:
|
||||
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
//
|
||||
// ScheduleMatcher.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Finds and scores candidate games for trip planning.
|
||||
///
|
||||
/// Updated for the new scenario-based planning:
|
||||
/// - Scenario A (Date Range): Find games in date range, cluster by region
|
||||
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
|
||||
/// - Scenario C (Start+End): Find games along directional corridor with progress check
|
||||
struct ScheduleMatcher {
|
||||
|
||||
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
|
||||
|
||||
/// Finds candidate games along a corridor between start and end.
|
||||
/// Supports directional filtering for Scenario C.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: Planning request with preferences and games
|
||||
/// - startCoordinate: Starting location
|
||||
/// - endCoordinate: Ending location
|
||||
/// - enforceDirection: If true, only include games that make progress toward end
|
||||
/// - Returns: Array of game candidates sorted by score
|
||||
func findCandidateGames(
|
||||
from request: PlanningRequest,
|
||||
startCoordinate: CLLocationCoordinate2D,
|
||||
endCoordinate: CLLocationCoordinate2D,
|
||||
enforceDirection: Bool = false
|
||||
) -> [GameCandidate] {
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
// Calculate the corridor between start and end
|
||||
let corridor = RouteCorridorCalculator(
|
||||
start: startCoordinate,
|
||||
end: endCoordinate,
|
||||
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
|
||||
)
|
||||
|
||||
for game in request.availableGames {
|
||||
guard let stadium = request.stadiums[game.stadiumId],
|
||||
let homeTeam = request.teams[game.homeTeamId],
|
||||
let awayTeam = request.teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if game is within date range
|
||||
guard game.dateTime >= request.preferences.startDate,
|
||||
game.dateTime <= request.preferences.endDate else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check sport filter
|
||||
guard request.preferences.sports.contains(game.sport) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate detour distance
|
||||
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||
|
||||
// For directional routes, check if this stadium makes progress
|
||||
if enforceDirection {
|
||||
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
|
||||
let startDistanceToEnd = corridor.directDistance
|
||||
|
||||
// Skip if stadium is behind the start (going backwards)
|
||||
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if too far from route (unless must-see)
|
||||
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
// Score the game
|
||||
let score = scoreGame(
|
||||
game: game,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
isMustSee: isMustSee,
|
||||
request: request
|
||||
)
|
||||
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
score: score
|
||||
)
|
||||
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
return candidates.sorted { $0.score > $1.score }
|
||||
}
|
||||
|
||||
// MARK: - Directional Game Filtering (Scenario C)
|
||||
|
||||
/// Finds games along a directional route from start to end.
|
||||
/// Ensures monotonic progress toward destination.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: Planning request
|
||||
/// - startCoordinate: Starting location
|
||||
/// - endCoordinate: Destination
|
||||
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
|
||||
/// - Returns: Games sorted by their position along the route
|
||||
func findDirectionalGames(
|
||||
from request: PlanningRequest,
|
||||
startCoordinate: CLLocationCoordinate2D,
|
||||
endCoordinate: CLLocationCoordinate2D,
|
||||
corridorWidthPercent: Double = 0.3
|
||||
) -> [GameCandidate] {
|
||||
let corridor = RouteCorridorCalculator(
|
||||
start: startCoordinate,
|
||||
end: endCoordinate,
|
||||
maxDetourFactor: 1.0 + corridorWidthPercent
|
||||
)
|
||||
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
for game in request.availableGames {
|
||||
guard let stadium = request.stadiums[game.stadiumId],
|
||||
let homeTeam = request.teams[game.homeTeamId],
|
||||
let awayTeam = request.teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Date and sport filter
|
||||
guard game.dateTime >= request.preferences.startDate,
|
||||
game.dateTime <= request.preferences.endDate,
|
||||
request.preferences.sports.contains(game.sport) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate progress along route (0 = start, 1 = end)
|
||||
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
|
||||
|
||||
// Only include games that are along the route (positive progress, not behind start)
|
||||
guard progress >= -0.1 && progress <= 1.1 else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check corridor width
|
||||
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||
|
||||
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
let score = scoreGame(
|
||||
game: game,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
isMustSee: isMustSee,
|
||||
request: request
|
||||
)
|
||||
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
score: score
|
||||
)
|
||||
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
// Sort by date (chronological order is the primary constraint)
|
||||
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
// MARK: - Game Scoring
|
||||
|
||||
private func scoreGame(
|
||||
game: Game,
|
||||
homeTeam: Team,
|
||||
awayTeam: Team,
|
||||
detourDistance: Double,
|
||||
isMustSee: Bool,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
var score: Double = 50.0 // Base score
|
||||
|
||||
// Must-see bonus
|
||||
if isMustSee {
|
||||
score += 100.0
|
||||
}
|
||||
|
||||
// Playoff bonus
|
||||
if game.isPlayoff {
|
||||
score += 30.0
|
||||
}
|
||||
|
||||
// Weekend bonus (more convenient)
|
||||
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
|
||||
if weekday == 1 || weekday == 7 { // Sunday or Saturday
|
||||
score += 10.0
|
||||
}
|
||||
|
||||
// Evening game bonus (day games harder to schedule around)
|
||||
let hour = Calendar.current.component(.hour, from: game.dateTime)
|
||||
if hour >= 17 { // 5 PM or later
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Detour penalty
|
||||
let detourMiles = detourDistance * 0.000621371
|
||||
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
|
||||
|
||||
// Preferred city bonus
|
||||
if request.preferences.preferredCities.contains(homeTeam.city) {
|
||||
score += 15.0
|
||||
}
|
||||
|
||||
// Must-stop location bonus
|
||||
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
|
||||
score += 25.0
|
||||
}
|
||||
|
||||
return max(0, score)
|
||||
}
|
||||
|
||||
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
|
||||
switch leisureLevel {
|
||||
case .packed: return 1.3 // 30% detour allowed
|
||||
case .moderate: return 1.5 // 50% detour allowed
|
||||
case .relaxed: return 2.0 // 100% detour allowed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Find Games at Location
|
||||
|
||||
func findGames(
|
||||
at stadium: Stadium,
|
||||
within dateRange: ClosedRange<Date>,
|
||||
from games: [Game]
|
||||
) -> [Game] {
|
||||
games.filter { game in
|
||||
game.stadiumId == stadium.id &&
|
||||
dateRange.contains(game.dateTime)
|
||||
}.sorted { $0.dateTime < $1.dateTime }
|
||||
}
|
||||
|
||||
// MARK: - Find Other Sports
|
||||
|
||||
func findOtherSportsGames(
|
||||
along route: [CLLocationCoordinate2D],
|
||||
excludingSports: Set<Sport>,
|
||||
within dateRange: ClosedRange<Date>,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
teams: [UUID: Team],
|
||||
maxDetourMiles: Double = 50
|
||||
) -> [GameCandidate] {
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
for game in games {
|
||||
// Skip if sport is already selected
|
||||
if excludingSports.contains(game.sport) { continue }
|
||||
|
||||
// Skip if outside date range
|
||||
if !dateRange.contains(game.dateTime) { continue }
|
||||
|
||||
guard let stadium = stadiums[game.stadiumId],
|
||||
let homeTeam = teams[game.homeTeamId],
|
||||
let awayTeam = teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if stadium is near the route
|
||||
let minDistance = route.map { coord in
|
||||
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||
.distance(from: stadium.location)
|
||||
}.min() ?? .greatestFiniteMagnitude
|
||||
|
||||
let distanceMiles = minDistance * 0.000621371
|
||||
|
||||
if distanceMiles <= maxDetourMiles {
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: minDistance,
|
||||
score: 50.0 - distanceMiles // Score inversely proportional to detour
|
||||
)
|
||||
candidates.append(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.sorted { $0.score > $1.score }
|
||||
}
|
||||
|
||||
// MARK: - Validate Games for Scenarios
|
||||
|
||||
/// Validates that all must-see games are within the date range.
|
||||
/// Used for Scenario B validation.
|
||||
func validateMustSeeGamesInRange(
|
||||
mustSeeGameIds: Set<UUID>,
|
||||
allGames: [Game],
|
||||
dateRange: ClosedRange<Date>
|
||||
) -> (valid: Bool, outOfRange: [UUID]) {
|
||||
var outOfRange: [UUID] = []
|
||||
|
||||
for gameId in mustSeeGameIds {
|
||||
guard let game = allGames.first(where: { $0.id == gameId }) else {
|
||||
continue
|
||||
}
|
||||
if !dateRange.contains(game.dateTime) {
|
||||
outOfRange.append(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
return (outOfRange.isEmpty, outOfRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Corridor Calculator
|
||||
|
||||
struct RouteCorridorCalculator {
|
||||
let start: CLLocationCoordinate2D
|
||||
let end: CLLocationCoordinate2D
|
||||
let maxDetourFactor: Double
|
||||
|
||||
var directDistance: CLLocationDistance {
|
||||
CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
}
|
||||
|
||||
var maxDetourDistance: CLLocationDistance {
|
||||
directDistance * (maxDetourFactor - 1.0)
|
||||
}
|
||||
|
||||
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||
|
||||
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
|
||||
let totalViaPoint = startToPoint + pointToEnd
|
||||
|
||||
return max(0, totalViaPoint - directDistance)
|
||||
}
|
||||
|
||||
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
|
||||
detourDistance(to: point) <= maxDetourDistance
|
||||
}
|
||||
|
||||
/// Returns the distance from a point to the end location.
|
||||
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||
CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
}
|
||||
|
||||
/// Calculates progress along the route (0 = at start, 1 = at end).
|
||||
/// Can be negative (behind start) or > 1 (past end).
|
||||
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
|
||||
guard directDistance > 0 else { return 0 }
|
||||
|
||||
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||
|
||||
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
|
||||
// Use the law of cosines to project onto the line
|
||||
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
|
||||
let dStart = distFromStart
|
||||
let dEnd = distFromEnd
|
||||
let dTotal = directDistance
|
||||
|
||||
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
|
||||
let denominator = 2 * dTotal * dTotal
|
||||
|
||||
return numerator / denominator
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user