fix: ScenarioCPlanner endpoint merging and game validation
Eliminate redundant 0-mile travel segments when start/end city matches the first/last game stop city, and fail early when no games exist at endpoint cities within the selected date range. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,38 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2.5: Validate games exist at endpoint stadiums (explicit date range)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if let dateRange = request.dateRange {
|
||||
let startStadiumIds = Set(startStadiums.map { $0.id })
|
||||
let endStadiumIds = Set(endStadiums.map { $0.id })
|
||||
|
||||
let hasStartGames = request.allGames.contains {
|
||||
startStadiumIds.contains($0.stadiumId) && dateRange.contains($0.startTime)
|
||||
}
|
||||
let hasEndGames = request.allGames.contains {
|
||||
endStadiumIds.contains($0.stadiumId) && dateRange.contains($0.startTime)
|
||||
}
|
||||
|
||||
if !hasStartGames {
|
||||
return .failure(PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [ConstraintViolation(type: .general,
|
||||
description: "No games found at \(startLocation.name) within the selected dates",
|
||||
severity: .error)]
|
||||
))
|
||||
}
|
||||
if !hasEndGames {
|
||||
return .failure(PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [ConstraintViolation(type: .general,
|
||||
description: "No games found at \(endLocation.name) within the selected dates",
|
||||
severity: .error)]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Generate date ranges
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
@@ -544,6 +576,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
}
|
||||
|
||||
/// Builds stops with start and end location endpoints.
|
||||
/// Skips adding a separate endpoint stop when the first/last game stop
|
||||
/// is already in the same city, avoiding redundant 0-mile travel segments.
|
||||
private func buildStopsWithEndpoints(
|
||||
start: LocationInput,
|
||||
end: LocationInput,
|
||||
@@ -551,43 +585,58 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
stadiums: [String: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
// Build game stops first so we can check for city overlap
|
||||
let gameStops = buildStops(from: games, stadiums: stadiums)
|
||||
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
// Start stop (no games)
|
||||
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
|
||||
let startStop = ItineraryStop(
|
||||
city: start.name,
|
||||
state: "",
|
||||
coordinate: start.coordinate,
|
||||
games: [],
|
||||
arrivalDate: startArrival,
|
||||
departureDate: startArrival,
|
||||
location: start,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(startStop)
|
||||
// Only add start endpoint if start city differs from first game stop city
|
||||
let firstGameCity = gameStops.first?.city
|
||||
if firstGameCity == nil || !citiesMatch(start.name, firstGameCity!) {
|
||||
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
|
||||
let startStop = ItineraryStop(
|
||||
city: start.name,
|
||||
state: "",
|
||||
coordinate: start.coordinate,
|
||||
games: [],
|
||||
arrivalDate: startArrival,
|
||||
departureDate: startArrival,
|
||||
location: start,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(startStop)
|
||||
}
|
||||
|
||||
// Game stops
|
||||
let gameStops = buildStops(from: games, stadiums: stadiums)
|
||||
stops.append(contentsOf: gameStops)
|
||||
|
||||
// End stop (no games)
|
||||
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
|
||||
let endStop = ItineraryStop(
|
||||
city: end.name,
|
||||
state: "",
|
||||
coordinate: end.coordinate,
|
||||
games: [],
|
||||
arrivalDate: endArrival,
|
||||
departureDate: endArrival,
|
||||
location: end,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(endStop)
|
||||
// Only add end endpoint if end city differs from last game stop city
|
||||
let lastGameCity = gameStops.last?.city
|
||||
if lastGameCity == nil || !citiesMatch(end.name, lastGameCity!) {
|
||||
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
|
||||
let endStop = ItineraryStop(
|
||||
city: end.name,
|
||||
state: "",
|
||||
coordinate: end.coordinate,
|
||||
games: [],
|
||||
arrivalDate: endArrival,
|
||||
departureDate: endArrival,
|
||||
location: end,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(endStop)
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Checks if two city names refer to the same city using normalized comparison.
|
||||
private func citiesMatch(_ cityA: String, _ cityB: String) -> Bool {
|
||||
let a = normalizeCityName(cityA)
|
||||
let b = normalizeCityName(cityB)
|
||||
if a == b { return true }
|
||||
return a.contains(b) || b.contains(a)
|
||||
}
|
||||
|
||||
// MARK: - Monotonic Progress Validation
|
||||
|
||||
/// Validates that the route makes generally forward progress toward the end.
|
||||
|
||||
Reference in New Issue
Block a user