This commit is contained in:
Bandwidth
2026-04-26 18:33:24 +02:00
parent e9c85ac8d3
commit 796ba0906d
5 changed files with 651 additions and 40 deletions

View File

@@ -196,7 +196,8 @@ out skel qt;
PathType = ClassifyPathType(highwayType),
Name = tags.GetValueOrDefault("name"),
IsWalkable = IsWalkableHighway(highwayType),
Width = EstimatePathWidth(highwayType)
Width = EstimatePathWidth(highwayType),
IsPubliclyAccessible = IsPubliclyAccessibleWay(highwayType, tags)
});
}
else if (tags.ContainsKey("leisure"))
@@ -326,6 +327,143 @@ out skel qt;
_ => 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)
{
@@ -369,49 +507,116 @@ out skel qt;
}
/// <summary>
/// Get a random reachable position suitable for placing a task or repair station
/// 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)
{
// Fallback to any pathway point
// 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)
/// 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>();
var available = mapData.ReachablePositions.ToList();
// 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)