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:
@@ -71,9 +71,6 @@ enum GameDAGRouter {
|
||||
/// Buffer time after game ends before we can depart (hours)
|
||||
private static let gameEndBufferHours: Double = 3.0
|
||||
|
||||
/// Maximum days ahead to consider for next game (1 = next day only, 5 = allows multi-day drives)
|
||||
private static let maxDayLookahead = 5
|
||||
|
||||
// MARK: - Route Profile
|
||||
|
||||
/// Captures the key metrics of a route for diversity analysis
|
||||
@@ -176,14 +173,29 @@ enum GameDAGRouter {
|
||||
// Step 2.5: Calculate effective beam width for this dataset size
|
||||
let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)
|
||||
|
||||
// Step 3: Initialize beam with first few days' games as starting points
|
||||
var beam: [[Game]] = []
|
||||
for dayIndex in sortedDays.prefix(maxDayLookahead) {
|
||||
if let dayGames = buckets[dayIndex] {
|
||||
for game in dayGames {
|
||||
beam.append([game])
|
||||
}
|
||||
}
|
||||
// Step 3: Initialize beam from all games so later-starting valid routes
|
||||
// (including anchor-driven routes) are not dropped up front.
|
||||
let initialBeam = sortedGames.map { [$0] }
|
||||
let beamSeedLimit = max(scaledBeamWidth * 2, 50)
|
||||
|
||||
let anchorSeeds = initialBeam.filter { path in
|
||||
guard let game = path.first else { return false }
|
||||
return anchorGameIds.contains(game.id)
|
||||
}
|
||||
let nonAnchorSeeds = initialBeam.filter { path in
|
||||
guard let game = path.first else { return false }
|
||||
return !anchorGameIds.contains(game.id)
|
||||
}
|
||||
|
||||
let reservedForAnchors = min(anchorSeeds.count, beamSeedLimit)
|
||||
let remainingSlots = max(0, beamSeedLimit - reservedForAnchors)
|
||||
let prunedNonAnchorSeeds = remainingSlots > 0
|
||||
? diversityPrune(nonAnchorSeeds, stadiums: stadiums, targetCount: remainingSlots)
|
||||
: []
|
||||
|
||||
var beam = Array(anchorSeeds.prefix(reservedForAnchors)) + prunedNonAnchorSeeds
|
||||
if beam.isEmpty {
|
||||
beam = diversityPrune(initialBeam, stadiums: stadiums, targetCount: beamSeedLimit)
|
||||
}
|
||||
|
||||
// Step 4: Expand beam day by day with early termination
|
||||
@@ -200,13 +212,6 @@ enum GameDAGRouter {
|
||||
|
||||
for path in beam {
|
||||
guard let lastGame = path.last else { continue }
|
||||
let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)
|
||||
|
||||
// Skip if this day is too far ahead for this route
|
||||
if dayIndex > lastGameDay + maxDayLookahead {
|
||||
nextBeam.append(path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try adding each of today's games
|
||||
for candidate in todaysGames {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -75,6 +75,26 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// In explicit date-range mode, fail fast if selected anchors are out of range.
|
||||
let isGameFirstMode = request.preferences.planningMode == .gameFirst
|
||||
if !isGameFirstMode, let explicitRange = request.dateRange {
|
||||
let outOfRangeAnchors = selectedGames.filter { !explicitRange.contains($0.startTime) }
|
||||
if !outOfRangeAnchors.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .dateRangeViolation(games: outOfRangeAnchors),
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "\(outOfRangeAnchors.count) selected game(s) are outside the requested date range",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Generate date ranges (sliding window or single range)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -295,12 +295,42 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
cityName: String,
|
||||
stadiums: [String: Stadium]
|
||||
) -> [Stadium] {
|
||||
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let normalizedCity = normalizeCityName(cityName)
|
||||
return stadiums.values.filter { stadium in
|
||||
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
|
||||
let normalizedStadiumCity = normalizeCityName(stadium.city)
|
||||
if normalizedStadiumCity == normalizedCity { return true }
|
||||
return normalizedStadiumCity.contains(normalizedCity) || normalizedCity.contains(normalizedStadiumCity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes city labels for resilient user-input matching.
|
||||
private func normalizeCityName(_ raw: String) -> String {
|
||||
// Keep the city component before state suffixes like "City, ST".
|
||||
let cityPart = raw.split(separator: ",", maxSplits: 1).first.map(String.init) ?? raw
|
||||
var normalized = cityPart
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: ".", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let aliases: [String: String] = [
|
||||
"nyc": "new york",
|
||||
"new york city": "new york",
|
||||
"la": "los angeles",
|
||||
"sf": "san francisco",
|
||||
"dc": "washington",
|
||||
"washington dc": "washington"
|
||||
]
|
||||
|
||||
if let aliased = aliases[normalized] {
|
||||
normalized = aliased
|
||||
}
|
||||
|
||||
// Collapse repeated spaces after punctuation/alias normalization.
|
||||
return normalized
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Finds stadiums that make forward progress from start to end.
|
||||
///
|
||||
/// A stadium is "directional" if visiting it doesn't significantly increase
|
||||
|
||||
@@ -18,7 +18,7 @@ import CoreLocation
|
||||
/// - date_range: Required. The trip dates.
|
||||
/// - selectedRegions: Optional. Filter to specific regions.
|
||||
/// - useHomeLocation: Whether to start/end from user's home.
|
||||
/// - startLocation: Required if useHomeLocation is true.
|
||||
/// - startLocation: Used as start/end home stop when provided.
|
||||
///
|
||||
/// Output:
|
||||
/// - Success: Ranked list of itinerary options
|
||||
@@ -173,6 +173,11 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 5: Prepare for routing
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let homeLocation: LocationInput? = {
|
||||
guard request.preferences.useHomeLocation else { return nil }
|
||||
return request.startLocation
|
||||
}()
|
||||
|
||||
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
|
||||
// allowRepeatCities internally, which allows it to pick the optimal game
|
||||
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
|
||||
@@ -232,10 +237,13 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
|
||||
for (index, routeGames) in validRoutes.enumerated() {
|
||||
// Build stops for this route
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
|
||||
var stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
if let homeLocation {
|
||||
stops = buildStopsWithHomeEndpoints(home: homeLocation, gameStops: stops)
|
||||
}
|
||||
|
||||
// Calculate travel segments using shared ItineraryBuilder
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
@@ -401,6 +409,45 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
/// Wraps game stops with optional home start/end waypoints.
|
||||
private func buildStopsWithHomeEndpoints(
|
||||
home: LocationInput,
|
||||
gameStops: [ItineraryStop]
|
||||
) -> [ItineraryStop] {
|
||||
guard !gameStops.isEmpty else { return [] }
|
||||
|
||||
let calendar = Calendar.current
|
||||
let firstGameDay = gameStops.first?.arrivalDate ?? Date()
|
||||
let startDay = calendar.date(byAdding: .day, value: -1, to: firstGameDay) ?? firstGameDay
|
||||
|
||||
let startStop = ItineraryStop(
|
||||
city: home.name,
|
||||
state: "",
|
||||
coordinate: home.coordinate,
|
||||
games: [],
|
||||
arrivalDate: startDay,
|
||||
departureDate: startDay,
|
||||
location: home,
|
||||
firstGameStart: nil
|
||||
)
|
||||
|
||||
let lastGameDay = gameStops.last?.departureDate ?? firstGameDay
|
||||
let endDay = calendar.date(byAdding: .day, value: 1, to: lastGameDay) ?? lastGameDay
|
||||
|
||||
let endStop = ItineraryStop(
|
||||
city: home.name,
|
||||
state: "",
|
||||
coordinate: home.coordinate,
|
||||
games: [],
|
||||
arrivalDate: endDay,
|
||||
departureDate: endDay,
|
||||
location: home,
|
||||
firstGameStart: nil
|
||||
)
|
||||
|
||||
return [startStop] + gameStops + [endStop]
|
||||
}
|
||||
|
||||
// MARK: - Route Deduplication
|
||||
|
||||
/// Removes duplicate routes (routes with identical game IDs).
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
||||
// 3. Filter to windows where each selected team has at least 1 home game
|
||||
// 4. Cap at 50 windows (sample if more exist)
|
||||
// 5. For each valid window, find routes using GameDAGRouter with team games as anchors
|
||||
// 5. For each valid window, find routes using anchor strategies + fallback search
|
||||
// 6. Rank by shortest duration + minimal miles
|
||||
// 7. Return top 10 results
|
||||
//
|
||||
@@ -168,9 +168,8 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
|
||||
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
||||
// Collect games in this window
|
||||
// Use one home game per team as anchors (the best one for route efficiency)
|
||||
var gamesInWindow: [Game] = []
|
||||
var anchorGameIds = Set<String>()
|
||||
var gamesByTeamInWindow: [String: [Game]] = [:]
|
||||
var hasAllTeamsInWindow = true
|
||||
|
||||
for teamId in selectedTeamIds {
|
||||
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
||||
@@ -181,32 +180,68 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
if teamGamesInWindow.isEmpty {
|
||||
// Window doesn't have a game for this team - skip this window
|
||||
// This shouldn't happen since we pre-filtered windows
|
||||
continue
|
||||
hasAllTeamsInWindow = false
|
||||
break
|
||||
}
|
||||
|
||||
// Add all games to the pool
|
||||
gamesInWindow.append(contentsOf: teamGamesInWindow)
|
||||
|
||||
// Mark the earliest game as anchor (must visit this team)
|
||||
if let earliestGame = teamGamesInWindow.sorted(by: { $0.startTime < $1.startTime }).first {
|
||||
anchorGameIds.insert(earliestGame.id)
|
||||
}
|
||||
gamesByTeamInWindow[teamId] = teamGamesInWindow
|
||||
}
|
||||
|
||||
// Skip if we don't have anchors for all teams
|
||||
guard anchorGameIds.count == selectedTeamIds.count else { continue }
|
||||
guard hasAllTeamsInWindow else { continue }
|
||||
|
||||
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
|
||||
let gamesInWindow = gamesByTeamInWindow.values.flatMap { $0 }
|
||||
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
|
||||
guard !uniqueGames.isEmpty else { continue }
|
||||
|
||||
// Find routes using GameDAGRouter with anchor games
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
// Primary pass: earliest anchor set for each team.
|
||||
let earliestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
||||
games.min(by: { $0.startTime < $1.startTime })?.id
|
||||
})
|
||||
|
||||
var candidateRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
anchorGameIds: earliestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
var validRoutes = candidateRoutes.filter { route in
|
||||
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
||||
}
|
||||
|
||||
// Fallback pass: avoid over-constraining to earliest anchors.
|
||||
if validRoutes.isEmpty {
|
||||
let latestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
||||
games.max(by: { $0.startTime < $1.startTime })?.id
|
||||
})
|
||||
|
||||
if latestAnchorIds != earliestAnchorIds {
|
||||
let latestAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: latestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
||||
}
|
||||
|
||||
let noAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
||||
|
||||
candidateRoutes = deduplicateRoutes(candidateRoutes)
|
||||
validRoutes = candidateRoutes.filter { route in
|
||||
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
||||
}
|
||||
}
|
||||
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
// Build itineraries for valid routes
|
||||
for routeGames in validRoutes {
|
||||
@@ -228,12 +263,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
dateFormatter.dateFormat = "MMM d"
|
||||
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
||||
|
||||
let teamsVisited = routeGames.compactMap { game -> String? in
|
||||
if anchorGameIds.contains(game.id) {
|
||||
return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ", ")
|
||||
let teamsVisited = orderedTeamLabels(for: routeGames, teams: request.teams)
|
||||
|
||||
let cities = stops.map { $0.city }.joined(separator: " -> ")
|
||||
|
||||
@@ -273,7 +303,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// Deduplicate options (same stops in same order)
|
||||
// Deduplicate options (same stop-day-game structure)
|
||||
let uniqueOptions = deduplicateOptions(allItineraryOptions)
|
||||
|
||||
// Sort by: shortest duration first, then fewest miles
|
||||
@@ -460,14 +490,19 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - Deduplication
|
||||
|
||||
/// Removes duplicate itinerary options (same stops in same order).
|
||||
/// Removes duplicate itinerary options (same stop-day-game structure).
|
||||
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
|
||||
var seen = Set<String>()
|
||||
var unique: [ItineraryOption] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
for option in options {
|
||||
// Create key from stop cities in order
|
||||
let key = option.stops.map { $0.city }.joined(separator: "-")
|
||||
// Key by stop city + day + game IDs to avoid collapsing distinct itineraries.
|
||||
let key = option.stops.map { stop in
|
||||
let day = Int(calendar.startOfDay(for: stop.arrivalDate).timeIntervalSince1970)
|
||||
let gameKey = stop.games.sorted().joined(separator: ",")
|
||||
return "\(stop.city)|\(day)|\(gameKey)"
|
||||
}.joined(separator: "->")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(option)
|
||||
@@ -477,6 +512,43 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
return unique
|
||||
}
|
||||
|
||||
/// Removes duplicate game routes (same game IDs in any order).
|
||||
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
|
||||
}
|
||||
|
||||
/// Returns true if a route includes at least one home game for each selected team.
|
||||
private func routeCoversAllSelectedTeams(_ route: [Game], selectedTeamIds: Set<String>) -> Bool {
|
||||
let homeTeamsInRoute = Set(route.map { $0.homeTeamId })
|
||||
return selectedTeamIds.isSubset(of: homeTeamsInRoute)
|
||||
}
|
||||
|
||||
/// Team labels in first-visit order for itinerary rationale text.
|
||||
private func orderedTeamLabels(for route: [Game], teams: [String: Team]) -> String {
|
||||
var seen = Set<String>()
|
||||
var labels: [String] = []
|
||||
|
||||
for game in route {
|
||||
let teamId = game.homeTeamId
|
||||
guard !seen.contains(teamId) else { continue }
|
||||
seen.insert(teamId)
|
||||
labels.append(teams[teamId]?.abbreviation ?? teamId)
|
||||
}
|
||||
|
||||
return labels.joined(separator: ", ")
|
||||
}
|
||||
|
||||
// MARK: - Trip Duration Calculation
|
||||
|
||||
/// Calculates trip duration in days for an itinerary option.
|
||||
|
||||
@@ -58,8 +58,8 @@ enum TravelEstimator {
|
||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
||||
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim.
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
@@ -103,8 +103,8 @@ enum TravelEstimator {
|
||||
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
||||
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim.
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user