Add TravelInfo initializers and city normalization helpers to fix repeat city-pair disambiguation. Improve drag-and-drop reordering with segment index tracking and source-row-aware zone calculation. Enhance all five scenario planners with better next-day departure handling and travel segment placement. Add comprehensive tests across all planners. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
508 lines
21 KiB
Swift
508 lines
21 KiB
Swift
//
|
|
// ScenarioAPlanner.swift
|
|
// SportsTime
|
|
//
|
|
// Scenario A: Date range only planning.
|
|
// User provides a date range, we find all games and build chronological routes.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Scenario A: Date range planning
|
|
///
|
|
/// This is the simplest scenario - user just picks a date range and we find games.
|
|
///
|
|
/// Input:
|
|
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
|
/// - must_stop: Optional. One or more locations the route must include
|
|
///
|
|
/// Output:
|
|
/// - Success: Ranked list of itinerary options
|
|
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
|
|
///
|
|
/// Example:
|
|
/// User selects Jan 5-10, 2026
|
|
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
|
|
/// Output: Single itinerary visiting LA → SF → Sacramento in order
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - No date range → returns .failure with .missingDateRange
|
|
/// - No games in date range → returns .failure with .noGamesInRange
|
|
/// - With selectedRegions → only includes games in those regions
|
|
/// - With mustStopLocations → route must include at least one game in each must-stop city
|
|
/// - Missing games for any must-stop city → .failure with .noGamesInRange
|
|
/// - No valid routes from GameDAGRouter → .failure with .noValidRoutes
|
|
/// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable
|
|
/// - Success → returns sorted itineraries based on leisureLevel
|
|
///
|
|
/// - Invariants:
|
|
/// - Returned games are always within the date range
|
|
/// - Returned games are always chronologically ordered within each stop
|
|
/// - buildStops groups consecutive games at the same stadium
|
|
/// - Visiting A→B→A creates 3 separate stops (not 2)
|
|
///
|
|
final class ScenarioAPlanner: ScenarioPlanner {
|
|
|
|
// MARK: - ScenarioPlanner Protocol
|
|
|
|
/// Main entry point for Scenario A planning.
|
|
///
|
|
/// Flow:
|
|
/// 1. Validate inputs (date range must exist)
|
|
/// 2. Find all games within the date range
|
|
/// 3. Convert games to stops (grouping by stadium)
|
|
/// 4. Calculate travel between stops
|
|
/// 5. Return the complete itinerary
|
|
///
|
|
/// Failure cases:
|
|
/// - No date range provided → .missingDateRange
|
|
/// - No games in date range → .noGamesInRange
|
|
/// - Can't build valid route → .constraintsUnsatisfiable
|
|
///
|
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 1: Validate date range exists
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Scenario A requires a date range. Without it, we can't filter games.
|
|
guard let dateRange = request.dateRange else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingDateRange,
|
|
violations: []
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 2: Filter games within date range and selected regions
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Get all games that fall within the user's travel dates.
|
|
// Sort by start time so we visit them in chronological order.
|
|
let selectedRegions = request.preferences.selectedRegions
|
|
let gamesInRange = request.allGames
|
|
.filter { game in
|
|
// Must be in date range
|
|
guard dateRange.contains(game.startTime) else { 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)
|
|
return selectedRegions.contains(gameRegion)
|
|
}
|
|
return true
|
|
}
|
|
.sorted { $0.startTime < $1.startTime }
|
|
|
|
// No games? Nothing to plan.
|
|
if gamesInRange.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: []
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 2b: Filter by must-stop locations (if any)
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Must-stops are route constraints, not exclusive filters.
|
|
// Keep all games in range, then require routes to include each must-stop city.
|
|
let requiredMustStops = request.preferences.mustStopLocations.filter { stop in
|
|
!normalizeCityName(stop.name).isEmpty
|
|
}
|
|
|
|
if !requiredMustStops.isEmpty {
|
|
let missingMustStops = requiredMustStops.filter { mustStop in
|
|
!gamesInRange.contains { game in
|
|
gameMatchesCity(game, cityName: mustStop.name, stadiums: request.stadiums)
|
|
}
|
|
}
|
|
|
|
if !missingMustStops.isEmpty {
|
|
let violations = missingMustStops.map { missing in
|
|
ConstraintViolation(
|
|
type: .mustStop,
|
|
description: "No home games found in \(missing.name) during selected dates",
|
|
severity: .error
|
|
)
|
|
}
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: violations
|
|
)
|
|
)
|
|
}
|
|
}
|
|
let filteredGames = gamesInRange
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 3: Find ALL geographically sensible route variations
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Not all games in the date range may form a sensible route.
|
|
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
|
|
// - Including all = zig-zag nightmare
|
|
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
|
|
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
|
|
// - etc.
|
|
//
|
|
// We explore ALL valid combinations and return multiple options.
|
|
// Uses GameDAGRouter for polynomial-time beam search.
|
|
//
|
|
// Run beam search BOTH globally AND per-region to get diverse routes:
|
|
// - Global search finds cross-region routes
|
|
// - Per-region search ensures we have good regional options too
|
|
// Travel style filtering happens at UI layer.
|
|
//
|
|
var validRoutes: [[Game]] = []
|
|
|
|
// Global beam search (finds cross-region routes)
|
|
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: filteredGames,
|
|
stadiums: request.stadiums,
|
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
|
stopBuilder: buildStops
|
|
)
|
|
validRoutes.append(contentsOf: globalRoutes)
|
|
|
|
// Per-region beam search (ensures good regional options)
|
|
let regionalRoutes = findRoutesPerRegion(
|
|
games: filteredGames,
|
|
stadiums: request.stadiums,
|
|
allowRepeatCities: request.preferences.allowRepeatCities
|
|
)
|
|
validRoutes.append(contentsOf: regionalRoutes)
|
|
|
|
// Deduplicate routes (same game IDs)
|
|
validRoutes = deduplicateRoutes(validRoutes)
|
|
|
|
// Enforce must-stop coverage after route generation so non-must-stop games can
|
|
// still be included as connective "bonus" cities.
|
|
if !requiredMustStops.isEmpty {
|
|
validRoutes = validRoutes.filter { route in
|
|
routeSatisfiesMustStops(
|
|
route,
|
|
mustStops: requiredMustStops,
|
|
stadiums: request.stadiums
|
|
)
|
|
}
|
|
}
|
|
|
|
print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)")
|
|
if let firstRoute = validRoutes.first {
|
|
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
|
|
let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city }
|
|
print("🔍 ScenarioA: Route cities: \(cities)")
|
|
}
|
|
|
|
if validRoutes.isEmpty {
|
|
let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noValidRoutes,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: noMustStopSatisfyingRoutes ? .mustStop : .geographicSanity,
|
|
description: noMustStopSatisfyingRoutes
|
|
? "No valid route can include all required must-stop cities"
|
|
: "No geographically sensible route found for games in this date range",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 4: Build itineraries for each valid route
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// For each valid game combination, build stops and calculate travel.
|
|
// Some routes may fail driving constraints - filter those out.
|
|
//
|
|
var itineraryOptions: [ItineraryOption] = []
|
|
|
|
var routesAttempted = 0
|
|
var routesFailed = 0
|
|
|
|
for (index, routeGames) in validRoutes.enumerated() {
|
|
routesAttempted += 1
|
|
// Build stops for this route
|
|
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
|
|
|
// Debug: show stops created from games
|
|
let stopCities = stops.map { "\($0.city)(\($0.games.count)g)" }
|
|
print("🔍 ScenarioA: Route \(index) - \(routeGames.count) games → \(stops.count) stops: \(stopCities)")
|
|
|
|
guard !stops.isEmpty else {
|
|
routesFailed += 1
|
|
print("⚠️ ScenarioA: Route \(index) - buildStops returned empty")
|
|
continue
|
|
}
|
|
|
|
// Calculate travel segments using shared ItineraryBuilder
|
|
guard let itinerary = ItineraryBuilder.build(
|
|
stops: stops,
|
|
constraints: request.drivingConstraints
|
|
) else {
|
|
// This route fails driving constraints, skip it
|
|
routesFailed += 1
|
|
print("⚠️ ScenarioA: Route \(index) - ItineraryBuilder.build failed")
|
|
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: "\(stops.count) games: \(cities)"
|
|
)
|
|
itineraryOptions.append(option)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 5: Return ranked results
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// If no routes passed all constraints, fail.
|
|
// Otherwise, return all valid options for the user to choose from.
|
|
//
|
|
if itineraryOptions.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .constraintsUnsatisfiable,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .drivingTime,
|
|
description: "No routes satisfy driving constraints",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// Sort and rank based on leisure level
|
|
let leisureLevel = request.preferences.leisureLevel
|
|
|
|
// Debug: show all options before sorting
|
|
print("🔍 ScenarioA: \(itineraryOptions.count) itinerary options before sorting:")
|
|
for (i, opt) in itineraryOptions.enumerated() {
|
|
print(" Option \(i): \(opt.stops.count) stops, \(opt.totalGames) games, \(String(format: "%.1f", opt.totalDrivingHours))h driving")
|
|
}
|
|
|
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
|
itineraryOptions,
|
|
leisureLevel: leisureLevel
|
|
)
|
|
|
|
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting")
|
|
if let first = rankedOptions.first {
|
|
print("🔍 ScenarioA: First option has \(first.stops.count) stops")
|
|
}
|
|
|
|
return .success(rankedOptions)
|
|
}
|
|
|
|
// MARK: - Stop Building
|
|
|
|
/// Converts a list of games into itinerary stops.
|
|
///
|
|
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
|
|
/// when walking through games chronologically.
|
|
///
|
|
/// Example:
|
|
/// Input games (already sorted by date):
|
|
/// 1. Jan 5 - Lakers @ Staples Center (LA)
|
|
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
|
|
/// 3. Jan 8 - Warriors @ Chase Center (SF)
|
|
///
|
|
/// Output stops:
|
|
/// Stop 1: Los Angeles (contains game 1 and 2)
|
|
/// Stop 2: San Francisco (contains game 3)
|
|
///
|
|
/// Note: If you visit the same city, leave, and come back, that creates
|
|
/// separate stops (one for each visit).
|
|
///
|
|
private func buildStops(
|
|
from games: [Game],
|
|
stadiums: [String: Stadium]
|
|
) -> [ItineraryStop] {
|
|
guard !games.isEmpty else { return [] }
|
|
|
|
// Sort games chronologically
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
|
|
// Group consecutive games at the same stadium into stops
|
|
// If you visit A, then B, then A again, that's 3 stops (A, B, A)
|
|
var stops: [ItineraryStop] = []
|
|
var currentStadiumId: String? = nil
|
|
var currentGames: [Game] = []
|
|
|
|
for game in sortedGames {
|
|
if game.stadiumId == currentStadiumId {
|
|
// Same stadium as previous game - add to current group
|
|
currentGames.append(game)
|
|
} else {
|
|
// Different stadium - finalize previous stop (if any) and start new one
|
|
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
|
|
)
|
|
|
|
// departureDate is same day as last game
|
|
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
|
|
|
return ItineraryStop(
|
|
city: city,
|
|
state: state,
|
|
coordinate: coordinate,
|
|
games: sortedGames.map { $0.id },
|
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
|
departureDate: lastGameDate,
|
|
location: location,
|
|
firstGameStart: sortedGames.first?.startTime
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
private func routeSatisfiesMustStops(
|
|
_ route: [Game],
|
|
mustStops: [LocationInput],
|
|
stadiums: [String: Stadium]
|
|
) -> Bool {
|
|
mustStops.allSatisfy { mustStop in
|
|
route.contains { game in
|
|
gameMatchesCity(game, cityName: mustStop.name, stadiums: stadiums)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func gameMatchesCity(
|
|
_ game: Game,
|
|
cityName: String,
|
|
stadiums: [String: Stadium]
|
|
) -> Bool {
|
|
guard let stadium = stadiums[game.stadiumId] else { return false }
|
|
let targetCity = normalizeCityName(cityName)
|
|
let stadiumCity = normalizeCityName(stadium.city)
|
|
guard !targetCity.isEmpty, !stadiumCity.isEmpty else { return false }
|
|
return stadiumCity == targetCity || stadiumCity.contains(targetCity) || targetCity.contains(stadiumCity)
|
|
}
|
|
|
|
private func normalizeCityName(_ value: String) -> String {
|
|
let cityPart = value.split(separator: ",", maxSplits: 1).first.map(String.init) ?? value
|
|
return cityPart
|
|
.lowercased()
|
|
.replacingOccurrences(of: ".", with: "")
|
|
.split(whereSeparator: \.isWhitespace)
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
// MARK: - Regional Route Finding
|
|
|
|
/// Finds routes by running beam search separately for each geographic region.
|
|
/// This ensures we get diverse options from East, Central, and West coasts.
|
|
private func findRoutesPerRegion(
|
|
games: [Game],
|
|
stadiums: [String: Stadium],
|
|
allowRepeatCities: Bool
|
|
) -> [[Game]] {
|
|
// Partition games by region
|
|
var gamesByRegion: [Region: [Game]] = [:]
|
|
|
|
for game in games {
|
|
guard let stadium = stadiums[game.stadiumId] else { continue }
|
|
let coord = stadium.coordinate
|
|
let region = Region.classify(longitude: coord.longitude)
|
|
// Only consider actual regions, not cross-country
|
|
if region != .crossCountry {
|
|
gamesByRegion[region, default: []].append(game)
|
|
}
|
|
}
|
|
|
|
print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions")
|
|
for (region, regionGames) in gamesByRegion {
|
|
print(" \(region.shortName): \(regionGames.count) games")
|
|
}
|
|
|
|
// Run beam search for each region
|
|
var allRoutes: [[Game]] = []
|
|
|
|
for (region, regionGames) in gamesByRegion {
|
|
guard !regionGames.isEmpty else { continue }
|
|
|
|
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: regionGames,
|
|
stadiums: stadiums,
|
|
allowRepeatCities: allowRepeatCities,
|
|
stopBuilder: buildStops
|
|
)
|
|
|
|
print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes")
|
|
allRoutes.append(contentsOf: regionRoutes)
|
|
}
|
|
|
|
return allRoutes
|
|
}
|
|
|
|
}
|