// // ScenarioBPlanner.swift // SportsTime // // Scenario B: Selected games planning. // User selects specific games they MUST see. Those are fixed anchors that cannot be removed. // // Key Features: // - Selected games are "anchors" - they MUST appear in every valid route // - Sliding window logic when only trip duration (no specific dates) is provided // - Additional games from date range can be added if they fit geographically // // Sliding Window Algorithm: // When user provides selected games + day span (e.g., 10 days) without specific dates: // 1. Find first and last selected game dates // 2. Generate all possible windows of the given duration that contain ALL selected games // 3. Window 1: Last selected game is on last day // 4. Window N: First selected game is on first day // 5. Explore routes for each window, return best options // // Example: // Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days. // - Window 1: Jan 3-12 (Jan 12 is last day) // - Window 2: Jan 4-13 // - Window 3: Jan 5-14 (Jan 5 is first day) // For each window, find all games and explore routes with selected as anchors. // import Foundation import CoreLocation /// Scenario B: Selected games planning /// Input: selected_games, date_range (or trip_duration), optional must_stop /// Output: Itinerary options connecting all selected games with possible bonus games final class ScenarioBPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol func plan(request: PlanningRequest) -> ItineraryResult { let selectedGames = request.selectedGames // ────────────────────────────────────────────────────────────────── // Step 1: Validate selected games exist // ────────────────────────────────────────────────────────────────── if selectedGames.isEmpty { return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( type: .selectedGames, description: "No games selected", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 2: Generate date ranges (sliding window or single range) // ────────────────────────────────────────────────────────────────── let dateRanges = generateDateRanges( selectedGames: selectedGames, request: request ) if dateRanges.isEmpty { return .failure( PlanningFailure( reason: .missingDateRange, violations: [ ConstraintViolation( type: .dateRange, description: "Cannot determine valid date range for selected games", severity: .error ) ] ) ) } // ────────────────────────────────────────────────────────────────── // Step 3: For each date range, find routes with anchors // ────────────────────────────────────────────────────────────────── let anchorGameIds = Set(selectedGames.map { $0.id }) let selectedRegions = request.preferences.selectedRegions var allItineraryOptions: [ItineraryOption] = [] for dateRange in dateRanges { // Find all games in this date range and selected regions 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) // Note: Anchor games are always included regardless of region if !selectedRegions.isEmpty && !anchorGameIds.contains(game.id) { 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 } // Skip if no games (shouldn't happen if date range is valid) guard !gamesInRange.isEmpty else { continue } // Verify all selected games are in range let selectedInRange = selectedGames.allSatisfy { game in dateRange.contains(game.startTime) } guard selectedInRange else { continue } // Find all sensible routes that include the anchor games // Uses GameDAGRouter for polynomial-time beam search // Run BOTH global and per-region search for diverse routes var validRoutes: [[Game]] = [] // Global beam search (finds cross-region routes) let globalRoutes = GameDAGRouter.findAllSensibleRoutes( from: gamesInRange, stadiums: request.stadiums, anchorGameIds: anchorGameIds, allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops ) validRoutes.append(contentsOf: globalRoutes) // Per-region beam search (ensures good regional options) let regionalRoutes = findRoutesPerRegion( games: gamesInRange, stadiums: request.stadiums, anchorGameIds: anchorGameIds, allowRepeatCities: request.preferences.allowRepeatCities ) validRoutes.append(contentsOf: regionalRoutes) // Deduplicate validRoutes = deduplicateRoutes(validRoutes) // Build itineraries for each valid route for routeGames in validRoutes { let stops = buildStops(from: routeGames, stadiums: request.stadiums) guard !stops.isEmpty else { continue } // Use shared ItineraryBuilder with arrival time validator guard let itinerary = ItineraryBuilder.build( stops: stops, constraints: request.drivingConstraints, logPrefix: "[ScenarioB]", segmentValidator: ItineraryBuilder.arrivalBeforeGameStart() ) else { continue } let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count let bonusCount = routeGames.count - selectedCount let cities = stops.map { $0.city }.joined(separator: " → ") let option = ItineraryOption( rank: 0, // Will re-rank later stops: itinerary.stops, travelSegments: itinerary.travelSegments, totalDrivingHours: itinerary.totalDrivingHours, totalDistanceMiles: itinerary.totalDistanceMiles, geographicRationale: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)" ) allItineraryOptions.append(option) } } // ────────────────────────────────────────────────────────────────── // Step 4: Return ranked results // ────────────────────────────────────────────────────────────────── if allItineraryOptions.isEmpty { return .failure( PlanningFailure( reason: .constraintsUnsatisfiable, violations: [ ConstraintViolation( type: .geographicSanity, description: "Cannot create a geographically sensible route connecting selected games", severity: .error ) ] ) ) } // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel let rankedOptions = ItineraryOption.sortByLeisure( allItineraryOptions, leisureLevel: leisureLevel ) return .success(Array(rankedOptions)) } // MARK: - Date Range Generation (Sliding Window) /// Generates all valid date ranges for the selected games. /// /// Two modes: /// 1. If explicit date range provided: Use it directly (validate selected games fit) /// 2. If only trip duration provided: Generate sliding windows /// /// Sliding Window Logic: /// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days. /// - Window must contain all selected games /// - First window: ends on last selected game date (Jan 3-12) /// - Slide forward one day at a time /// - Last window: starts on first selected game date (Jan 5-14) /// private func generateDateRanges( selectedGames: [Game], request: PlanningRequest ) -> [DateInterval] { // If explicit date range exists, use it if let dateRange = request.dateRange { return [dateRange] } // Otherwise, use trip duration to create sliding windows let duration = request.preferences.effectiveTripDuration guard duration > 0 else { return [] } // Find the span of selected games let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime } guard let firstGame = sortedGames.first, let lastGame = sortedGames.last else { return [] } let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime) let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime) // Calculate how many days the selected games span let gameSpanDays = Calendar.current.dateComponents( [.day], from: firstGameDate, to: lastGameDate ).day ?? 0 // If selected games span more days than trip duration, can't fit if gameSpanDays >= duration { // Just return one window that exactly covers the games let start = firstGameDate let end = Calendar.current.date( byAdding: .day, value: gameSpanDays + 1, to: start ) ?? lastGameDate return [DateInterval(start: start, end: end)] } // Generate sliding windows var dateRanges: [DateInterval] = [] // First window: last selected game is on last day of window // Window end = lastGameDate + 1 day (to include the game) // Window start = end - duration days let firstWindowEnd = Calendar.current.date( byAdding: .day, value: 1, to: lastGameDate )! let firstWindowStart = Calendar.current.date( byAdding: .day, value: -duration, to: firstWindowEnd )! // Last window: first selected game is on first day of window // Window start = firstGameDate let lastWindowStart = firstGameDate // Slide from first window to last window var currentStart = firstWindowStart while currentStart <= lastWindowStart { let windowEnd = Calendar.current.date( byAdding: .day, value: duration, to: currentStart )! let window = DateInterval(start: currentStart, end: windowEnd) dateRanges.append(window) // Slide forward one day currentStart = Calendar.current.date( byAdding: .day, value: 1, to: currentStart )! } return dateRanges } // MARK: - Stop Building /// Converts a list of games into itinerary stops. /// Groups consecutive games at the same stadium into one stop. /// Creates separate stops when visiting the same city with other cities in between. private func buildStops( from games: [Game], stadiums: [UUID: 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 var stops: [ItineraryStop] = [] var currentStadiumId: UUID? = 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: UUID, stadiums: [UUID: 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 day AFTER last game (we leave the next morning) let lastGameDate = sortedGames.last?.gameDate ?? Date() let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate return ItineraryStop( city: city, state: state, coordinate: coordinate, games: sortedGames.map { $0.id }, arrivalDate: sortedGames.first?.gameDate ?? Date(), departureDate: departureDateValue, location: location, firstGameStart: sortedGames.first?.startTime ) } // 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. /// For Scenario B, routes must still contain all anchor games. private func findRoutesPerRegion( games: [Game], stadiums: [UUID: Stadium], anchorGameIds: Set, allowRepeatCities: Bool ) -> [[Game]] { // First, determine which region(s) the anchor games are in var anchorRegions = Set() for game in games where anchorGameIds.contains(game.id) { guard let stadium = stadiums[game.stadiumId] else { continue } let coord = stadium.coordinate let region = Region.classify(longitude: coord.longitude) if region != .crossCountry { anchorRegions.insert(region) } } // Partition all 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) if region != .crossCountry { gamesByRegion[region, default: []].append(game) } } print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })") // Run beam search for each region that has anchor games // (Other regions without anchor games would produce routes that don't satisfy anchors) var allRoutes: [[Game]] = [] for region in anchorRegions { guard let regionGames = gamesByRegion[region], !regionGames.isEmpty else { continue } // Get anchor games in this region let regionAnchorIds = anchorGameIds.filter { anchorId in regionGames.contains { $0.id == anchorId } } let regionRoutes = GameDAGRouter.findAllSensibleRoutes( from: regionGames, stadiums: stadiums, anchorGameIds: regionAnchorIds, allowRepeatCities: allowRepeatCities, stopBuilder: buildStops ) print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes") allRoutes.append(contentsOf: regionRoutes) } return allRoutes } // MARK: - Route Deduplication /// Removes duplicate routes (routes with identical game IDs). private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] { var seen = Set() var unique: [[Game]] = [] for route in routes { let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-") if !seen.contains(key) { seen.insert(key) unique.append(route) } } return unique } }