using Theriapolis.Core; using Theriapolis.Core.Util; using Theriapolis.Core.World; using Theriapolis.Core.World.Polylines; using Xunit; using Xunit.Abstractions; namespace Theriapolis.Tests.Worldgen; /// /// Road network connectivity tests: /// 1. Every non-POI settlement has a road endpoint within reach. /// 2. Every bridge has road geometry on both sides (not truncated). /// 3. No duplicate road polylines connect the same settlement pair. /// public sealed class RoadConnectivityTests : IClassFixture { private readonly WorldCache _cache; private readonly ITestOutputHelper _out; public RoadConnectivityTests(WorldCache cache, ITestOutputHelper output) { _cache = cache; _out = output; } // ── 1. Settlement connectivity ─────────────────────────────────────────── /// /// Every non-POI settlement must have at least one road polyline endpoint /// within 2 tiles of its center. This catches settlements that are /// topologically in the network but have no visible road reaching them. /// [Theory] [InlineData(0xCAFEBABEUL)] [InlineData(0xDEADBEEFUL)] [InlineData(0x12345678UL)] public void AllSettlements_HaveRoadEndpointNearby(ulong seed) { var ctx = _cache.Get(seed); var world = ctx.World; float maxDist = C.WORLD_TILE_PIXELS * 2.5f; // 2.5 tiles float maxDistSq = maxDist * maxDist; var disconnected = new List(); foreach (var settle in world.Settlements) { if (settle.IsPoi) continue; var center = new Vec2(settle.WorldPixelX, settle.WorldPixelY); bool found = false; foreach (var road in world.Roads) { if (road.Points.Count < 2) continue; if (Vec2.DistSq(road.Points[0], center) < maxDistSq || Vec2.DistSq(road.Points[^1], center) < maxDistSq) { found = true; break; } } if (!found) { // Find closest endpoint for diagnostic output float bestDist = float.MaxValue; foreach (var road in world.Roads) { if (road.Points.Count < 2) continue; bestDist = MathF.Min(bestDist, Vec2.Dist(road.Points[0], center)); bestDist = MathF.Min(bestDist, Vec2.Dist(road.Points[^1], center)); } disconnected.Add( $" {settle.Name ?? "?"} (Tier {settle.Tier}, tile {settle.TileX},{settle.TileY}) " + $"— nearest endpoint {bestDist:F0}px away ({bestDist / C.WORLD_TILE_PIXELS:F1} tiles)"); } } if (disconnected.Count > 0) { _out.WriteLine($"Disconnected settlements ({disconnected.Count}):"); foreach (var line in disconnected) _out.WriteLine(line); } Assert.Empty(disconnected); } // ── 2. Bridge–road continuity ──────────────────────────────────────────── /// /// Every bridge must reference a valid road, and the road must have enough /// geometry to visually support the bridge (not just 1-2 segments total). /// Bridges at road polyline termini are acceptable — the "other side" is /// covered by a different polyline from SplitByExistingFeature. /// [Theory] [InlineData(0xCAFEBABEUL)] [InlineData(0xDEADBEEFUL)] [InlineData(0x12345678UL)] public void AllBridges_ReferenceValidRoads(ulong seed) { var ctx = _cache.Get(seed); var world = ctx.World; // Index roads by Id for fast lookup var roadsById = new Dictionary(); foreach (var road in world.Roads) roadsById.TryAdd(road.Id, road); var broken = new List(); foreach (var bridge in world.Bridges) { if (!roadsById.TryGetValue(bridge.RoadId, out var road)) { broken.Add($" Bridge at ({bridge.WorldPixelX:F0},{bridge.WorldPixelY:F0}) references missing road Id={bridge.RoadId}"); continue; } // A road with < 5 segments is too short to meaningfully support a // bridge — the deck would be the entire road. if (road.Points.Count < 5) { broken.Add( $" Bridge at ({bridge.WorldPixelX:F0},{bridge.WorldPixelY:F0}) on road {road.Id} " + $"({road.RoadClassification}) — road only has {road.Points.Count} points"); } } if (broken.Count > 0) { _out.WriteLine($"Invalid bridges ({broken.Count}/{world.Bridges.Count}):"); foreach (var line in broken) _out.WriteLine(line); } Assert.Empty(broken); } // ── 3. No geometrically redundant roads ───────────────────────────────── /// /// When multiple road polylines connect the same settlement pair (expected /// from SplitByExistingFeature), they should cover DIFFERENT geographic /// stretches. If two same-pair polylines have endpoints close to each other, /// they're geometrically redundant — one should have been merged or subsumed /// during cleanup. /// [Theory] [InlineData(0xCAFEBABEUL)] [InlineData(0xDEADBEEFUL)] [InlineData(0x12345678UL)] public void NoDuplicateRoads_BetweenSameSettlementPair(ulong seed) { var ctx = _cache.Get(seed); var world = ctx.World; float overlapDist = C.POLYLINE_MERGE_DIST; // 80px — if endpoints are this close, they overlap float overlapDistSq = overlapDist * overlapDist; // Group roads by their unordered settlement pair + classification var groups = new Dictionary<(int, int, RoadType), List>(); foreach (var road in world.Roads) { if (road.FromSettlementId < 0 || road.ToSettlementId < 0) continue; int a = Math.Min(road.FromSettlementId, road.ToSettlementId); int b = Math.Max(road.FromSettlementId, road.ToSettlementId); var key = (a, b, road.RoadClassification); if (!groups.TryGetValue(key, out var list)) groups[key] = list = new List(); list.Add(road); } var duplicates = new List(); foreach (var (key, roads) in groups) { if (roads.Count < 2) continue; // Check every pair for geometric overlap. // Skip consecutive-ID pairs: those are split segments from a single // A* edge (SplitByExistingFeature), not duplicate routes. They share // a junction endpoint by design. for (int i = 0; i < roads.Count; i++) for (int j = i + 1; j < roads.Count; j++) { // Consecutive IDs come from the same edge's split — not redundant if (Math.Abs(roads[i].Id - roads[j].Id) == 1) continue; var ptsA = roads[i].Points; var ptsB = roads[j].Points; if (ptsA.Count < 2 || ptsB.Count < 2) continue; // Check if A's start is near any of B's endpoints AND // A's end is near any of B's endpoints. bool startOverlap = Vec2.DistSq(ptsA[0], ptsB[0]) < overlapDistSq || Vec2.DistSq(ptsA[0], ptsB[^1]) < overlapDistSq; bool endOverlap = Vec2.DistSq(ptsA[^1], ptsB[0]) < overlapDistSq || Vec2.DistSq(ptsA[^1], ptsB[^1]) < overlapDistSq; if (startOverlap && endOverlap) { var settleA = world.Settlements.FirstOrDefault(s => s.Id == key.Item1); var settleB = world.Settlements.FirstOrDefault(s => s.Id == key.Item2); duplicates.Add( $" {settleA?.Name ?? $"#{key.Item1}"} <-> {settleB?.Name ?? $"#{key.Item2}"}: " + $"{key.Item3} ids {roads[i].Id} & {roads[j].Id} " + $"({ptsA.Count} pts vs {ptsB.Count} pts, endpoints within {overlapDist:F0}px)"); } } } if (duplicates.Count > 0) { _out.WriteLine($"Geometrically redundant road pairs ({duplicates.Count}):"); foreach (var line in duplicates) _out.WriteLine(line); } Assert.Empty(duplicates); } // ── 4. Road segments near settlements aren't excessively fanning ───────── /// /// At each settlement, count the number of distinct road polyline endpoints /// within 3 tiles. Settlements shouldn't have more road endpoints than their /// degree in the MST + shortcuts would produce. A reasonable upper bound is /// 12 for any single settlement (even a capital in a dense network). /// [Theory] [InlineData(0xCAFEBABEUL)] [InlineData(0xDEADBEEFUL)] [InlineData(0x12345678UL)] public void NoSettlement_HasExcessiveRoadFanout(ulong seed) { var ctx = _cache.Get(seed); var world = ctx.World; float radiusSq = (C.WORLD_TILE_PIXELS * 3f) * (C.WORLD_TILE_PIXELS * 3f); const int maxEndpoints = 12; var excessive = new List(); foreach (var settle in world.Settlements) { if (settle.IsPoi) continue; var center = new Vec2(settle.WorldPixelX, settle.WorldPixelY); int endpointCount = 0; foreach (var road in world.Roads) { if (road.Points.Count < 2) continue; if (Vec2.DistSq(road.Points[0], center) < radiusSq) endpointCount++; if (Vec2.DistSq(road.Points[^1], center) < radiusSq) endpointCount++; } if (endpointCount > maxEndpoints) { excessive.Add( $" {settle.Name ?? "?"} (Tier {settle.Tier}, tile {settle.TileX},{settle.TileY}) " + $"— {endpointCount} road endpoints within 3 tiles"); } } if (excessive.Count > 0) { _out.WriteLine($"Settlements with excessive fan-out ({excessive.Count}):"); foreach (var line in excessive) _out.WriteLine(line); } Assert.Empty(excessive); } }