Files

277 lines
11 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 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.
/// </summary>
public sealed class RoadConnectivityTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
private readonly ITestOutputHelper _out;
public RoadConnectivityTests(WorldCache cache, ITestOutputHelper output)
{
_cache = cache;
_out = output;
}
// ── 1. Settlement connectivity ───────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<string>();
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. Bridgeroad continuity ────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<int, Polyline>();
foreach (var road in world.Roads)
roadsById.TryAdd(road.Id, road);
var broken = new List<string>();
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 ─────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<Polyline>>();
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<Polyline>();
list.Add(road);
}
var duplicates = new List<string>();
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 ─────────
/// <summary>
/// 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).
/// </summary>
[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<string>();
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);
}
}