Files
Server/OverpassService.cs
Bandwidth 796ba0906d fixes 2
2026-04-26 18:33:24 +02:00

792 lines
28 KiB
C#

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;
/// <summary>
/// Service for fetching and processing OpenStreetMap data via Overpass API
/// </summary>
public class OverpassService
{
private readonly ServerConfig _config;
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, MapData> _cache = new();
public OverpassService(ServerConfig config, ILogger logger)
{
_config = config;
_logger = logger;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(config.OverpassTimeoutSec)
};
}
/// <summary>
/// Fetch map data for a circular area, with caching
/// </summary>
public async Task<MapData?> 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<MapData?> 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<string, string>("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<OverpassResult>(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<long, OverpassNode>();
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<Position>();
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<string, string>();
// 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
};
}
/// <summary>
/// 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).
/// </summary>
private bool IsPubliclyAccessibleWay(string highway, Dictionary<string, string> 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<Position> 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);
}
/// <summary>
/// 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").
/// </summary>
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)];
}
/// <summary>
/// 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.
/// </summary>
private HashSet<Position> CollectPubliclyAccessiblePoints(MapData mapData)
{
var set = new HashSet<Position>();
foreach (var path in mapData.Pathways)
{
if (!path.IsPubliclyAccessible) continue;
foreach (var p in path.Points)
set.Add(p);
}
return set;
}
/// <summary>
/// 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.
/// </summary>
public List<Position> GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20)
{
var result = new List<Position>();
// 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;
}
/// <summary>
/// Get positions near POIs (better for task placement as they have semantic meaning)
/// </summary>
public List<Position> GetPOIBasedPositions(MapData mapData, int count, Random random)
{
var result = new List<Position>();
// 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<OverpassElement> 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<long>? Nodes { get; set; }
[JsonPropertyName("tags")]
public Dictionary<string, string>? Tags { get; set; }
}
internal class OverpassNode
{
public long Id { get; set; }
public double Lat { get; set; }
public double Lon { get; set; }
public Dictionary<string, string>? Tags { get; set; }
}
#endregion
#region Path Graph for Reachability
internal class PathGraph
{
private readonly Dictionary<Position, HashSet<Position>> _adjacency = new();
private readonly List<Position> _allNodes = new();
public void AddEdge(Position a, Position b)
{
if (!_adjacency.ContainsKey(a))
{
_adjacency[a] = new HashSet<Position>();
_allNodes.Add(a);
}
if (!_adjacency.ContainsKey(b))
{
_adjacency[b] = new HashSet<Position>();
_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<Position> GetReachableNodes(Position start, double maxDistance)
{
var visited = new HashSet<Position>();
var queue = new Queue<Position>();
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