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:
Trey t
2026-02-21 22:40:06 -06:00
parent 242634e03c
commit 826eadbc0f
2 changed files with 396 additions and 28 deletions

View File

@@ -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.