namespace GeoSus.Server; using System.Collections.Concurrent; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; /// /// Service for fetching and processing OpenStreetMap data via Overpass API /// public class OverpassService { private readonly ServerConfig _config; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly ConcurrentDictionary _cache = new(); public OverpassService(ServerConfig config, ILogger logger) { _config = config; _logger = logger; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(config.OverpassTimeoutSec) }; } /// /// Fetch map data for a circular area, with caching /// public async Task GetMapDataAsync(Position center, double radiusMeters) { var cacheKey = $"{center.Lat:F5}_{center.Lon:F5}_{radiusMeters:F0}"; if (_cache.TryGetValue(cacheKey, out var cached)) { _logger.LogDebug("Map data cache hit for {CacheKey}", cacheKey); return cached; } try { var mapData = await FetchMapDataAsync(center, radiusMeters); if (mapData != null) { _cache[cacheKey] = mapData; // Clean old cache entries if too many if (_cache.Count > _config.OverpassCacheMaxEntries) { var oldest = _cache.Keys.First(); _cache.TryRemove(oldest, out _); } } return mapData; } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch Overpass data for {Center}, radius {Radius}m", center, radiusMeters); return null; } } public async Task FetchMapDataAsync(Position center, double radiusMeters) { // Build Overpass QL query var query = BuildOverpassQuery(center, radiusMeters); _logger.LogInformation("Fetching Overpass data for center {Center}, radius {Radius}m", center, radiusMeters); var response = await _httpClient.PostAsync( _config.OverpassApiUrl, new FormUrlEncodedContent(new[] { new KeyValuePair("data", query) }) ); if (!response.IsSuccessStatusCode) { _logger.LogError("Overpass API returned {StatusCode}: {Content}", response.StatusCode, await response.Content.ReadAsStringAsync()); return null; } var json = await response.Content.ReadAsStringAsync(); var overpassResult = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); if (overpassResult == null) { _logger.LogError("Failed to parse Overpass response"); return null; } return ProcessOverpassResult(overpassResult, center, radiusMeters); } private string BuildOverpassQuery(Position center, double radiusMeters) { // Query for: roads, paths, buildings, amenities // We use "around" to get data within radius of center point return $@" [out:json][timeout:{_config.OverpassTimeoutSec}]; ( // Walkable ways (roads, paths, sidewalks) way[""highway""~""footway|path|pedestrian|steps|track|residential|tertiary|secondary|primary|service|living_street|cycleway|bridleway""] (around:{radiusMeters},{center.Lat},{center.Lon}); // Buildings way[""building""] (around:{radiusMeters},{center.Lat},{center.Lon}); relation[""building""] (around:{radiusMeters},{center.Lat},{center.Lon}); // Interesting POIs for task placement node[""amenity""] (around:{radiusMeters},{center.Lat},{center.Lon}); way[""amenity""] (around:{radiusMeters},{center.Lat},{center.Lon}); // Parks and green areas way[""leisure""~""park|playground|garden""] (around:{radiusMeters},{center.Lat},{center.Lon}); relation[""leisure""~""park|playground|garden""] (around:{radiusMeters},{center.Lat},{center.Lon}); ); out body; >; out skel qt; "; } private MapData ProcessOverpassResult(OverpassResult result, Position center, double radiusMeters) { var mapData = new MapData { Center = center, RadiusMeters = radiusMeters, FetchedAt = DateTime.UtcNow }; // Index nodes by ID for quick lookup var nodesById = new Dictionary(); foreach (var element in result.Elements) { if (element.Type == "node" && element.Lat.HasValue && element.Lon.HasValue) { nodesById[element.Id] = new OverpassNode { Id = element.Id, Lat = element.Lat.Value, Lon = element.Lon.Value, Tags = element.Tags }; } } // Process ways foreach (var element in result.Elements.Where(e => e.Type == "way")) { if (element.Nodes == null || element.Nodes.Count < 2) continue; var wayNodes = new List(); foreach (var nodeId in element.Nodes) { if (nodesById.TryGetValue(nodeId, out var node)) { wayNodes.Add(new Position(node.Lat, node.Lon)); } } if (wayNodes.Count < 2) continue; var tags = element.Tags ?? new Dictionary(); // Classify way type if (tags.ContainsKey("building")) { mapData.Buildings.Add(new MapBuilding { Id = $"b_{element.Id}", Outline = wayNodes, BuildingType = tags.GetValueOrDefault("building", "yes"), Name = tags.GetValueOrDefault("name"), Levels = int.TryParse(tags.GetValueOrDefault("building:levels", "1"), out var l) ? l : 1 }); } else if (tags.ContainsKey("highway")) { var highwayType = tags["highway"]; mapData.Pathways.Add(new MapPathway { Id = $"p_{element.Id}", Points = wayNodes, PathType = ClassifyPathType(highwayType), Name = tags.GetValueOrDefault("name"), IsWalkable = IsWalkableHighway(highwayType), Width = EstimatePathWidth(highwayType), IsPubliclyAccessible = IsPubliclyAccessibleWay(highwayType, tags) }); } else if (tags.ContainsKey("leisure")) { mapData.Areas.Add(new MapArea { Id = $"a_{element.Id}", Outline = wayNodes, AreaType = MapAreaType.Park, Name = tags.GetValueOrDefault("name") }); } else if (tags.ContainsKey("amenity")) { // Calculate centroid for POI var centroid = CalculateCentroid(wayNodes); mapData.PointsOfInterest.Add(new MapPOI { Id = $"poi_{element.Id}", Position = centroid, PoiType = ClassifyAmenity(tags["amenity"]), Name = tags.GetValueOrDefault("name") }); } } // Process standalone POI nodes foreach (var element in result.Elements.Where(e => e.Type == "node" && e.Tags != null)) { var tags = element.Tags!; if (tags.ContainsKey("amenity") && element.Lat.HasValue && element.Lon.HasValue) { mapData.PointsOfInterest.Add(new MapPOI { Id = $"poi_{element.Id}", Position = new Position(element.Lat.Value, element.Lon.Value), PoiType = ClassifyAmenity(tags["amenity"]), Name = tags.GetValueOrDefault("name") }); } } // Build reachability graph and compute reachable positions BuildReachabilityData(mapData, center, radiusMeters); _logger.LogInformation( "Processed map data: {Buildings} buildings, {Pathways} pathways, {POIs} POIs, {ReachablePoints} reachable points", mapData.Buildings.Count, mapData.Pathways.Count, mapData.PointsOfInterest.Count, mapData.ReachablePositions.Count); return mapData; } private void BuildReachabilityData(MapData mapData, Position center, double radiusMeters) { // Build a graph of walkable paths and find connected components from center var graph = new PathGraph(); // Add all walkable pathway segments to graph foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable)) { for (int i = 0; i < pathway.Points.Count - 1; i++) { graph.AddEdge(pathway.Points[i], pathway.Points[i + 1]); } } // Find all positions reachable from center (or nearest walkable point to center) var startNode = graph.FindNearestNode(center); if (startNode == null) { _logger.LogWarning("No walkable paths found near center, using all pathway points as reachable"); // Fallback: all pathway points are considered reachable foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable)) { mapData.ReachablePositions.AddRange(pathway.Points); } return; } // BFS from start node to find all reachable nodes var reachable = graph.GetReachableNodes(startNode.Value, radiusMeters); // Filter to only include points within the play area foreach (var pos in reachable) { if (pos.DistanceTo(center) <= radiusMeters) { mapData.ReachablePositions.Add(pos); } } // Also mark pathways as reachable or not foreach (var pathway in mapData.Pathways) { var reachablePoints = pathway.Points.Count(p => mapData.ReachablePositions.Any(rp => rp.DistanceTo(p) < 5)); pathway.IsFullyReachable = reachablePoints == pathway.Points.Count; pathway.IsPartiallyReachable = reachablePoints > 0; } _logger.LogDebug("Reachability analysis: {Reachable} of {Total} positions are reachable from center", mapData.ReachablePositions.Count, mapData.Pathways.Sum(p => p.Points.Count)); } private PathType ClassifyPathType(string highway) { return highway switch { "footway" or "pedestrian" or "path" => PathType.Footway, "steps" => PathType.Steps, "cycleway" => PathType.Cycleway, "residential" or "living_street" => PathType.Residential, "service" => PathType.Service, "tertiary" or "secondary" or "primary" => PathType.Road, "track" or "bridleway" => PathType.Track, _ => PathType.Other }; } private bool IsWalkableHighway(string highway) { // Everything except motorways is considered walkable return highway switch { "motorway" or "motorway_link" or "trunk" or "trunk_link" => false, _ => true }; } /// /// P11: Strict public-access check for task placement. Returns true only /// when the way is unambiguously safe and legal for a foot player to /// stand on. Bandwidth's directive: "brutal check even at the cost of /// quality" - we'd rather refuse 80% of marginal candidates than place /// one task on a private driveway or a busy primary road. The `any /// pathway point` fallback in GetRandomReachablePosition still works for /// rural/forest scenarios where this filter rejects everything. /// /// Hard rejects: /// - access tag in {private, no, customers, permit, forestry, /// agricultural, military, employees, delivery} /// - foot tag in {no, private, discouraged} /// - highway types known to be unsafe foot terrain (busy roads) or /// ambiguous (service ways often = parking lots / driveways unless /// explicitly tagged otherwise). /// private bool IsPubliclyAccessibleWay(string highway, Dictionary tags) { // Hard-reject the highway types we never want a task on. switch (highway) { case "motorway": case "motorway_link": case "trunk": case "trunk_link": case "primary": case "primary_link": case "secondary": case "secondary_link": case "tertiary": case "tertiary_link": // Roads people drive fast on - even if foot is technically // allowed, putting a task target here invites tragedy. return false; case "construction": case "proposed": case "abandoned": case "razed": // Not even a real path right now. return false; case "raceway": case "bus_guideway": case "escape": // Specialized infrastructure, never appropriate. return false; } // Access tag: explicit private/restricted = reject. if (tags.TryGetValue("access", out var access)) { access = access.ToLowerInvariant(); switch (access) { case "private": case "no": case "customers": case "permit": case "forestry": case "agricultural": case "military": case "employees": case "delivery": case "destination": return false; } } // foot tag: explicit foot=no = reject regardless of highway type. if (tags.TryGetValue("foot", out var foot)) { foot = foot.ToLowerInvariant(); switch (foot) { case "no": case "private": case "discouraged": return false; } } // motor_vehicle / vehicle tags can flag a way as no-vehicles only, // which usually implies pedestrians are welcome - we don't reject // on those, just note them as positive evidence in the explicit- // approval branch below. // Service ways: commonly driveways, parking lots, alleys. Reject by // default unless the access/foot/service tag explicitly opens it up. if (highway == "service") { // service=alley is generally walkable; service=driveway, parking_aisle, // emergency_access are not. if (tags.TryGetValue("service", out var serviceType)) { serviceType = serviceType.ToLowerInvariant(); if (serviceType == "alley") return true; // All other service subtypes default to "no" without explicit access=yes. if (access != null && (access == "yes" || access == "permissive" || access == "public")) return true; return false; } // No service subtag - too ambiguous, reject. // (Players can still walk on these IRL, but for "absolutely public" // we want clearer signal.) if (access == "yes" || access == "permissive" || access == "public") return true; return false; } // bridleway: technically horse paths; foot may or may not be allowed. // Require explicit foot=yes/designated to opt in. if (highway == "bridleway") { if (foot != null && (foot == "yes" || foot == "designated" || foot == "permissive")) return true; return false; } // Highway types we trust as inherently public foot terrain. switch (highway) { case "footway": case "path": case "pedestrian": case "steps": case "cycleway": case "residential": case "living_street": case "track": return true; } // Everything else: reject by default. The brutal-mode point is to // not gamble on tags we don't recognize. return false; } private double EstimatePathWidth(string highway) { return highway switch { "footway" or "path" => 1.5, "pedestrian" => 5.0, "steps" => 2.0, "cycleway" => 2.0, "residential" => 6.0, "living_street" => 5.0, "service" => 4.0, "tertiary" => 7.0, "secondary" => 9.0, "primary" => 11.0, _ => 3.0 }; } private MapPOIType ClassifyAmenity(string amenity) { return amenity switch { "cafe" or "restaurant" or "fast_food" or "bar" or "pub" => MapPOIType.FoodDrink, "bank" or "atm" => MapPOIType.Finance, "pharmacy" or "hospital" or "clinic" or "doctors" => MapPOIType.Health, "school" or "university" or "library" => MapPOIType.Education, "fuel" or "parking" => MapPOIType.Transport, "shop" or "supermarket" or "convenience" => MapPOIType.Shop, "toilets" or "bench" or "drinking_water" => MapPOIType.Amenity, _ => MapPOIType.Other }; } private Position CalculateCentroid(List points) { if (points.Count == 0) return new Position(0, 0); var lat = points.Average(p => p.Lat); var lon = points.Average(p => p.Lon); return new Position(lat, lon); } /// /// Get a random reachable position suitable for placing a task or repair station. /// /// P11 cascade (each tier falls through to the next on empty result): /// 1. Reachable AND on a publicly-accessible way (brutal mode preferred). /// 2. Any reachable position (covers the rare case where reachability /// analysis hit a way we don't deem "absolutely public" but is /// still pathway-connected). /// 3. Any walkable pathway point in radius (used in dense urban OR /// rural - the fallback Bandwidth specifically asked for: "When /// starting a game when there are almost no roads at all (at a /// field or in a forest) we can place tasks wherever"). /// public Position? GetRandomReachablePosition(MapData mapData, Random random, double minDistFromCenter = 0) { // Tier 1: reachable AND on a publicly-accessible way. var publicWayPoints = CollectPubliclyAccessiblePoints(mapData); var candidates = mapData.ReachablePositions .Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter) .Where(p => publicWayPoints.Contains(p)) .ToList(); if (candidates.Count == 0) { // Tier 2: any reachable, ignoring the public-only filter. candidates = mapData.ReachablePositions .Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter) .ToList(); } if (candidates.Count == 0) { // Tier 3: any walkable pathway point in radius (rural fallback). candidates = mapData.Pathways .Where(p => p.IsWalkable) .SelectMany(p => p.Points) .Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters) .ToList(); } if (candidates.Count == 0) return null; return candidates[random.Next(candidates.Count)]; } /// /// Set of all positions that lie on a publicly-accessible pathway. Built /// once per call site (cheap; pathway count is bounded by the radius). /// HashSet of Position relies on Position.Equals/GetHashCode being /// content-based; if that ever changes, swap to a custom comparer. /// private HashSet CollectPubliclyAccessiblePoints(MapData mapData) { var set = new HashSet(); foreach (var path in mapData.Pathways) { if (!path.IsPubliclyAccessible) continue; foreach (var p in path.Points) set.Add(p); } return set; } /// /// Get multiple well-distributed reachable positions (e.g., for placing /// multiple tasks). Same P11 tier cascade as GetRandomReachablePosition: /// public-only first, fall through to reachable-any, then any walkable /// pathway point if even that's empty. /// public List GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20) { var result = new List(); // Tier 1: reachable AND public. var publicWayPoints = CollectPubliclyAccessiblePoints(mapData); var available = mapData.ReachablePositions .Where(p => publicWayPoints.Contains(p)) .ToList(); // Tier 2: any reachable. if (available.Count == 0) available = mapData.ReachablePositions.ToList(); // Tier 3: any walkable pathway point in radius. Logged so the server // op can see when the brutal filter exhausted itself - useful in // testing to know whether the area has decent OSM coverage. if (available.Count == 0) { available = mapData.Pathways .Where(p => p.IsWalkable) .SelectMany(p => p.Points) .Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters) .Distinct() .ToList(); if (available.Count > 0) _logger.LogInformation("[Overpass] Task placement falling back to any-pathway-point - no publicly-accessible reachable geometry within radius {R}m of {Lat},{Lon}", mapData.RadiusMeters, mapData.Center.Lat, mapData.Center.Lon); } // Shuffle available positions for (int i = available.Count - 1; i > 0; i--) { int j = random.Next(i + 1); (available[i], available[j]) = (available[j], available[i]); } foreach (var pos in available) { if (result.Count >= count) break; // Check minimum spacing from already selected positions bool tooClose = result.Any(r => r.DistanceTo(pos) < minSpacing); if (!tooClose) { result.Add(pos); } } // If we couldn't find enough spaced positions, fill with any available if (result.Count < count) { foreach (var pos in available) { if (result.Count >= count) break; if (!result.Contains(pos)) { result.Add(pos); } } } return result; } /// /// Get positions near POIs (better for task placement as they have semantic meaning) /// public List GetPOIBasedPositions(MapData mapData, int count, Random random) { var result = new List(); // Prefer POIs that are reachable var reachablePOIs = mapData.PointsOfInterest .Where(poi => mapData.ReachablePositions.Any(rp => rp.DistanceTo(poi.Position) < 10)) .ToList(); // Shuffle for (int i = reachablePOIs.Count - 1; i > 0; i--) { int j = random.Next(i + 1); (reachablePOIs[i], reachablePOIs[j]) = (reachablePOIs[j], reachablePOIs[i]); } foreach (var poi in reachablePOIs.Take(count)) { result.Add(poi.Position); } // Fill remaining with distributed positions if (result.Count < count) { var additional = GetDistributedReachablePositions(mapData, count - result.Count, random); result.AddRange(additional); } return result; } } #region Overpass API Response Types internal class OverpassResult { [JsonPropertyName("elements")] public List Elements { get; set; } = new(); } internal class OverpassElement { [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("id")] public long Id { get; set; } [JsonPropertyName("lat")] public double? Lat { get; set; } [JsonPropertyName("lon")] public double? Lon { get; set; } [JsonPropertyName("nodes")] public List? Nodes { get; set; } [JsonPropertyName("tags")] public Dictionary? Tags { get; set; } } internal class OverpassNode { public long Id { get; set; } public double Lat { get; set; } public double Lon { get; set; } public Dictionary? Tags { get; set; } } #endregion #region Path Graph for Reachability internal class PathGraph { private readonly Dictionary> _adjacency = new(); private readonly List _allNodes = new(); public void AddEdge(Position a, Position b) { if (!_adjacency.ContainsKey(a)) { _adjacency[a] = new HashSet(); _allNodes.Add(a); } if (!_adjacency.ContainsKey(b)) { _adjacency[b] = new HashSet(); _allNodes.Add(b); } _adjacency[a].Add(b); _adjacency[b].Add(a); } public Position? FindNearestNode(Position target) { if (_allNodes.Count == 0) return null; Position? nearest = null; double minDist = double.MaxValue; foreach (var node in _allNodes) { var dist = node.DistanceTo(target); if (dist < minDist) { minDist = dist; nearest = node; } } return nearest; } public HashSet GetReachableNodes(Position start, double maxDistance) { var visited = new HashSet(); var queue = new Queue(); queue.Enqueue(start); visited.Add(start); while (queue.Count > 0) { var current = queue.Dequeue(); if (!_adjacency.TryGetValue(current, out var neighbors)) continue; foreach (var neighbor in neighbors) { if (!visited.Contains(neighbor) && neighbor.DistanceTo(start) <= maxDistance) { visited.Add(neighbor); queue.Enqueue(neighbor); } } } return visited; } } #endregion