feat: improve planning engine travel handling, itinerary reordering, and scenario planners
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>
This commit is contained in:
@@ -15,7 +15,7 @@ import CoreLocation
|
||||
///
|
||||
/// Input:
|
||||
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
||||
/// - must_stop: Optional. A location they must visit (filters to home games in that city)
|
||||
/// - must_stop: Optional. One or more locations the route must include
|
||||
///
|
||||
/// Output:
|
||||
/// - Success: Ranked list of itinerary options
|
||||
@@ -30,8 +30,8 @@ import CoreLocation
|
||||
/// - 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 mustStopLocation → filters to home games in that city
|
||||
/// - Empty games after must-stop filter → .failure with .noGamesInRange
|
||||
/// - 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
|
||||
@@ -109,33 +109,36 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2b: Filter by must-stop locations (if any)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// If user specified a must-stop city, filter to HOME games in that city.
|
||||
// A "home game" means the stadium is in the must-stop city.
|
||||
var filteredGames = gamesInRange
|
||||
if let mustStop = request.mustStopLocation {
|
||||
let mustStopCity = mustStop.name.lowercased()
|
||||
filteredGames = gamesInRange.filter { game in
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let stadiumCity = stadium.city.lowercased()
|
||||
// Match if either contains the other (handles "Chicago" vs "Chicago, IL")
|
||||
return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity)
|
||||
// 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 filteredGames.isEmpty {
|
||||
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: [
|
||||
ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "No home games found in \(mustStop.name) during selected dates",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
violations: violations
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
let filteredGames = gamesInRange
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Find ALL geographically sensible route variations
|
||||
@@ -177,6 +180,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
// 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")
|
||||
@@ -185,13 +200,16 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
}
|
||||
|
||||
if validRoutes.isEmpty {
|
||||
let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "No geographically sensible route found for games in this date range",
|
||||
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
|
||||
)
|
||||
]
|
||||
@@ -406,6 +424,39 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user