Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
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>
2026-04-30 20:40:51 -07:00

277 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}