b451f83174
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
277 lines
11 KiB
C#
277 lines
11 KiB
C#
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. Bridge–road 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);
|
||
}
|
||
}
|