Production changes: - TravelEstimator: remove 300mi fallback, return nil on missing coords - TripPlanningEngine: add warnings array, empty sports warning, inverted date range rejection, must-stop filter, segment validation gate - GameDAGRouter: add routePreference parameter with preference-aware bucket ordering and sorting in selectDiverseRoutes() - ScenarioA-E: pass routePreference through to GameDAGRouter - ScenarioA: track games with missing stadium data - ScenarioE: add region filtering for home games - TravelSegment: add requiresOvernightStop and travelDays() helpers Test changes: - GameDAGRouterTests: +252 lines for route preference verification - TripPlanningEngineTests: +153 lines for segment validation, date range, empty sports - ScenarioEPlannerTests: +119 lines for region filter tests - TravelEstimatorTests: remove obsolete fallback distance tests - ItineraryBuilderTests: update nil-coords test expectation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
502 lines
21 KiB
Swift
502 lines
21 KiB
Swift
//
|
|
// ScenarioDPlanner.swift
|
|
// SportsTime
|
|
//
|
|
// Scenario D: Follow Team planning.
|
|
// User selects a team, we find all their games (home and away) and build routes.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Scenario D: Follow Team planning
|
|
///
|
|
/// This scenario builds trips around a specific team's schedule.
|
|
///
|
|
/// Input:
|
|
/// - followTeamId: Required. The team to follow.
|
|
/// - date_range: Required. The trip dates.
|
|
/// - selectedRegions: Optional. Filter to specific regions.
|
|
/// - useHomeLocation: Whether to start/end from user's home.
|
|
/// - startLocation: Used as start/end home stop when provided.
|
|
///
|
|
/// Output:
|
|
/// - Success: Ranked list of itinerary options
|
|
/// - Failure: Explicit error with reason (no team, no games, etc.)
|
|
///
|
|
/// Example:
|
|
/// User follows Yankees, Jan 5-15, 2026
|
|
/// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12)
|
|
/// Output: Route visiting Boston → Toronto → New York
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - No followTeamId → returns .failure with .missingTeamSelection
|
|
/// - No date range → returns .failure with .missingDateRange
|
|
/// - No team games found → returns .failure with .noGamesInRange
|
|
/// - No games in date range/region → returns .failure with .noGamesInRange
|
|
/// - filterToTeam returns BOTH home and away games for the team
|
|
/// - With selectedRegions → only includes games in those regions
|
|
/// - No valid routes → .failure with .noValidRoutes
|
|
/// - All routes fail constraints → .failure with .constraintsUnsatisfiable
|
|
/// - Success → returns sorted itineraries based on leisureLevel
|
|
///
|
|
/// - Invariants:
|
|
/// - All returned games have homeTeamId == teamId OR awayTeamId == teamId
|
|
/// - Games are chronologically ordered within each stop
|
|
/// - Duplicate routes are removed
|
|
///
|
|
final class ScenarioDPlanner: ScenarioPlanner {
|
|
|
|
// MARK: - ScenarioPlanner Protocol
|
|
|
|
/// Main entry point for Scenario D planning.
|
|
///
|
|
/// Flow:
|
|
/// 1. Validate inputs (team must be selected)
|
|
/// 2. Filter games to team's schedule (home and away)
|
|
/// 3. Apply region and date filters
|
|
/// 4. Apply repeat city constraints
|
|
/// 5. Build routes and calculate travel
|
|
/// 6. Return ranked itineraries
|
|
///
|
|
/// Failure cases:
|
|
/// - No team selected → .missingTeamSelection
|
|
/// - No date range → .missingDateRange
|
|
/// - No games found → .noGamesInRange
|
|
/// - Can't build valid route → .constraintsUnsatisfiable
|
|
///
|
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 1: Validate team selection
|
|
// ──────────────────────────────────────────────────────────────────
|
|
guard let teamId = request.preferences.followTeamId
|
|
?? request.preferences.selectedTeamIds.first else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingTeamSelection,
|
|
violations: []
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 2: Validate date range exists
|
|
// ──────────────────────────────────────────────────────────────────
|
|
guard let dateRange = request.dateRange else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingDateRange,
|
|
violations: []
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 3: Filter games to team's schedule (home and away)
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let teamGames = filterToTeam(request.allGames, teamId: teamId)
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)")
|
|
print("🔍 ScenarioD: Looking for teamId=\(teamId)")
|
|
for game in teamGames.prefix(20) {
|
|
let stadium = request.stadiums[game.stadiumId]
|
|
let isHome = game.homeTeamId == teamId
|
|
print("🔍 Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))")
|
|
}
|
|
#endif
|
|
|
|
if teamGames.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .selectedGames,
|
|
description: "No games found for selected team",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 4: Apply date range and region filters
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let selectedRegions = request.preferences.selectedRegions
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)")
|
|
#endif
|
|
|
|
let filteredGames = teamGames
|
|
.filter { game in
|
|
// Must be in date range
|
|
let inDateRange = dateRange.contains(game.startTime)
|
|
if !inDateRange {
|
|
#if DEBUG
|
|
let stadium = request.stadiums[game.stadiumId]
|
|
print("🔍 FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)")
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
// Must be in selected region (if regions specified)
|
|
if !selectedRegions.isEmpty {
|
|
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
|
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
|
let inRegion = selectedRegions.contains(gameRegion)
|
|
#if DEBUG
|
|
if !inRegion {
|
|
print("🔍 FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)")
|
|
}
|
|
#endif
|
|
return inRegion
|
|
}
|
|
return true
|
|
}
|
|
.sorted { $0.startTime < $1.startTime }
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter")
|
|
for game in filteredGames {
|
|
let stadium = request.stadiums[game.stadiumId]
|
|
print("🔍 Kept: \(stadium?.city ?? "?") on \(game.gameDate)")
|
|
}
|
|
#endif
|
|
|
|
if filteredGames.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .dateRange,
|
|
description: "No team games found in selected date range and regions",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 5: Prepare for routing
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let homeLocation: LocationInput? = {
|
|
guard request.preferences.useHomeLocation else { return nil }
|
|
return request.startLocation
|
|
}()
|
|
|
|
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
|
|
// allowRepeatCities internally, which allows it to pick the optimal game
|
|
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
|
|
// July 27 if it makes the driving from Chicago feasible).
|
|
let finalGames = filteredGames
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 5: Passing \(finalGames.count) games to router (allowRepeatCities=\(request.preferences.allowRepeatCities))")
|
|
print("🔍 ScenarioD: teamGames=\(teamGames.count), filteredGames=\(filteredGames.count), finalGames=\(finalGames.count)")
|
|
#endif
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 6: Find valid routes using DAG router
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Follow Team mode typically has fewer games than Scenario A,
|
|
// so we can be more exhaustive in route finding.
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 6: Finding routes from \(finalGames.count) games")
|
|
#endif
|
|
|
|
var validRoutes: [[Game]] = []
|
|
|
|
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: finalGames,
|
|
stadiums: request.stadiums,
|
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
|
routePreference: request.preferences.routePreference,
|
|
stopBuilder: buildStops
|
|
)
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes")
|
|
for (i, route) in globalRoutes.prefix(5).enumerated() {
|
|
let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: " → ")
|
|
print("🔍 Route \(i+1): \(route.count) games - \(cities)")
|
|
}
|
|
#endif
|
|
|
|
validRoutes.append(contentsOf: globalRoutes)
|
|
|
|
// Deduplicate routes
|
|
validRoutes = deduplicateRoutes(validRoutes)
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes")
|
|
#endif
|
|
|
|
if validRoutes.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noValidRoutes,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .geographicSanity,
|
|
description: "No geographically sensible route found for team's games",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 7: Build itineraries for each valid route
|
|
// ──────────────────────────────────────────────────────────────────
|
|
var itineraryOptions: [ItineraryOption] = []
|
|
|
|
for (index, routeGames) in validRoutes.enumerated() {
|
|
// Build stops for this route
|
|
var stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
|
guard !stops.isEmpty else { continue }
|
|
|
|
if let homeLocation {
|
|
stops = buildStopsWithHomeEndpoints(home: homeLocation, gameStops: stops)
|
|
}
|
|
|
|
// Calculate travel segments using shared ItineraryBuilder
|
|
guard let itinerary = ItineraryBuilder.build(
|
|
stops: stops,
|
|
constraints: request.drivingConstraints
|
|
) else {
|
|
// This route fails driving constraints, skip it
|
|
continue
|
|
}
|
|
|
|
// Create the option
|
|
let cities = stops.map { $0.city }.joined(separator: " → ")
|
|
let option = ItineraryOption(
|
|
rank: index + 1,
|
|
stops: itinerary.stops,
|
|
travelSegments: itinerary.travelSegments,
|
|
totalDrivingHours: itinerary.totalDrivingHours,
|
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
|
geographicRationale: "Follow Team: \(itinerary.stops.reduce(0) { $0 + $1.games.count }) games - \(cities)"
|
|
)
|
|
itineraryOptions.append(option)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 8: Return ranked results
|
|
// ──────────────────────────────────────────────────────────────────
|
|
if itineraryOptions.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .constraintsUnsatisfiable,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .drivingTime,
|
|
description: "No routes satisfy driving constraints for team's schedule",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// Sort and rank based on leisure level
|
|
let leisureLevel = request.preferences.leisureLevel
|
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
|
itineraryOptions,
|
|
leisureLevel: leisureLevel
|
|
)
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioD: Returning \(rankedOptions.count) options")
|
|
#endif
|
|
|
|
return .success(rankedOptions)
|
|
}
|
|
|
|
// MARK: - Team Filtering
|
|
|
|
/// Filters games to those involving the followed team (home or away).
|
|
private func filterToTeam(_ games: [Game], teamId: String) -> [Game] {
|
|
games.filter { game in
|
|
game.homeTeamId == teamId || game.awayTeamId == teamId
|
|
}
|
|
}
|
|
|
|
// MARK: - Repeat City Filtering
|
|
|
|
/// When `allowRepeatCities = false`, keeps only the first game per city.
|
|
private func applyRepeatCityFilter(
|
|
_ games: [Game],
|
|
allowRepeat: Bool,
|
|
stadiums: [String: Stadium]
|
|
) -> [Game] {
|
|
guard !allowRepeat else {
|
|
#if DEBUG
|
|
print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games")
|
|
#endif
|
|
return games
|
|
}
|
|
|
|
#if DEBUG
|
|
print("🔍 applyRepeatCityFilter: allowRepeat=false, filtering duplicates")
|
|
#endif
|
|
var seenCities: Set<String> = []
|
|
return games.filter { game in
|
|
guard let stadium = stadiums[game.stadiumId] else {
|
|
#if DEBUG
|
|
print("🔍 Game \(game.id): NO STADIUM FOUND - filtered out")
|
|
#endif
|
|
return false
|
|
}
|
|
if seenCities.contains(stadium.city) {
|
|
#if DEBUG
|
|
print("🔍 Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out")
|
|
#endif
|
|
return false
|
|
}
|
|
#if DEBUG
|
|
print("🔍 Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)")
|
|
#endif
|
|
seenCities.insert(stadium.city)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Stop Building
|
|
|
|
/// Converts a list of games into itinerary stops.
|
|
/// Same logic as ScenarioAPlanner.
|
|
private func buildStops(
|
|
from games: [Game],
|
|
stadiums: [String: Stadium]
|
|
) -> [ItineraryStop] {
|
|
guard !games.isEmpty else { return [] }
|
|
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
|
|
var stops: [ItineraryStop] = []
|
|
var currentStadiumId: String? = nil
|
|
var currentGames: [Game] = []
|
|
|
|
for game in sortedGames {
|
|
if game.stadiumId == currentStadiumId {
|
|
currentGames.append(game)
|
|
} else {
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
currentStadiumId = game.stadiumId
|
|
currentGames = [game]
|
|
}
|
|
}
|
|
|
|
// Don't forget the last group
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
|
|
return stops
|
|
}
|
|
|
|
/// Creates an ItineraryStop from a group of games at the same stadium.
|
|
private func createStop(
|
|
from games: [Game],
|
|
stadiumId: String,
|
|
stadiums: [String: Stadium]
|
|
) -> ItineraryStop? {
|
|
guard !games.isEmpty else { return nil }
|
|
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
let stadium = stadiums[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 lastGameDate = sortedGames.last?.gameDate ?? Date()
|
|
let calendar = Calendar.current
|
|
|
|
return ItineraryStop(
|
|
city: city,
|
|
state: state,
|
|
coordinate: coordinate,
|
|
games: sortedGames.map { $0.id },
|
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
|
departureDate: calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate.addingTimeInterval(86400),
|
|
location: location,
|
|
firstGameStart: sortedGames.first?.startTime
|
|
)
|
|
}
|
|
|
|
/// Wraps game stops with optional home start/end waypoints.
|
|
private func buildStopsWithHomeEndpoints(
|
|
home: LocationInput,
|
|
gameStops: [ItineraryStop]
|
|
) -> [ItineraryStop] {
|
|
guard !gameStops.isEmpty else { return [] }
|
|
|
|
let calendar = Calendar.current
|
|
let firstGameDay = gameStops.first?.arrivalDate ?? Date()
|
|
let startDay = calendar.date(byAdding: .day, value: -1, to: firstGameDay) ?? firstGameDay
|
|
|
|
let startStop = ItineraryStop(
|
|
city: home.name,
|
|
state: "",
|
|
coordinate: home.coordinate,
|
|
games: [],
|
|
arrivalDate: startDay,
|
|
departureDate: startDay,
|
|
location: home,
|
|
firstGameStart: nil
|
|
)
|
|
|
|
let lastGameDay = gameStops.last?.departureDate ?? firstGameDay
|
|
let endDay = calendar.date(byAdding: .day, value: 1, to: lastGameDay) ?? lastGameDay
|
|
|
|
let endStop = ItineraryStop(
|
|
city: home.name,
|
|
state: "",
|
|
coordinate: home.coordinate,
|
|
games: [],
|
|
arrivalDate: endDay,
|
|
departureDate: endDay,
|
|
location: home,
|
|
firstGameStart: nil
|
|
)
|
|
|
|
return [startStop] + gameStops + [endStop]
|
|
}
|
|
|
|
// MARK: - Route Deduplication
|
|
|
|
/// Removes duplicate routes (routes with identical game IDs).
|
|
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
|
var seen = Set<String>()
|
|
var unique: [[Game]] = []
|
|
|
|
for route in routes {
|
|
let key = route.map { $0.id }.sorted().joined(separator: "-")
|
|
if !seen.contains(key) {
|
|
seen.insert(key)
|
|
unique.append(route)
|
|
}
|
|
}
|
|
|
|
return unique
|
|
}
|
|
}
|